From 297f913418e0ce9116526b0c8c8f66cd4472fc55 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 18:39:36 +0800 Subject: [PATCH 1/2] feat: allow empty btw command --- .changeset/open-empty-btw-panel.md | 5 +++ apps/kimi-code/src/tui/commands/btw.ts | 5 --- .../src/tui/components/panes/btw-panel.ts | 4 ++ .../src/tui/controllers/btw-panel.ts | 10 +++-- .../test/tui/commands/resolve.test.ts | 5 +++ .../test/tui/kimi-tui-message-flow.test.ts | 43 +++++++++++++++++-- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 8 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 .changeset/open-empty-btw-panel.md diff --git a/.changeset/open-empty-btw-panel.md b/.changeset/open-empty-btw-panel.md new file mode 100644 index 00000000..bd3c4279 --- /dev/null +++ b/.changeset/open-empty-btw-panel.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Allow `/btw` to open the side-channel panel before entering a question. diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index 5b857151..a55ceca0 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -4,11 +4,6 @@ import type { SlashCommandHost } from './dispatch'; export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { const prompt = args.trim(); - if (prompt.length === 0) { - host.showError('Usage: /btw '); - return; - } - const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 252438e0..990f6b59 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -233,6 +233,10 @@ export class BtwPanelComponent implements Component { return this.currentTurn()?.phase === 'running'; } + isEmpty(): boolean { + return this.turns.length === 0; + } + scroll(direction: 'up' | 'down'): boolean { if (this.maxScrollTop <= 0) return false; const current = this.followTail ? this.maxScrollTop : this.scrollTop; diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 1ec1eab4..a1fc1e14 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -52,7 +52,7 @@ export class BtwPanelController { clear(): void { const active = this.active; - if (active?.panel.isRunning()) { + if (active !== undefined && this.shouldCancelOnUnmount(active.panel)) { void this.cancelAgent(active.agentId); } this.active = undefined; @@ -64,9 +64,9 @@ export class BtwPanelController { closeOrCancel(): boolean { const active = this.active; if (active === undefined) return false; - const wasRunning = active.panel.isRunning(); + const shouldCancel = this.shouldCancelOnUnmount(active.panel); this.close(active.panel); - if (wasRunning) { + if (shouldCancel) { void this.cancelAgent(active.agentId); } return true; @@ -213,6 +213,10 @@ export class BtwPanelController { }); } + private shouldCancelOnUnmount(panel: BtwPanelComponent): boolean { + return panel.isRunning() || panel.isEmpty(); + } + private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { const previousAgentId = this.host.harness.interactiveAgentId; this.host.harness.interactiveAgentId = agentId; diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index ad19cdc5..e0538ad4 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -36,6 +36,11 @@ describe('resolveSlashCommandInput', () => { args: 'New title', }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); + expect(resolve('/btw')).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: '', + }); expect(resolve('/btw what are you doing?')).toMatchObject({ kind: 'builtin', name: 'btw', diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 4655b342..7ebf0510 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1366,6 +1366,42 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); }); + it('opens /btw without a question and sends the first panel input to a side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/btw'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + + driver.handleUserInput('你现在在做啥?'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('你现在在做啥?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Q: 你现在在做啥?'); + }); + + it('cancels an unused /btw side agent when closing an empty panel', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/btw'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + }); + it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -1921,14 +1957,15 @@ describe('KimiTUI message flow', () => { expect(renderedPanel).toContain('answer from new side agent'); }); - it('does not run /btw without a question or selected model', async () => { + it('does not run /btw without a selected model', async () => { const { driver, session } = await makeDriver(); + driver.state.appState.model = ''; driver.handleUserInput('/btw'); expect(session.startBtw).not.toHaveBeenCalled(); - expect(stripSgr(renderTranscript(driver))).toContain('Usage: /btw '); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); - driver.state.appState.model = ''; driver.handleUserInput('/btw 现在在做什么?'); expect(session.startBtw).not.toHaveBeenCalled(); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index be9952b5..04042459 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -108,7 +108,7 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | Command | Alias | Description | Always available | | --- | --- | --- | --- | | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | -| `/btw ` | — | Open a side-channel conversation in a forked subagent without steering the current main agent turn. | Yes | +| `/btw [question]` | — | Open a side-channel conversation in a forked subagent without steering the current main agent turn. Without a question, it opens the panel and waits for input. | Yes | | `/usage` | — | Show token usage, context consumption, and quota information. | Yes | | `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | | `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 2c05eb81..f301cc26 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -108,7 +108,7 @@ Prompt 模式在 goal 完成时以退出码 `0` 退出,在 blocked 时以 `3` | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | -| `/btw <问题>` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次。 | 是 | +| `/btw [问题]` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次;不带问题时会先打开面板并等待输入。 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | From c1ae2bb789ccd4d55b6657cb20011b85fa87fefa Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 18:58:39 +0800 Subject: [PATCH 2/2] test: translate tui message flow fixtures --- .../test/tui/kimi-tui-message-flow.test.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 7ebf0510..92e58f9a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1100,7 +1100,7 @@ describe('KimiTUI message flow', () => { coalescedCount: 1, stale: false, }, - prompt: '提醒用户:这是每分钟提醒', + prompt: 'Remind the user: this is a once-per-minute reminder', } as Event, vi.fn(), ); @@ -1108,7 +1108,7 @@ describe('KimiTUI message flow', () => { const entry = driver.state.transcriptEntries.at(-1); expect(entry).toMatchObject({ kind: 'cron', - content: '提醒用户:这是每分钟提醒', + content: 'Remind the user: this is a once-per-minute reminder', cronData: { jobId: 'deadbeef', cron: '* * * * *', @@ -1120,7 +1120,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); expect(transcript).toContain('Scheduled reminder fired'); expect(transcript).toContain('* * * * *'); - expect(transcript).toContain('提醒用户:这是每分钟提醒'); + expect(transcript).toContain('Remind the user: this is a once-per-minute reminder'); expect(transcript).not.toContain(' { driver.state.appState.streamingPhase = 'composing'; driver.state.livePane.mode = 'thinking'; - driver.handleUserInput('/btw 你现在在做啥?'); + driver.handleUserInput('/btw What are you working on right now?'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); }); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('你现在在做啥?'); + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); }); expect(session.steer).not.toHaveBeenCalled(); expect(driver.state.appState.streamingPhase).toBe('composing'); @@ -1378,13 +1378,13 @@ describe('KimiTUI message flow', () => { expect(session.prompt).not.toHaveBeenCalled(); expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); - driver.handleUserInput('你现在在做啥?'); + driver.handleUserInput('What are you working on right now?'); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('你现在在做啥?'); + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); }); expect(session.steer).not.toHaveBeenCalled(); - expect(stripSgr(renderBtwPanel(driver))).toContain('Q: 你现在在做啥?'); + expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?'); }); it('cancels an unused /btw side agent when closing an empty panel', async () => { @@ -1405,7 +1405,7 @@ describe('KimiTUI message flow', () => { it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { const session = makeSession(); const { driver } = await makeDriver(session); - await openBtwPanel(driver, session, '你现在在做啥?'); + await openBtwPanel(driver, session, 'What are you working on right now?'); driver.sessionEventHandler.handleEvent( { @@ -1413,7 +1413,7 @@ describe('KimiTUI message flow', () => { agentId: 'agent-btw', sessionId: 'ses-1', turnId: 0, - delta: '正在实现 /btw 的独立面板。', + delta: 'I am implementing the dedicated /btw panel.', } as Event, () => {}, ); @@ -1450,12 +1450,12 @@ describe('KimiTUI message flow', () => { expect(panel).not.toContain('BTW failed'); expect(panel).not.toContain('Ask:'); expect(panel).not.toContain('Type follow-up'); - expect(panel).toContain('Q: 你现在在做啥?'); - expect(panel).toContain('正在实现 /btw 的独立面板。'); + expect(panel).toContain('Q: What are you working on right now?'); + expect(panel).toContain('I am implementing the dedicated /btw panel.'); expect(panel).not.toContain('Agent'); expect(transcript).not.toContain('BTW'); expect(transcript).not.toContain('Esc close'); - expect(transcript).not.toContain('正在实现 /btw 的独立面板。'); + expect(transcript).not.toContain('I am implementing the dedicated /btw panel.'); }); it('keeps the /btw panel closest to the input after later transcript output', async () => { @@ -1966,7 +1966,7 @@ describe('KimiTUI message flow', () => { expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); - driver.handleUserInput('/btw 现在在做什么?'); + driver.handleUserInput('/btw What are you doing now?'); expect(session.startBtw).not.toHaveBeenCalled(); expect(stripSgr(renderTranscript(driver))).toContain('LLM not set');