feat(adapters): add /new <project> quick switch and unify project selection

- Add matchProject() to http-client for fuzzy project matching by
  index or name
- Extract startNewSession() in both adapters to share reset + match +
  create logic
- Project selection replies now go through startNewSession(), creating
  a clean new session instead of continuing the old one
- Support: /new 2 (by index), /new backend (by name), /new (default)
- Add usage hint in /projects list output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 21:25:23 +08:00
parent b4ad6f15de
commit 41fc58d73a
3 changed files with 123 additions and 60 deletions

View File

@ -41,4 +41,34 @@ export class AdapterHttpClient {
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
}
/**
* Match a project by index (1-based) or fuzzy name from recent projects.
* Returns { project, ambiguous[] } ambiguous is set when multiple projects match.
*/
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
const projects = await this.listRecentProjects()
// Try as 1-based index
const num = parseInt(query, 10)
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
return { project: projects[num - 1] }
}
const q = query.toLowerCase()
// Exact project name match
const exact = projects.find(p => p.projectName.toLowerCase() === q)
if (exact) return { project: exact }
// Fuzzy: name or path contains query
const matches = projects.filter(p =>
p.projectName.toLowerCase().includes(q) ||
p.realPath.toLowerCase().includes(q)
)
if (matches.length === 1) return { project: matches[0] }
if (matches.length > 1) return { ambiguous: matches }
return {}
}
}

View File

@ -252,12 +252,54 @@ async function showProjectPicker(chatId: string): Promise<void> {
`${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
)
pendingProjectSelection.set(chatId, true)
await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`)
await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
} catch (err) {
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
}
}
async function startNewSession(chatId: string, query?: string): Promise<void> {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
chatStates.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) {
await sendText(chatId,
`✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. **${p.projectName}** — ${p.realPath}`).join('\n')
await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await sendText(chatId, `${err instanceof Error ? err.message : String(err)}`)
}
} else {
const workDir = config.defaultProjectDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
}
} else {
await showProjectPicker(chatId)
}
}
}
// ---------- server message handler ----------
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
@ -436,24 +478,9 @@ async function handleMessage(data: any): Promise<void> {
if (!text) return
// Handle commands
if (text === '/new' || text === '新会话') {
bridge.resetSession(chatId)
sessionStore.delete(chatId)
chatStates.delete(chatId)
accumulatedText.delete(chatId)
buffers.get(chatId)?.reset()
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
const workDir = config.defaultProjectDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
}
} else {
await showProjectPicker(chatId)
}
if (text === '/new' || text === '新会话' || text.startsWith('/new ')) {
const arg = text.startsWith('/new ') ? text.slice(5).trim() : ''
await startNewSession(chatId, arg || undefined)
return
}
if (text === '/stop' || text === '停止') {
@ -468,20 +495,7 @@ async function handleMessage(data: any): Promise<void> {
// Check if user is responding to project selection
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await sendText(chatId, '请输入有效的编号。')
await startNewSession(chatId, text.trim())
return
}

View File

@ -147,7 +147,7 @@ async function showProjectPicker(chatId: string): Promise<void> {
)
pendingProjectSelection.set(chatId, true)
await bot.api.sendMessage(numericChatId,
`选择项目(回复编号):\n\n${lines.join('\n\n')}`)
`选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
@ -266,14 +266,19 @@ bot.command('start', (ctx) => {
ctx.reply(
'👋 Claude Code Bot 已就绪。\n\n' +
'命令:\n' +
'/projects — 选择/切换项目\n' +
'/new — 新建会话\n' +
'/new — 新建会话(使用默认项目)\n' +
'/new <名称或编号> — 新建会话并指定项目\n' +
'/projects — 查看项目列表\n' +
'/stop — 停止生成'
)
})
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
/** Reset session state and start a new session for chatId.
* If `query` is provided, match a project by index or name;
* otherwise use defaultProjectDir or show the picker. */
async function startNewSession(chatId: string, query?: string): Promise<void> {
const numericChatId = Number(chatId)
bridge.resetSession(chatId)
sessionStore.delete(chatId)
placeholders.delete(chatId)
@ -282,15 +287,43 @@ bot.command('new', async (ctx) => {
buffers.delete(chatId)
pendingProjectSelection.delete(chatId)
const workDir = config.defaultProjectDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await bot.api.sendMessage(Number(chatId), '✅ 已新建会话,可以开始对话了。')
if (query) {
try {
const { project, ambiguous } = await httpClient.matchProject(query)
if (project) {
const ok = await createSessionForChat(chatId, project.realPath)
if (ok) {
await bot.api.sendMessage(numericChatId,
`✅ 已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
}
return
}
if (ambiguous) {
const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName}${p.realPath}`).join('\n')
await bot.api.sendMessage(numericChatId, `匹配到多个项目,请更精确:\n\n${list}`)
return
}
await bot.api.sendMessage(numericChatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
} catch (err) {
await bot.api.sendMessage(numericChatId,
`${err instanceof Error ? err.message : String(err)}`)
}
} else {
await showProjectPicker(chatId)
const workDir = config.defaultProjectDir
if (workDir) {
const ok = await createSessionForChat(chatId, workDir)
if (ok) {
await bot.api.sendMessage(numericChatId, '✅ 已新建会话,可以开始对话了。')
}
} else {
await showProjectPicker(chatId)
}
}
}
bot.command('new', async (ctx) => {
const chatId = String(ctx.chat.id)
await startNewSession(chatId, ctx.match?.trim() || undefined)
})
bot.command('projects', async (ctx) => {
@ -330,23 +363,9 @@ bot.on('message:text', (ctx) => {
}
enqueue(chatId, async () => {
// Check if user is responding to project selection
// Project selection pending — treat input as /new <query>
if (pendingProjectSelection.has(chatId)) {
const num = parseInt(text, 10)
if (num >= 1) {
try {
const projects = await httpClient.listRecentProjects()
const selected = projects[num - 1]
if (selected) {
pendingProjectSelection.delete(chatId)
await createSessionForChat(chatId, selected.realPath)
await bot.api.sendMessage(Number(chatId),
`✅ 已选择 ${selected.projectName}。现在可以开始对话了。`)
return
}
} catch { /* fall through */ }
}
await bot.api.sendMessage(Number(chatId), '请输入有效的编号。')
await startNewSession(chatId, text.trim())
return
}