From c1158893c916735bb5e5d5a890ae170e06f043e0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 20:33:33 +0800 Subject: [PATCH 01/18] feat: add btw side-channel command --- .changeset/btw-side-agent.md | 7 + apps/kimi-code/src/tui/commands/btw.ts | 162 ++++++++++ apps/kimi-code/src/tui/commands/dispatch.ts | 12 + apps/kimi-code/src/tui/commands/index.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 7 + .../tui/controllers/session-event-handler.ts | 120 +++++++- apps/kimi-code/src/tui/kimi-tui.ts | 19 ++ apps/kimi-code/src/tui/tui-state.ts | 3 + .../test/tui/commands/registry.test.ts | 2 + .../test/tui/commands/resolve.test.ts | 10 + .../test/tui/kimi-tui-message-flow.test.ts | 278 +++++++++++++++++ docs/en/guides/interaction.md | 2 +- docs/en/reference/slash-commands.md | 1 + docs/zh/guides/interaction.md | 2 +- docs/zh/reference/slash-commands.md | 1 + packages/agent-core/src/agent/config/index.ts | 2 +- .../agent-core/src/agent/context/index.ts | 6 +- .../agent-core/src/agent/context/projector.ts | 20 ++ packages/agent-core/src/agent/index.ts | 2 + .../agent-core/src/agent/permission/index.ts | 6 +- .../src/agent/permission/policies/deny-all.ts | 15 + .../src/agent/permission/policies/index.ts | 2 +- packages/agent-core/src/agent/tool/index.ts | 6 + packages/agent-core/src/rpc/core-api.ts | 6 + packages/agent-core/src/rpc/core-impl.ts | 9 + packages/agent-core/src/session/rpc.ts | 9 + .../agent-core/src/session/subagent-host.ts | 74 +++++ packages/agent-core/test/session/init.test.ts | 283 +++++++++++++++++- .../test/session/subagent-host.test.ts | 36 ++- packages/node-sdk/src/rpc.ts | 21 ++ packages/node-sdk/src/session.ts | 15 + .../test/session-prompt-events.test.ts | 69 +++++ .../test/session-prompt-input.test.ts | 36 +++ 33 files changed, 1225 insertions(+), 19 deletions(-) create mode 100644 .changeset/btw-side-agent.md create mode 100644 apps/kimi-code/src/tui/commands/btw.ts create mode 100644 packages/agent-core/src/agent/permission/policies/deny-all.ts diff --git a/.changeset/btw-side-agent.md b/.changeset/btw-side-agent.md new file mode 100644 index 0000000000..1d02c5fa59 --- /dev/null +++ b/.changeset/btw-side-agent.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `/btw` for asking side-channel questions without steering the active main turn. diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts new file mode 100644 index 0000000000..d249d7a187 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -0,0 +1,162 @@ +import type { Component, Focusable, MarkdownTheme } from '@earendil-works/pi-tui'; +import { + Key, + Markdown, + Text, + matchesKey, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { THINKING_PREVIEW_LINES } from '../constant/rendering'; +import type { ColorPalette } from '../theme/colors'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +type BtwPanelPhase = 'running' | 'done' | 'failed'; + +export interface BtwPanelOptions { + readonly colors: ColorPalette; + readonly markdownTheme: MarkdownTheme; + readonly onClose: () => void; + readonly onCancel: () => void; +} + +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); + return; + } + + try { + await session.startBtw(prompt); + } catch (error) { + host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); + } +} + +export class BtwPanelComponent implements Component, Focusable { + private answer = ''; + private thinking = ''; + private error: string | undefined; + private phase: BtwPanelPhase = 'running'; + focused = false; + + constructor(private readonly options: BtwPanelOptions) {} + + appendAnswer(delta: string): void { + this.answer += delta; + } + + appendThinking(delta: string): void { + this.thinking += delta; + } + + markDone(resultSummary?: string | undefined): void { + if (this.answer.trim().length === 0 && resultSummary !== undefined) { + this.answer = resultSummary; + } + this.phase = 'done'; + } + + markFailed(error: string): void { + this.error = error; + this.phase = 'failed'; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const contentWidth = Math.max(1, safeWidth - 4); + const lines = [this.renderTopBorder(safeWidth)]; + for (const line of this.renderBody(contentWidth)) { + lines.push(this.renderBodyLine(line, safeWidth)); + } + return lines; + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + if (this.phase === 'running') { + this.options.onCancel(); + } else { + this.options.onClose(); + } + return; + } + if (matchesKey(data, Key.enter) && this.phase !== 'running') { + this.options.onClose(); + } + } + + private renderTopBorder(width: number): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const title = `${chalk.hex(this.options.colors.accent).bold(' BTW ')}${this.renderPhase()} `; + const innerWidth = Math.max(1, width - 2); + const clippedTitle = + visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; + const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); + return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); + } + + private renderPhase(): string { + switch (this.phase) { + case 'done': + return chalk.hex(this.options.colors.success)('done'); + case 'failed': + return chalk.hex(this.options.colors.error)('failed'); + case 'running': + return chalk.hex(this.options.colors.textMuted)('running'); + } + } + + private renderBody(width: number): string[] { + if (this.error !== undefined) { + return [ + ...new Text(chalk.hex(this.options.colors.error)(this.error), 0, 0).render(width), + this.renderHint(), + ]; + } + const text = this.answer.trim(); + if (text.length > 0) { + return [ + ...new Markdown(text, 0, 0, this.options.markdownTheme).render(width), + this.renderHint(), + ]; + } + const thinking = this.thinking.trim(); + if (thinking.length > 0) { + const lines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render(width); + const visibleLines = + lines.length > THINKING_PREVIEW_LINES + ? lines.slice(lines.length - THINKING_PREVIEW_LINES) + : lines; + return [...visibleLines, this.renderHint()]; + } + return [chalk.hex(this.options.colors.textDim)('Waiting for answer...'), this.renderHint()]; + } + + private renderBodyLine(line: string, width: number): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const contentWidth = Math.max(1, width - 4); + const clipped = + visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; + const padding = Math.max(0, contentWidth - visibleWidth(clipped)); + return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); + } + + private renderHint(): string { + const text = this.phase === 'running' ? 'Esc/Ctrl-C cancel' : 'Enter/Esc/Ctrl-C close'; + return chalk.hex(this.options.colors.textMuted)(text); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 259fb02d9d..f8a5f575b7 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -21,6 +21,7 @@ import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../typ import type { TUIState } from '../tui-state'; import { handleLoginCommand, handleLogoutCommand } from './auth'; +import { handleBtwCommand } from './btw'; import { tryHandleDanceCommand } from '../easter-eggs/dance'; import { handleAutoCommand, @@ -54,6 +55,7 @@ export { handleLoginCommand, handleLogoutCommand, } from './auth'; +export { handleBtwCommand } from './btw'; export { handleAutoCommand, handleCompactCommand, @@ -161,6 +163,13 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); return; + case 'invalid': + host.track('input_command_invalid', { reason: intent.reason, command: intent.commandName }); + if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { + return; + } + host.sendNormalUserInput(input); + return; case 'skill': { const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { @@ -255,6 +264,9 @@ async function handleBuiltInSlashCommand( case 'feedback': await handleFeedbackCommand(host); return; + case 'btw': + await handleBtwCommand(host, args); + return; case 'title': await handleTitleCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 643856a728..bbec309173 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -10,6 +10,7 @@ export { handleLoginCommand, handleLogoutCommand, } from './auth'; +export { handleBtwCommand } from './btw'; export { handleCompactCommand, handleEditorCommand, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 71001d4ad0..60a9916eb2 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -50,6 +50,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 95, availability: 'always', }, + { + name: 'btw', + aliases: [], + description: 'Ask a forked side agent a question', + priority: 90, + availability: 'always', + }, { name: 'help', aliases: ['h', '?'], diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 6f13a9f922..d7e46d2ff0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -40,6 +40,7 @@ import { } from '../constant/kimi-tui'; import { argsRecord, + formatErrorMessage, isTodoItemShape, serializeToolResultOutput, stringValue, @@ -57,6 +58,7 @@ import { import { openUrl } from '../utils/open-url'; import { setProcessTitle } from '../utils/proctitle'; import { errorReportHintLine } from '../constant/feedback'; +import { BtwPanelComponent } from '../commands/btw'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; import type { StreamingUIController } from './streaming-ui'; @@ -87,11 +89,21 @@ export interface SessionEventHost { showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + showBtwPanel(panel: BtwPanelComponent): void; + closeBtwPanel(panel: BtwPanelComponent): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; readonly tasksBrowserController: TasksBrowserController; } +type BtwTurnStartedEvent = Event & + TurnStartedEvent & { + readonly origin: { + readonly kind: 'system_trigger'; + readonly name: 'btw'; + }; + }; + export class SessionEventHandler { constructor(private readonly host: SessionEventHost) {} @@ -99,7 +111,10 @@ export class SessionEventHandler { backgroundAgentMetadata: Map = new Map(); backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); - subagentInfo: Map = new Map(); + subagentInfo: Map< + string, + { parentToolCallId: string; name: string; btwPanel?: BtwPanelComponent | undefined } + > = new Map(); renderedSkillActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); @@ -231,7 +246,15 @@ export class SessionEventHandler { const { streamingUI } = this.host; const info = this.subagentInfo.get(subagentId); - if (info === undefined || info.parentToolCallId.length === 0) return true; + if (info === undefined && isBtwTurnStarted(event)) { + this.handleBtwTurnStarted(event); + return true; + } + if (info === undefined) return true; + if (info.btwPanel !== undefined) { + return this.routeBtwEvent(info.btwPanel, event); + } + if (info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; const toolCall = streamingUI.getToolComponent(parentToolCallId); @@ -307,6 +330,84 @@ export class SessionEventHandler { } } + private routeBtwEvent(panel: BtwPanelComponent, event: Event): boolean { + switch (event.type) { + case 'assistant.delta': + panel.appendAnswer(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'thinking.delta': + panel.appendThinking(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'hook.result': + panel.appendAnswer(formatHookResultPlain(event)); + this.host.state.ui.requestRender(); + return true; + case 'turn.ended': + if (event.reason === 'completed') { + panel.markDone(); + } else { + panel.markFailed(formatBtwTurnEnd(event)); + } + this.subagentInfo.delete(event.agentId); + this.host.state.ui.requestRender(); + return true; + case 'agent.status.updated': + case 'background.task.started': + case 'background.task.terminated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'cron.fired': + case 'error': + case 'mcp.server.status': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.call.delta': + case 'tool.call.started': + case 'tool.list.updated': + case 'tool.progress': + case 'tool.result': + case 'turn.started': + case 'turn.step.completed': + case 'turn.step.interrupted': + case 'turn.step.retrying': + case 'turn.step.started': + case 'warning': + return true; + default: + return true; + } + } + + private handleBtwTurnStarted(event: BtwTurnStartedEvent): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + colors: this.host.state.theme.colors, + markdownTheme: this.host.state.theme.markdownTheme, + onClose: () => { + this.host.closeBtwPanel(panel); + }, + onCancel: () => { + this.host.closeBtwPanel(panel); + void this.host.session?.cancelBtw().catch((error: unknown) => { + this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + }, + }); + this.subagentInfo.set(event.agentId, { + parentToolCallId: '', + name: 'btw', + btwPanel: panel, + }); + this.host.showBtwPanel(panel); + } + private handleTurnBegin(_event: TurnStartedEvent): void { void _event; this.host.streamingUI.resetToolUi(); @@ -987,3 +1088,18 @@ export class SessionEventHandler { state.ui.requestRender(); } } + +function isBtwTurnStarted(event: Event): event is BtwTurnStartedEvent { + return ( + event.type === 'turn.started' && + event.origin.kind === 'system_trigger' && + event.origin.name === 'btw' + ); +} + +function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.error !== undefined) { + return `[${event.error.code}] ${event.error.message}`; + } + return `BTW turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 1e239e50e8..8111e20bc8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -40,6 +40,7 @@ import { type KimiSlashCommand, type SkillListSession, } from './commands'; +import type { BtwPanelComponent } from './commands/btw'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; import { CHROME_GUTTER } from './constant/rendering'; @@ -639,6 +640,7 @@ export class KimiTUI { ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); + ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -1081,6 +1083,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); + this.state.btwPanelContainer.clear(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); @@ -1331,6 +1334,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); + this.state.btwPanelContainer.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); @@ -1621,6 +1625,21 @@ export class KimiTUI { this.state.ui.requestRender(); } + showBtwPanel(panel: BtwPanelComponent): void { + this.state.btwPanelContainer.clear(); + this.state.btwPanelContainer.addChild(new Spacer(1)); + this.state.btwPanelContainer.addChild(panel); + this.state.ui.setFocus(panel); + this.state.ui.requestRender(); + } + + closeBtwPanel(panel: BtwPanelComponent): void { + if (!this.state.btwPanelContainer.children.includes(panel)) return; + this.state.btwPanelContainer.clear(); + this.state.ui.setFocus(this.state.editor); + this.state.ui.requestRender(); + } + private async runMigrationScreen(plan: MigrationPlan): Promise { const result = await new Promise((resolve) => { const screen = new MigrationScreenComponent({ diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 1fb1f44450..41c7146392 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -32,6 +32,7 @@ export interface TUIState { todoPanelContainer: Container; todoPanel: TodoPanelComponent; queueContainer: Container; + btwPanelContainer: Container; editorContainer: Container; footer: FooterComponent; editor: CustomEditor; @@ -64,6 +65,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanel = new TodoPanelComponent(theme.colors); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editor = new CustomEditor(ui, theme.colors); const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { @@ -78,6 +80,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { todoPanelContainer, todoPanel, queueContainer, + btwPanelContainer, editorContainer, footer, editor, diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index c481aa2cb1..058e189141 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -32,6 +32,7 @@ describe('built-in slash command registry', () => { expect(findBuiltInSlashCommand('quit')?.name).toBe('exit'); expect(findBuiltInSlashCommand('q')?.name).toBe('exit'); expect(findBuiltInSlashCommand('clear')?.name).toBe('new'); + expect(findBuiltInSlashCommand('btw')?.name).toBe('btw'); expect(findBuiltInSlashCommand('mcp')?.name).toBe('mcp'); expect(findBuiltInSlashCommand('status')?.name).toBe('status'); expect(findBuiltInSlashCommand('usage')?.aliases).not.toContain('status'); @@ -79,6 +80,7 @@ describe('built-in slash command registry', () => { expect(names).toEqual( expect.arrayContaining([ 'compact', + 'btw', 'editor', 'exit', 'export-debug-zip', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index dbbdff7b7c..e404b97028 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -35,6 +35,11 @@ describe('resolveSlashCommandInput', () => { args: 'New title', }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); + expect(resolve('/btw what are you doing?')).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'what are you doing?', + }); }); it('blocks idle-only built-ins while streaming', () => { @@ -94,6 +99,11 @@ describe('resolveSlashCommandInput', () => { name: 'mcp', args: '', }); + expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'side question', + }); }); it('blocks plan clear while compacting because it is idle-only', () => { 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 8cdac1c1c9..d831ecf586 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 @@ -112,6 +112,8 @@ function makeSession(overrides: Record = {}) { prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), init: vi.fn(async () => {}), + startBtw: vi.fn(async () => {}), + cancelBtw: vi.fn(async () => {}), undoHistory: vi.fn(async () => {}), cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), @@ -241,6 +243,24 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +function renderBtwPanel(driver: MessageDriver): string { + return driver.state.btwPanelContainer.render(120).join('\n'); +} + +interface MountedBtwPanel { + readonly focused: boolean; + handleInput(data: string): void; +} + +function getMountedBtwPanel(driver: MessageDriver): MountedBtwPanel { + const panel = driver.state.btwPanelContainer.children.find((child) => { + if (typeof child !== 'object' || child === null) return false; + return 'focused' in child && typeof (child as { handleInput?: unknown }).handleInput === 'function'; + }); + if (panel === undefined) throw new Error('Expected a mounted /btw panel.'); + return panel as unknown as MountedBtwPanel; +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -1310,6 +1330,264 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); }); + it('starts /btw through a forked side agent without changing the main busy state', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + harness.track.mockClear(); + driver.state.appState.streamingPhase = 'composing'; + driver.state.livePane.mode = 'thinking'; + + driver.handleUserInput('/btw 你现在在做啥?'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith('你现在在做啥?'); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('composing'); + expect(driver.state.livePane.mode).toBe('thinking'); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); + }); + + it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + origin: { + kind: 'system_trigger', + name: 'btw', + }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: '正在实现 /btw 的独立面板。', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.btwPanelContainer.render(120)[0]?.trim()).toBe(''); + expect(getMountedBtwPanel(driver).focused).toBe(true); + expect(driver.state.editor.focused).toBe(false); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + expect(panel).toContain('BTW done'); + expect(panel).toContain('正在实现 /btw 的独立面板。'); + expect(panel).not.toContain('Agent'); + expect(transcript).not.toContain('BTW done'); + expect(transcript).not.toContain('正在实现 /btw 的独立面板。'); + }); + + it('keeps the /btw panel closest to the input after later transcript output', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + origin: { + kind: 'system_trigger', + name: 'btw', + }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'side answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + origin: { kind: 'user' }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'main answer after btw', + } as Event, + () => {}, + ); + driver.streamingUI.flushNow(); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + const rootChildren = driver.state.ui.children; + expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + rootChildren.indexOf(driver.state.editorContainer) - 1, + ); + expect(transcript).toContain('main answer after btw'); + expect(transcript).not.toContain('side answer'); + expect(panel).toContain('BTW done'); + expect(panel).toContain('side answer'); + expect(panel).not.toContain('main answer after btw'); + }); + + it('renders only the tail of /btw thinking output', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + origin: { + kind: 'system_trigger', + name: 'btw', + }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'), + } as Event, + () => {}, + ); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + expect(transcript).not.toContain('line7'); + expect(panel).not.toContain('line1'); + expect(panel).not.toContain('line5'); + expect(panel).toContain('line6'); + expect(panel).toContain('line7'); + }); + + it('cancels and closes a running /btw panel on Escape', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + origin: { + kind: 'system_trigger', + name: 'btw', + }, + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.focused).toBe(true); + + panel.handleInput('\x1b'); + + expect(session.cancelBtw).toHaveBeenCalledOnce(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + }); + + it('closes a completed /btw panel on Enter without cancelling it', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + origin: { + kind: 'system_trigger', + name: 'btw', + }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.focused).toBe(true); + + panel.handleInput('\r'); + + expect(session.cancelBtw).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + }); + + it('does not run /btw without a question or selected model', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('/btw'); + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Usage: /btw '); + + driver.state.appState.model = ''; + driver.handleUserInput('/btw 现在在做什么?'); + + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + }); + it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 39552b8656..5df274fb9c 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -14,7 +14,7 @@ Anything starting with `/` is recognized as a slash command, covering session ma Type `/` to open the command completion menu, which also includes commands from [Agent Skills](../customization/skills.md). If a skill name collides with a built-in command, use the full `/skill:` form. Press `Esc` to dismiss. -Some commands are only available while the agent is idle; interrupt the current turn first if the agent is streaming. Mode-switch and query commands such as `/yolo`, `/plan`, and `/help` are always available. +Some commands are only available while the agent is idle; interrupt the current turn first if the agent is streaming. Mode-switch and query commands such as `/yolo`, `/plan`, `/help`, and `/btw` are always available. Type `@` to trigger file-path completion. Selecting an entry inserts the relative path, and the agent can read the file directly. Dot-prefixed directories are hidden by default; write `@.github/` to include them explicitly. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 4f2a56a026..335dd09623 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -53,6 +53,7 @@ Some commands are only available in the idle state. Running them while the sessi | Command | Alias | Description | Always available | | --- | --- | --- | --- | | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | +| `/btw ` | — | Ask a side-channel question in a forked subagent without steering the current main agent turn. | 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/guides/interaction.md b/docs/zh/guides/interaction.md index 26bd6e5aa7..d71c383715 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -14,7 +14,7 @@ Kimi Code CLI 以交互式 TUI 运行在终端中,核心是一个输入框、 输入 `/` 后会弹出命令补全菜单,也包括来自 [Agent Skills](../customization/skills.md) 的命令。若 skill 与内置命令重名,需要用完整形式 `/skill:` 调用。按 `Esc` 关闭菜单。 -部分命令仅在 Agent 空闲时可用,流式输出中需先中断才能调用。`/yolo`、`/plan`、`/help` 等模式切换类命令则始终可用。 +部分命令仅在 Agent 空闲时可用,流式输出中需先中断才能调用。`/yolo`、`/plan`、`/help`、`/btw` 等模式切换和查询类命令则始终可用。 键入 `@` 触发文件路径补全,选中后插入相对路径,Agent 可直接读取对应文件。以点开头的目录默认隐藏,如需可显式写成 `@.github/`。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index af504f2c77..3bd9d12291 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -53,6 +53,7 @@ | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | +| `/btw <问题>` | — | 在 fork 出的子 Agent 中回答旁路问题,不改变当前主 Agent 轮次。 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 45aca21367..65e64904d5 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -27,7 +27,7 @@ export class ConfigState { this._modelAlias = agent.modelProvider?.defaultModel; } - update(changed: AgentConfigUpdateData): void { + update(changed: AgentConfigUpdateData | ConfigState): void { if (Object.keys(changed).length === 0) return; this.agent.records.logRecord({ diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 386dccd98a..b82c667bdc 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -5,7 +5,7 @@ import { ErrorCodes, KimiError } from '../../errors'; import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; import { estimateTokensForMessages } from '../../utils/tokens'; import type { CompactionResult } from '../compaction'; -import { project } from './projector'; +import { project, trimTrailingOpenToolExchange } from './projector'; import { USER_PROMPT_ORIGIN, type AgentContextData, @@ -175,6 +175,10 @@ export class ContextMemory { return this.project(this.history); } + appendProjectedHistoryFrom(source: ContextMemory): void { + this._history.push(...trimTrailingOpenToolExchange(source.project(source.history))); + } + appendLoopEvent(event: LoopRecordedEvent): void { this.agent.records.logRecord({ type: 'context.append_loop_event', diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index fc0ba2e5be..e0ae999720 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -70,3 +70,23 @@ function stripContextMetadata(message: ContextMessage): Message { partial: message.partial, }; } + +export function trimTrailingOpenToolExchange(history: readonly Message[]): Message[] { + let lastNonToolIndex = history.length - 1; + while (lastNonToolIndex >= 0 && history[lastNonToolIndex]?.role === 'tool') { + lastNonToolIndex -= 1; + } + + const assistant = history[lastNonToolIndex]; + if (assistant === undefined) return []; + if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history]; + + const trailingToolCallIds = new Set( + history + .slice(lastNonToolIndex + 1) + .map((message) => message.toolCallId) + .filter((toolCallId): toolCallId is string => typeof toolCallId === 'string'), + ); + const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id)); + return closed ? [...history] : history.slice(0, lastNonToolIndex); +} diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index cad1e5b4d9..7d9699b85d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -379,6 +379,8 @@ export class Agent { } this.skills.activate(payload); }, + startBtw: (payload) => this.subagentHost!.startBtw(payload.prompt), + cancelBtw: () => this.subagentHost!.cancelBtw(), getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), getContext: () => this.context.data(), getConfig: () => this.config.data(), diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts index ddd51d5298..06ce01f335 100644 --- a/packages/agent-core/src/agent/permission/index.ts +++ b/packages/agent-core/src/agent/permission/index.ts @@ -10,7 +10,7 @@ import type { PermissionPolicyContext, PermissionPolicyResolution, PermissionPolicyResult, - PermissionRule + PermissionRule, } from './types'; export * from './types'; @@ -26,11 +26,11 @@ interface PolicyEvaluation { } export class PermissionManager { - rules: PermissionRule[] = []; + readonly policies: PermissionPolicy[]; + readonly rules: PermissionRule[] = []; private modeOverride: PermissionMode | undefined; private readonly parent: PermissionManager | undefined; private readonly localSessionApprovalRulePatterns = new Set(); - private readonly policies: readonly PermissionPolicy[]; constructor( protected readonly agent: Agent, diff --git a/packages/agent-core/src/agent/permission/policies/deny-all.ts b/packages/agent-core/src/agent/permission/policies/deny-all.ts new file mode 100644 index 0000000000..3b5bf500a8 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/deny-all.ts @@ -0,0 +1,15 @@ +import type { PermissionPolicy, PermissionPolicyResult } from '../types'; + +export class DenyAllPermissionPolicy implements PermissionPolicy { + readonly name = 'deny-all'; + + constructor(private readonly message: string) {} + + evaluate(): PermissionPolicyResult { + return { + kind: 'deny', + message: this.message, + reason: { source: 'side_question' }, + }; + } +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 38e0bb9d76..5cc0386c2e 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -23,7 +23,7 @@ import { import { YoloModeApprovePermissionPolicy } from './yolo-mode-approve'; /** Permission policies run in order; the first non-undefined result wins. */ -export function createPermissionDecisionPolicies(agent: Agent): readonly PermissionPolicy[] { +export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy[] { return [ // PreToolUse hook returned a block → deny. new PreToolCallHookPermissionPolicy(agent), diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index f95de83767..9b6f8373f6 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -33,6 +33,7 @@ export class ToolManager { protected builtinTools: Map = new Map(); protected readonly userTools: Map = new Map(); protected readonly mcpTools: Map = new Map(); + private loopToolsOverride: readonly ExecutableTool[] | undefined; /** server name → list of qualified tool names registered for that server. */ protected readonly mcpToolsByServer: Map = new Map(); protected enabledTools: Set = new Set(); @@ -301,6 +302,10 @@ export class ToolManager { this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); } + copyLoopToolsFrom(source: ToolManager): void { + this.loopToolsOverride = source.loopTools; + } + private isMcpToolEnabled(name: string): boolean { return this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)); } @@ -413,6 +418,7 @@ export class ToolManager { } get loopTools(): readonly ExecutableTool[] { + if (this.loopToolsOverride !== undefined) return this.loopToolsOverride; const mcpNames = [...this.mcpTools.keys()].filter((name) => this.isMcpToolEnabled(name)); return uniq([...this.enabledTools, ...mcpNames]) .toSorted((a, b) => a.localeCompare(b)) diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index a636e2eecb..f42860c495 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -201,6 +201,10 @@ export interface ActivateSkillPayload { readonly args?: string | undefined; } +export interface StartBtwPayload { + readonly prompt: string; +} + export interface McpServerInfo { readonly name: string; readonly transport: 'stdio' | 'http'; @@ -281,6 +285,8 @@ export interface AgentAPI { stopBackground: (payload: StopBackgroundPayload) => void; clearContext: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; + startBtw: (payload: StartBtwPayload) => void; + cancelBtw: (payload: EmptyPayload) => void; getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; getContext: (payload: EmptyPayload) => AgentContextData; getConfig: (payload: EmptyPayload) => AgentConfigData; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 31fd26a7b1..b7f4aa33e1 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -81,6 +81,7 @@ import type { SetPluginMcpServerEnabledPayload, SetThinkingPayload, SkillSummary, + StartBtwPayload, SteerPayload, StopBackgroundPayload, UndoHistoryPayload, @@ -573,6 +574,14 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).generateAgentsMd(payload); } + startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { + return this.sessionApi(sessionId).startBtw(payload); + } + + cancelBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { + return this.sessionApi(sessionId).cancelBtw(payload); + } + async installPlugin(payload: InstallPluginPayload): Promise { await this.pluginsReady; this.assertPluginsLoaded(); diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d7172ca757..7f988f29ea 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -20,6 +20,7 @@ import type { SetPermissionPayload, SetThinkingPayload, SkillSummary, + StartBtwPayload, SteerPayload, StopBackgroundPayload, UndoHistoryPayload, @@ -88,6 +89,14 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.generateAgentsMd(); } + async startBtw({ agentId, ...payload }: AgentScopedPayload): Promise { + await this.getAgent(agentId).startBtw(payload); + } + + async cancelBtw({ agentId, ...payload }: AgentScopedPayload): Promise { + await this.getAgent(agentId).cancelBtw(payload); + } + async prompt({ agentId, ...payload }: AgentScopedPayload) { if (agentId === 'main') { await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 458e291e23..8135e0456a 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -2,6 +2,9 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; +import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; +import { InMemoryAgentRecordPersistence } from '../agent/records'; +import { ErrorCodes, KimiError } from '../errors'; import type { LoopTurnStopReason } from '../loop'; import { DEFAULT_AGENT_PROFILES, @@ -23,6 +26,21 @@ const SUMMARY_CONTINUATION_ATTEMPTS = 1; const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +const TOOL_CALL_DISABLED_MESSAGE = + 'Tool calls are disabled for side questions. Answer with text only.'; +const SIDE_QUESTION_SYSTEM_REMINDER = ` +This is a side question from the user. Answer directly in a single response. + +IMPORTANT: +- You are a separate, lightweight instance answering one question. +- The main agent continues independently; do not reference being interrupted. +- Do not call any tools. All tool calls are disabled and will be rejected. + Even though tool definitions are visible in this request, they exist only + for technical reasons (prompt cache). You must not use them. +- Respond only with text based on what you already know from the conversation. +- This is a one-off response; there are no follow-up turns. +- If you do not know the answer, say so directly. +`; type RunSubagentOptions = { readonly parentToolCallId: string; @@ -53,6 +71,7 @@ export type SubagentHandle = { export class SessionSubagentHost { private readonly activeChildren = new Map(); + private currentBtwController: AbortController | undefined; constructor( private readonly session: Session, @@ -167,6 +186,61 @@ export class SessionSubagentHost { }; } + async startBtw(prompt: string): Promise { + if (this.currentBtwController !== undefined) { + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'BTW is already running'); + } + const controller = new AbortController(); + this.currentBtwController = controller; + + try { + const parent = this.session.agents.get(this.ownerAgentId)!; + const { agent: child } = await this.session.createAgent( + { + type: 'sub', + generate: parent.rawGenerate, + persistence: new InMemoryAgentRecordPersistence(), + }, + undefined, + this.ownerAgentId, + ); + + child.config.update(parent.config); + child.tools.copyLoopToolsFrom(parent.tools); + child.context.appendProjectedHistoryFrom(parent.context); + child.permission.policies.unshift(new DenyAllPermissionPolicy(TOOL_CALL_DISABLED_MESSAGE)); + + child.turn.prompt( + [ + { + type: 'text', + text: `\n${SIDE_QUESTION_SYSTEM_REMINDER.trim()}\n\n\n${prompt}`, + }, + ], + { + kind: 'system_trigger', + name: 'btw', + }, + ); + await child.turn.waitForCurrentTurn(controller.signal); + } catch (error) { + if (!controller.signal.aborted) { + throw error; + } + } finally { + if (this.currentBtwController === controller) { + this.currentBtwController = undefined; + } + } + } + + cancelBtw(reason: unknown = userCancellationReason()): void { + const controller = this.currentBtwController; + if (controller === undefined) return; + this.currentBtwController = undefined; + controller.abort(reason); + } + cancelAll(reason: unknown = userCancellationReason()): void { const foregroundChildren = Array.from(this.activeChildren).filter( ([, child]) => !child.runInBackground, diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 3a5015405e..e6205cee20 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1,12 +1,13 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'pathe'; import { fileURLToPath } from 'node:url'; import { testKaos } from '../fixtures/test-kaos'; -import type { ProviderConfig } from '@moonshot-ai/kosong'; +import type { ProviderConfig, ToolCall } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Agent, AgentOptions } from '../../src/agent'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -172,6 +173,260 @@ describe('Session.init', () => { }, 20000); }); +describe('AgentAPI.startBtw', () => { + it('runs a side subagent from a stable parent context snapshot without writing btw history', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const scripted = createScriptedGenerate(); + const session = new Session({ + id: 'test-btw', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate: scripted.generate }, + testProfile(), + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + mainAgent.tools.setActiveTools(['Read']); + registerLookupNoteTool(mainAgent); + mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task: implement /btw.' }]); + mainAgent.context.appendLoopEvent({ + type: 'step.begin', + uuid: 'open-step', + turnId: 'main-turn', + step: 1, + }); + mainAgent.context.appendLoopEvent({ + type: 'tool.call', + uuid: 'open-call', + turnId: 'main-turn', + step: 1, + stepUuid: 'open-step', + toolCallId: 'call-open', + name: 'Read', + args: { path: 'src/main.ts' }, + }); + events.length = 0; + const summary = 'Main agent is implementing /btw.'; + scripted.mockNextResponse({ type: 'text', text: summary }); + + try { + await new SessionAPIImpl(session).startBtw({ + agentId: 'main', + prompt: 'What are you working on right now?', + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'completed', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + agentId: 'agent-0', + origin: { kind: 'system_trigger', name: 'btw' }, + }), + ); + expect(scripted.calls).toHaveLength(1); + expect(scripted.calls[0]?.systemPrompt).toBe(''); + expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + const historyText = JSON.stringify(scripted.calls[0]?.history); + expect(historyText).toContain('Main task: implement /btw.'); + expect(historyText).toContain('This is a side question from the user.'); + expect(historyText).toContain('All tool calls are disabled and will be rejected.'); + expect(historyText).toContain('What are you working on right now?'); + expect(historyText).not.toContain('call-open'); + expect(JSON.stringify(mainAgent.context.history)).not.toContain( + 'What are you working on right now?', + ); + expect(JSON.stringify(session.agents.get('agent-0')?.context.history)).toContain( + 'What are you working on right now?', + ); + await expect(access(join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'))).rejects.toThrow(); + } finally { + await session.close(); + } + }); + + it('declares parent tools but rejects side-question tool calls before a second text turn', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const scripted = createScriptedGenerate(); + const session = new Session({ + id: 'test-btw-deny-tools', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate: scripted.generate }, + testProfile(), + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + mainAgent.tools.setActiveTools(['Read']); + registerLookupNoteTool(mainAgent); + mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task context.' }]); + events.length = 0; + + scripted.mockNextResponse(lookupNoteCall()); + scripted.mockNextResponse({ + type: 'text', + text: 'Main agent is implementing /btw based on the existing context.', + }); + + try { + await new SessionAPIImpl(session).startBtw({ + agentId: 'main', + prompt: 'What are you working on right now?', + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'completed', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + expect(scripted.calls).toHaveLength(2); + expect(scripted.calls[0]?.systemPrompt).toBe(''); + expect(scripted.calls[1]?.systemPrompt).toBe(''); + expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + expect(scripted.calls[1]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + expect(JSON.stringify(scripted.calls[1]?.history)).toContain( + 'Tool calls are disabled for side questions. Answer with text only.', + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool.result', + agentId: 'agent-0', + toolCallId: 'call_lookup_note', + isError: true, + output: 'Tool calls are disabled for side questions. Answer with text only.', + }), + ); + expect(JSON.stringify(mainAgent.context.history)).not.toContain( + 'What are you working on right now?', + ); + } finally { + await session.close(); + } + }); + + it('rejects concurrent side subagents and cancels the current one', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const generate: NonNullable = vi.fn( + async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { + const signal = options?.signal; + if (signal === undefined) { + throw new Error('Expected generate signal'); + } + return await new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + ); + const session = new Session({ + id: 'test-btw-cancel', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate }, + testProfile(), + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + events.length = 0; + + try { + const api = new SessionAPIImpl(session); + const start = api.startBtw({ + agentId: 'main', + prompt: 'Where are things right now?', + }); + await expect( + api.startBtw({ agentId: 'main', prompt: 'Ask another question' }), + ).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.invalid', + message: 'BTW is already running', + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + agentId: 'agent-0', + origin: { kind: 'system_trigger', name: 'btw' }, + }), + ); + }); + + await api.cancelBtw({ agentId: 'main' }); + await expect(start).resolves.toBeUndefined(); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'cancelled', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + } finally { + await session.close(); + } + }); +}); + async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'kimi-core-init-')); tempDirs.push(dir); @@ -206,6 +461,30 @@ function testProfile(): ResolvedAgentProfile { }; } +function registerLookupNoteTool(agent: Agent): void { + agent.tools.registerUserTool({ + name: 'LookupNote', + description: 'Look up a note from the host application.', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + additionalProperties: false, + }, + }); +} + +function lookupNoteCall(): ToolCall { + return { + type: 'function', + id: 'call_lookup_note', + name: 'LookupNote', + arguments: JSON.stringify({ query: 'status' }), + }; +} + function createSessionRpc(events: Array>): SDKSessionRPC { return { emitEvent: vi.fn(async (event) => { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 558af30926..4e69c9b53a 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -140,13 +140,11 @@ describe('SessionSubagentHost', () => { const parent = testAgent({ telemetry: { track: telemetryTrack } }); parent.configure(); await parent.rpc.setPermission({ mode: 'yolo' }); - parent.agent.permission.rules = [ - { - decision: 'allow', - scope: 'session-runtime', - pattern: 'Read', - }, - ]; + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); parent.newEvents(); const child = testAgent({ @@ -287,6 +285,30 @@ describe('SessionSubagentHost', () => { expect(createAgent).not.toHaveBeenCalled(); }); + it('rejects unavailable subagent profiles even when a same-named fork label exists', async () => { + const parent = testAgent(); + parent.configure(); + const createAgent = vi.fn(); + const host = new SessionSubagentHost( + { + agents: new Map([['main', parent.agent]]), + createAgent, + } as never, + 'main', + ); + + await expect( + host.spawn('btw', { + parentToolCallId: 'call_agent', + prompt: 'Answer a side question', + description: 'Side question', + runInBackground: false, + signal, + }), + ).rejects.toThrow('Subagent profile "btw" was not found'); + expect(createAgent).not.toHaveBeenCalled(); + }); + it('cancels the child turn when the caller signal aborts', async () => { const parent = testAgent(); parent.configure(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index e2df96e7fb..39578d6728 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -98,6 +98,10 @@ export interface ActivateSkillRpcInput extends SessionIdRpcInput { readonly args?: string | undefined; } +export interface StartBtwRpcInput extends SessionIdRpcInput { + readonly prompt: string; +} + export interface ReconnectMcpServerRpcInput extends SessionIdRpcInput { readonly name: string; } @@ -236,6 +240,23 @@ export class SDKRpcClient { return rpc.generateAgentsMd({ sessionId: input.sessionId }); } + async startBtw(input: StartBtwRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.startBtw({ + sessionId: input.sessionId, + agentId: this.interactiveAgentId, + prompt: input.prompt, + }); + } + + async cancelBtw(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.cancelBtw({ + sessionId: input.sessionId, + agentId: this.interactiveAgentId, + }); + } + async cancel(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.cancel({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index c696157798..fa5c5c9144 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -95,6 +95,21 @@ export class Session { await this.rpc.generateAgentsMd({ sessionId: this.id }); } + async startBtw(prompt: string): Promise { + this.ensureOpen(); + const normalized = normalizeRequiredString( + prompt, + 'BTW prompt cannot be empty', + ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, + ); + await this.rpc.startBtw({ sessionId: this.id, prompt: normalized }); + } + + async cancelBtw(): Promise { + this.ensureOpen(); + await this.rpc.cancelBtw({ sessionId: this.id }); + } + async cancel(): Promise { this.ensureOpen(); await this.rpc.cancel({ sessionId: this.id }); diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 98c39aaf29..46efacd3da 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -311,6 +311,75 @@ describe('Session.prompt events', () => { } }); + it('starts btw through RPC as a forked subagent without prompt metadata updates', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = new KimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await configureFakeProvider(harness); + const session = await harness.createSession({ id: 'ses_btw_rpc', workDir }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + events.push(event); + }); + + let done = waitForEvent(session, (event) => event.type === 'turn.ended'); + await session.prompt('main task context'); + await done; + + fakeProviderState.responseText = 'The main agent is working from the existing context.'; + events.length = 0; + done = waitForEvent( + session, + (event) => event.type === 'turn.ended' && event.agentId !== 'main', + ); + + await session.startBtw('你现在在做啥?'); + await done; + unsubscribe(); + + const started = events.find( + (event) => + event.type === 'turn.started' && + event.origin.kind === 'system_trigger' && + event.origin.name === 'btw', + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + sessionId: session.id, + agentId: started?.agentId, + origin: { kind: 'system_trigger', name: 'btw' }, + }), + ); + expect(started?.agentId).not.toBe('main'); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.spawned' })); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.completed' })); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.failed' })); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'session.meta.updated', + }), + ); + expect(fakeProviderState.calls[1]?.systemPrompt).toBe( + fakeProviderState.calls[0]?.systemPrompt, + ); + const btwHistoryText = JSON.stringify(fakeProviderState.calls[1]?.history); + expect(btwHistoryText).toContain('main task context'); + expect(btwHistoryText).toContain('你现在在做啥?'); + + const statePath = join(session.summary!.sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + expect(state['lastPrompt']).toBe('main task context'); + } finally { + await harness.close(); + } + }); + it('rejects empty prompt input', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index df32e2b4be..330dc2eccb 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -24,4 +24,40 @@ describe('Session.prompt input normalization', () => { input, }); }); + + it('normalizes btw prompt text before calling the core RPC client', async () => { + const startBtw = vi.fn(async () => {}); + const session = new Session({ + id: 'ses_btw_prompt', + workDir: '/tmp/work', + rpc: { startBtw } as unknown as SDKRpcClient, + }); + + await expect(session.startBtw(' side question ')).resolves.toBeUndefined(); + expect(startBtw).toHaveBeenCalledWith({ + sessionId: 'ses_btw_prompt', + prompt: 'side question', + }); + + await expect(session.startBtw(' ')).rejects.toMatchObject({ + name: 'KimiError', + code: 'request.prompt_input_empty', + }); + expect(startBtw).toHaveBeenCalledTimes(1); + }); + + it('calls the core RPC client to cancel btw', async () => { + const cancelBtw = vi.fn(async () => {}); + const session = new Session({ + id: 'ses_btw_cancel', + workDir: '/tmp/work', + rpc: { cancelBtw } as unknown as SDKRpcClient, + }); + + await session.cancelBtw(); + + expect(cancelBtw).toHaveBeenCalledWith({ + sessionId: 'ses_btw_cancel', + }); + }); }); From 461ce0c986ecbfb544f1d8cfc6d7842dfeb4e82c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 20:53:10 +0800 Subject: [PATCH 02/18] feat: support multi-turn btw side conversations --- .changeset/btw-side-agent.md | 2 +- apps/kimi-code/src/tui/commands/btw.ts | 168 ++++++++++++++---- apps/kimi-code/src/tui/commands/dispatch.ts | 1 + .../tui/controllers/session-event-handler.ts | 61 ++----- apps/kimi-code/src/tui/kimi-tui.ts | 55 +++++- .../test/tui/kimi-tui-message-flow.test.ts | 128 ++++++------- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- packages/agent-core/src/agent/index.ts | 3 +- packages/agent-core/src/rpc/core-api.ts | 7 +- packages/agent-core/src/rpc/core-impl.ts | 7 +- packages/agent-core/src/session/rpc.ts | 9 +- .../agent-core/src/session/subagent-host.ts | 83 +++------ packages/agent-core/test/session/init.test.ts | 67 ++++--- packages/node-sdk/src/rpc.ts | 15 +- packages/node-sdk/src/session.ts | 14 +- .../test/session-prompt-events.test.ts | 14 +- .../test/session-prompt-input.test.ts | 32 +--- 18 files changed, 348 insertions(+), 322 deletions(-) diff --git a/.changeset/btw-side-agent.md b/.changeset/btw-side-agent.md index 1d02c5fa59..9224db0921 100644 --- a/.changeset/btw-side-agent.md +++ b/.changeset/btw-side-agent.md @@ -4,4 +4,4 @@ "@moonshot-ai/kimi-code": minor --- -Add `/btw` for asking side-channel questions without steering the active main turn. +Add `/btw` for side-channel conversations without steering the active main turn. diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index d249d7a187..1bf0f64050 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -1,5 +1,6 @@ import type { Component, Focusable, MarkdownTheme } from '@earendil-works/pi-tui'; import { + Input, Key, Markdown, Text, @@ -17,9 +18,18 @@ import type { SlashCommandHost } from './dispatch'; type BtwPanelPhase = 'running' | 'done' | 'failed'; +interface BtwTurn { + readonly prompt: string; + answer: string; + thinking: string; + error?: string | undefined; + phase: BtwPanelPhase; +} + export interface BtwPanelOptions { readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; + readonly onPrompt: (prompt: string) => void; readonly onClose: () => void; readonly onCancel: () => void; } @@ -38,39 +48,85 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr } try { - await session.startBtw(prompt); + const agentId = await session.startBtw(); + host.openBtwPanel(agentId, prompt); } catch (error) { host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); } } export class BtwPanelComponent implements Component, Focusable { - private answer = ''; - private thinking = ''; - private error: string | undefined; - private phase: BtwPanelPhase = 'running'; + private readonly turns: BtwTurn[] = []; + private readonly input = new Input(); focused = false; - constructor(private readonly options: BtwPanelOptions) {} + constructor(private readonly options: BtwPanelOptions) { + this.input.onSubmit = (value) => { + if (this.isRunning()) return; + const prompt = value.trim(); + if (prompt.length === 0) { + this.options.onClose(); + return; + } + this.input.setValue(''); + this.submit(prompt); + }; + this.input.onEscape = () => { + if (this.isRunning()) { + this.options.onCancel(); + } else { + this.options.onClose(); + } + }; + } + + submit(prompt: string): void { + const normalized = prompt.trim(); + if (normalized.length === 0 || this.isRunning()) return; + this.turns.push({ + prompt: normalized, + answer: '', + thinking: '', + phase: 'running', + }); + this.options.onPrompt(normalized); + } appendAnswer(delta: string): void { - this.answer += delta; + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.answer += delta; } appendThinking(delta: string): void { - this.thinking += delta; + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.thinking += delta; } markDone(resultSummary?: string | undefined): void { - if (this.answer.trim().length === 0 && resultSummary !== undefined) { - this.answer = resultSummary; + const turn = this.currentTurn(); + if (turn === undefined) return; + if (turn.answer.trim().length === 0 && resultSummary !== undefined) { + turn.answer = resultSummary; } - this.phase = 'done'; + turn.phase = 'done'; } markFailed(error: string): void { - this.error = error; - this.phase = 'failed'; + const turn = this.currentTurn(); + if (turn === undefined || turn.phase !== 'running') { + this.turns.push({ + prompt: '', + answer: '', + thinking: '', + error, + phase: 'failed', + }); + return; + } + turn.error = error; + turn.phase = 'failed'; } invalidate(): void {} @@ -86,17 +142,13 @@ export class BtwPanelComponent implements Component, Focusable { } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { - if (this.phase === 'running') { + if (this.isRunning()) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.options.onCancel(); - } else { - this.options.onClose(); } return; } - if (matchesKey(data, Key.enter) && this.phase !== 'running') { - this.options.onClose(); - } + this.input.handleInput(data); } private renderTopBorder(width: number): string { @@ -110,7 +162,8 @@ export class BtwPanelComponent implements Component, Focusable { } private renderPhase(): string { - switch (this.phase) { + const phase = this.currentTurn()?.phase ?? 'done'; + switch (phase) { case 'done': return chalk.hex(this.options.colors.success)('done'); case 'failed': @@ -121,29 +174,60 @@ export class BtwPanelComponent implements Component, Focusable { } private renderBody(width: number): string[] { - if (this.error !== undefined) { + const lines: string[] = []; + for (const [index, turn] of this.turns.entries()) { + if (index > 0) lines.push(''); + lines.push(...this.renderTurn(turn, width)); + } + if (this.turns.length === 0) { + lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + } + if (!this.isRunning()) { + this.input.focused = this.focused; + lines.push(''); + lines.push(this.renderInput(width)); + } + lines.push(this.renderHint()); + return lines; + } + + private renderTurn(turn: BtwTurn, width: number): string[] { + const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + if (turn.error !== undefined) { return [ - ...new Text(chalk.hex(this.options.colors.error)(this.error), 0, 0).render(width), - this.renderHint(), + ...new Text(prompt, 0, 0).render(width), + ...new Text(chalk.hex(this.options.colors.error)(turn.error), 0, 0).render(width), ]; } - const text = this.answer.trim(); - if (text.length > 0) { + const answer = turn.answer.trim(); + if (answer.length > 0) { return [ - ...new Markdown(text, 0, 0, this.options.markdownTheme).render(width), - this.renderHint(), + ...new Text(prompt, 0, 0).render(width), + ...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width), ]; } - const thinking = this.thinking.trim(); + const thinking = turn.thinking.trim(); if (thinking.length > 0) { - const lines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render(width); - const visibleLines = - lines.length > THINKING_PREVIEW_LINES - ? lines.slice(lines.length - THINKING_PREVIEW_LINES) - : lines; - return [...visibleLines, this.renderHint()]; + const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + return [...new Text(prompt, 0, 0).render(width), ...visibleThinking]; } - return [chalk.hex(this.options.colors.textDim)('Waiting for answer...'), this.renderHint()]; + return [ + ...new Text(prompt, 0, 0).render(width), + chalk.hex(this.options.colors.textDim)('Waiting for answer...'), + ]; + } + + private renderInput(width: number): string { + const label = chalk.hex(this.options.colors.textMuted)('Ask: '); + const inputWidth = Math.max(1, width - visibleWidth(label)); + const [line = ''] = this.input.render(inputWidth); + return label + line; } private renderBodyLine(line: string, width: number): string { @@ -156,7 +240,17 @@ export class BtwPanelComponent implements Component, Focusable { } private renderHint(): string { - const text = this.phase === 'running' ? 'Esc/Ctrl-C cancel' : 'Enter/Esc/Ctrl-C close'; + const text = this.isRunning() + ? 'Esc/Ctrl-C cancel' + : 'Type follow-up, Enter send, empty Enter/Esc/Ctrl-C close'; return chalk.hex(this.options.colors.textMuted)(text); } + + private currentTurn(): BtwTurn | undefined { + return this.turns.at(-1); + } + + private isRunning(): boolean { + return this.currentTurn()?.phase === 'running'; + } } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index f8a5f575b7..ddf7a8ec69 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -101,6 +101,7 @@ export interface SlashCommandHost { showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; track(event: string, props?: Record): void; + openBtwPanel(agentId: string, initialPrompt: string): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index d7e46d2ff0..7a24843306 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -40,7 +40,6 @@ import { } from '../constant/kimi-tui'; import { argsRecord, - formatErrorMessage, isTodoItemShape, serializeToolResultOutput, stringValue, @@ -96,14 +95,6 @@ export interface SessionEventHost { readonly tasksBrowserController: TasksBrowserController; } -type BtwTurnStartedEvent = Event & - TurnStartedEvent & { - readonly origin: { - readonly kind: 'system_trigger'; - readonly name: 'btw'; - }; - }; - export class SessionEventHandler { constructor(private readonly host: SessionEventHost) {} @@ -236,6 +227,22 @@ export class SessionEventHandler { this.mcpServerStatusSpinners.clear(); } + registerBtwPanel(agentId: string, panel: BtwPanelComponent): void { + this.subagentInfo.set(agentId, { + parentToolCallId: '', + name: 'btw', + btwPanel: panel, + }); + } + + unregisterBtwPanel(panel: BtwPanelComponent): void { + for (const [agentId, info] of this.subagentInfo) { + if (info.btwPanel === panel) { + this.subagentInfo.delete(agentId); + } + } + } + // --------------------------------------------------------------------------- // Private handlers // --------------------------------------------------------------------------- @@ -246,10 +253,6 @@ export class SessionEventHandler { const { streamingUI } = this.host; const info = this.subagentInfo.get(subagentId); - if (info === undefined && isBtwTurnStarted(event)) { - this.handleBtwTurnStarted(event); - return true; - } if (info === undefined) return true; if (info.btwPanel !== undefined) { return this.routeBtwEvent(info.btwPanel, event); @@ -350,7 +353,6 @@ export class SessionEventHandler { } else { panel.markFailed(formatBtwTurnEnd(event)); } - this.subagentInfo.delete(event.agentId); this.host.state.ui.requestRender(); return true; case 'agent.status.updated': @@ -385,29 +387,6 @@ export class SessionEventHandler { } } - private handleBtwTurnStarted(event: BtwTurnStartedEvent): void { - let panel: BtwPanelComponent; - panel = new BtwPanelComponent({ - colors: this.host.state.theme.colors, - markdownTheme: this.host.state.theme.markdownTheme, - onClose: () => { - this.host.closeBtwPanel(panel); - }, - onCancel: () => { - this.host.closeBtwPanel(panel); - void this.host.session?.cancelBtw().catch((error: unknown) => { - this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); - }); - }, - }); - this.subagentInfo.set(event.agentId, { - parentToolCallId: '', - name: 'btw', - btwPanel: panel, - }); - this.host.showBtwPanel(panel); - } - private handleTurnBegin(_event: TurnStartedEvent): void { void _event; this.host.streamingUI.resetToolUi(); @@ -1089,14 +1068,6 @@ export class SessionEventHandler { } } -function isBtwTurnStarted(event: Event): event is BtwTurnStartedEvent { - return ( - event.type === 'turn.started' && - event.origin.kind === 'system_trigger' && - event.origin.name === 'btw' - ); -} - function formatBtwTurnEnd(event: TurnEndedEvent): string { if (event.error !== undefined) { return `[${event.error.code}] ${event.error.message}`; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 8111e20bc8..115c7f5b68 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -40,7 +40,7 @@ import { type KimiSlashCommand, type SkillListSession, } from './commands'; -import type { BtwPanelComponent } from './commands/btw'; +import { BtwPanelComponent } from './commands/btw'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; import { CHROME_GUTTER } from './constant/rendering'; @@ -1635,11 +1635,64 @@ export class KimiTUI { closeBtwPanel(panel: BtwPanelComponent): void { if (!this.state.btwPanelContainer.children.includes(panel)) return; + this.sessionEventHandler.unregisterBtwPanel(panel); this.state.btwPanelContainer.clear(); this.state.ui.setFocus(this.state.editor); this.state.ui.requestRender(); } + openBtwPanel(agentId: string, initialPrompt: string): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + colors: this.state.theme.colors, + markdownTheme: this.state.theme.markdownTheme, + onPrompt: (prompt) => { + this.promptBtwAgent(agentId, prompt, panel); + }, + onClose: () => { + this.closeBtwPanel(panel); + }, + onCancel: () => { + this.closeBtwPanel(panel); + void this.cancelSideAgent(agentId); + }, + }); + this.sessionEventHandler.registerBtwPanel(agentId, panel); + this.showBtwPanel(panel); + panel.submit(initialPrompt); + } + + private promptBtwAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { + const session = this.session; + if (session === undefined) { + panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); + this.state.ui.requestRender(); + return; + } + void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.state.ui.requestRender(); + }); + } + + private async cancelSideAgent(agentId: string): Promise { + const session = this.session; + if (session === undefined) return; + await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + this.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + } + + private async withInteractiveAgent(agentId: string, fn: () => Promise): Promise { + const previousAgentId = this.harness.interactiveAgentId; + this.harness.interactiveAgentId = agentId; + try { + return await fn(); + } finally { + this.harness.interactiveAgentId = previousAgentId; + } + } + private async runMigrationScreen(plan: MigrationPlan): Promise { const result = await new Promise((resolve) => { const screen = new MigrationScreenComponent({ 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 d831ecf586..045dc1ca15 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 @@ -112,8 +112,7 @@ function makeSession(overrides: Record = {}) { prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), init: vi.fn(async () => {}), - startBtw: vi.fn(async () => {}), - cancelBtw: vi.fn(async () => {}), + startBtw: vi.fn(async () => 'agent-btw'), undoHistory: vi.fn(async () => {}), cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), @@ -261,6 +260,18 @@ function getMountedBtwPanel(driver: MessageDriver): MountedBtwPanel { return panel as unknown as MountedBtwPanel; } +async function openBtwPanel( + driver: MessageDriver, + session: ReturnType, + prompt = 'side question', +): Promise { + driver.handleUserInput(`/btw ${prompt}`); + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + }); +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -1340,9 +1351,11 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('/btw 你现在在做啥?'); await vi.waitFor(() => { - expect(session.startBtw).toHaveBeenCalledWith('你现在在做啥?'); + expect(session.startBtw).toHaveBeenCalledWith(); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('你现在在做啥?'); }); - expect(session.prompt).not.toHaveBeenCalled(); expect(session.steer).not.toHaveBeenCalled(); expect(driver.state.appState.streamingPhase).toBe('composing'); expect(driver.state.livePane.mode).toBe('thinking'); @@ -1350,21 +1363,10 @@ describe('KimiTUI message flow', () => { }); it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { - const { driver } = await makeDriver(); + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, '你现在在做啥?'); - driver.sessionEventHandler.handleEvent( - { - type: 'turn.started', - agentId: 'agent-btw', - sessionId: 'ses-1', - turnId: 0, - origin: { - kind: 'system_trigger', - name: 'btw', - }, - } as Event, - () => {}, - ); driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', @@ -1394,6 +1396,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); expect(panel).toContain('BTW done'); + expect(panel).toContain('Q: 你现在在做啥?'); expect(panel).toContain('正在实现 /btw 的独立面板。'); expect(panel).not.toContain('Agent'); expect(transcript).not.toContain('BTW done'); @@ -1401,21 +1404,10 @@ describe('KimiTUI message flow', () => { }); it('keeps the /btw panel closest to the input after later transcript output', async () => { - const { driver } = await makeDriver(); + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); - driver.sessionEventHandler.handleEvent( - { - type: 'turn.started', - agentId: 'agent-btw', - sessionId: 'ses-1', - turnId: 0, - origin: { - kind: 'system_trigger', - name: 'btw', - }, - } as Event, - () => {}, - ); driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', @@ -1473,21 +1465,10 @@ describe('KimiTUI message flow', () => { }); it('renders only the tail of /btw thinking output', async () => { - const { driver } = await makeDriver(); + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); - driver.sessionEventHandler.handleEvent( - { - type: 'turn.started', - agentId: 'agent-btw', - sessionId: 'ses-1', - turnId: 0, - origin: { - kind: 'system_trigger', - name: 'btw', - }, - } as Event, - () => {}, - ); driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', @@ -1511,27 +1492,14 @@ describe('KimiTUI message flow', () => { it('cancels and closes a running /btw panel on Escape', async () => { const session = makeSession(); const { driver } = await makeDriver(session); - - driver.sessionEventHandler.handleEvent( - { - type: 'turn.started', - agentId: 'agent-btw', - sessionId: 'ses-1', - turnId: 0, - origin: { - kind: 'system_trigger', - name: 'btw', - }, - } as Event, - () => {}, - ); + await openBtwPanel(driver, session); const panel = getMountedBtwPanel(driver); expect(panel.focused).toBe(true); panel.handleInput('\x1b'); - expect(session.cancelBtw).toHaveBeenCalledOnce(); + expect(session.cancel).toHaveBeenCalledOnce(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(driver.state.editor.focused).toBe(true); }); @@ -1539,20 +1507,34 @@ describe('KimiTUI message flow', () => { it('closes a completed /btw panel on Enter without cancelling it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); driver.sessionEventHandler.handleEvent( { - type: 'turn.started', + type: 'turn.ended', agentId: 'agent-btw', sessionId: 'ses-1', turnId: 0, - origin: { - kind: 'system_trigger', - name: 'btw', - }, + reason: 'completed', } as Event, () => {}, ); + + const panel = getMountedBtwPanel(driver); + expect(panel.focused).toBe(true); + + panel.handleInput('\r'); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + }); + + it('sends follow-up /btw input through ordinary prompt on the same side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', @@ -1565,13 +1547,17 @@ describe('KimiTUI message flow', () => { ); const panel = getMountedBtwPanel(driver); - expect(panel.focused).toBe(true); - + for (const ch of 'follow up') { + panel.handleInput(ch); + } panel.handleInput('\r'); - expect(session.cancelBtw).not.toHaveBeenCalled(); - expect(driver.state.btwPanelContainer.children).toHaveLength(0); - expect(driver.state.editor.focused).toBe(true); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('follow up'); + }); + expect(session.prompt).toHaveBeenCalledTimes(2); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(false); }); it('does not run /btw without a question or selected model', async () => { diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 335dd09623..abbfabe59b 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -53,7 +53,7 @@ Some commands are only available in the idle state. Running them while the sessi | Command | Alias | Description | Always available | | --- | --- | --- | --- | | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | -| `/btw ` | — | Ask a side-channel question in a forked subagent without steering the current main agent turn. | Yes | +| `/btw ` | — | Open a side-channel conversation in a forked subagent without steering the current main agent turn. | 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 3bd9d12291..e91b220387 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -53,7 +53,7 @@ | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | -| `/btw <问题>` | — | 在 fork 出的子 Agent 中回答旁路问题,不改变当前主 Agent 轮次。 | 是 | +| `/btw <问题>` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次。 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 7d9699b85d..1211a06828 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -379,8 +379,7 @@ export class Agent { } this.skills.activate(payload); }, - startBtw: (payload) => this.subagentHost!.startBtw(payload.prompt), - cancelBtw: () => this.subagentHost!.cancelBtw(), + startBtw: () => this.subagentHost!.startBtw(), getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), getContext: () => this.context.data(), getConfig: () => this.config.data(), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index f42860c495..0f26ef69e8 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -201,10 +201,6 @@ export interface ActivateSkillPayload { readonly args?: string | undefined; } -export interface StartBtwPayload { - readonly prompt: string; -} - export interface McpServerInfo { readonly name: string; readonly transport: 'stdio' | 'http'; @@ -285,8 +281,7 @@ export interface AgentAPI { stopBackground: (payload: StopBackgroundPayload) => void; clearContext: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; - startBtw: (payload: StartBtwPayload) => void; - cancelBtw: (payload: EmptyPayload) => void; + startBtw: (payload: EmptyPayload) => string; getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; getContext: (payload: EmptyPayload) => AgentContextData; getConfig: (payload: EmptyPayload) => AgentConfigData; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index b7f4aa33e1..4fab97ce8d 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -81,7 +81,6 @@ import type { SetPluginMcpServerEnabledPayload, SetThinkingPayload, SkillSummary, - StartBtwPayload, SteerPayload, StopBackgroundPayload, UndoHistoryPayload, @@ -574,14 +573,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).generateAgentsMd(payload); } - startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { + startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { return this.sessionApi(sessionId).startBtw(payload); } - cancelBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { - return this.sessionApi(sessionId).cancelBtw(payload); - } - async installPlugin(payload: InstallPluginPayload): Promise { await this.pluginsReady; this.assertPluginsLoaded(); diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 7f988f29ea..a4110725e3 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -20,7 +20,6 @@ import type { SetPermissionPayload, SetThinkingPayload, SkillSummary, - StartBtwPayload, SteerPayload, StopBackgroundPayload, UndoHistoryPayload, @@ -89,12 +88,8 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.generateAgentsMd(); } - async startBtw({ agentId, ...payload }: AgentScopedPayload): Promise { - await this.getAgent(agentId).startBtw(payload); - } - - async cancelBtw({ agentId, ...payload }: AgentScopedPayload): Promise { - await this.getAgent(agentId).cancelBtw(payload); + async startBtw({ agentId, ...payload }: AgentScopedPayload): Promise { + return this.getAgent(agentId).startBtw(payload); } async prompt({ agentId, ...payload }: AgentScopedPayload) { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 8135e0456a..54bac47712 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -4,7 +4,6 @@ import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; -import { ErrorCodes, KimiError } from '../errors'; import type { LoopTurnStopReason } from '../loop'; import { DEFAULT_AGENT_PROFILES, @@ -29,16 +28,17 @@ const SUBAGENT_MAX_TOKENS_ERROR = const TOOL_CALL_DISABLED_MESSAGE = 'Tool calls are disabled for side questions. Answer with text only.'; const SIDE_QUESTION_SYSTEM_REMINDER = ` -This is a side question from the user. Answer directly in a single response. +This is a side-channel conversation with the user. IMPORTANT: -- You are a separate, lightweight instance answering one question. +- You are a separate, lightweight instance. - The main agent continues independently; do not reference being interrupted. - Do not call any tools. All tool calls are disabled and will be rejected. Even though tool definitions are visible in this request, they exist only for technical reasons (prompt cache). You must not use them. -- Respond only with text based on what you already know from the conversation. -- This is a one-off response; there are no follow-up turns. +- Respond only with text based on what you already know from the conversation + and this side-channel conversation. +- Follow-up turns may happen in this side-channel conversation. - If you do not know the answer, say so directly. `; @@ -71,7 +71,6 @@ export type SubagentHandle = { export class SessionSubagentHost { private readonly activeChildren = new Map(); - private currentBtwController: AbortController | undefined; constructor( private readonly session: Session, @@ -186,59 +185,27 @@ export class SessionSubagentHost { }; } - async startBtw(prompt: string): Promise { - if (this.currentBtwController !== undefined) { - throw new KimiError(ErrorCodes.REQUEST_INVALID, 'BTW is already running'); - } - const controller = new AbortController(); - this.currentBtwController = controller; - - try { - const parent = this.session.agents.get(this.ownerAgentId)!; - const { agent: child } = await this.session.createAgent( - { - type: 'sub', - generate: parent.rawGenerate, - persistence: new InMemoryAgentRecordPersistence(), - }, - undefined, - this.ownerAgentId, - ); - - child.config.update(parent.config); - child.tools.copyLoopToolsFrom(parent.tools); - child.context.appendProjectedHistoryFrom(parent.context); - child.permission.policies.unshift(new DenyAllPermissionPolicy(TOOL_CALL_DISABLED_MESSAGE)); - - child.turn.prompt( - [ - { - type: 'text', - text: `\n${SIDE_QUESTION_SYSTEM_REMINDER.trim()}\n\n\n${prompt}`, - }, - ], - { - kind: 'system_trigger', - name: 'btw', - }, - ); - await child.turn.waitForCurrentTurn(controller.signal); - } catch (error) { - if (!controller.signal.aborted) { - throw error; - } - } finally { - if (this.currentBtwController === controller) { - this.currentBtwController = undefined; - } - } - } + async startBtw(): Promise { + const parent = this.session.agents.get(this.ownerAgentId)!; + const { id, agent: child } = await this.session.createAgent( + { + type: 'sub', + generate: parent.rawGenerate, + persistence: new InMemoryAgentRecordPersistence(), + }, + undefined, + this.ownerAgentId, + ); - cancelBtw(reason: unknown = userCancellationReason()): void { - const controller = this.currentBtwController; - if (controller === undefined) return; - this.currentBtwController = undefined; - controller.abort(reason); + child.config.update(parent.config); + child.tools.copyLoopToolsFrom(parent.tools); + child.context.appendProjectedHistoryFrom(parent.context); + child.context.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { + kind: 'system_trigger', + name: 'btw', + }); + child.permission.policies.unshift(new DenyAllPermissionPolicy(TOOL_CALL_DISABLED_MESSAGE)); + return id; } cancelAll(reason: unknown = userCancellationReason()): void { diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index e6205cee20..891e5fbc53 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -220,9 +220,14 @@ describe('AgentAPI.startBtw', () => { scripted.mockNextResponse({ type: 'text', text: summary }); try { - await new SessionAPIImpl(session).startBtw({ - agentId: 'main', - prompt: 'What are you working on right now?', + const api = new SessionAPIImpl(session); + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + expect(scripted.calls).toHaveLength(0); + + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'What are you working on right now?' }], }); await vi.waitFor(() => { @@ -236,12 +241,12 @@ describe('AgentAPI.startBtw', () => { }); expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); expect(events).toContainEqual( - expect.objectContaining({ - type: 'turn.started', - agentId: 'agent-0', - origin: { kind: 'system_trigger', name: 'btw' }, - }), - ); + expect.objectContaining({ + type: 'turn.started', + agentId: 'agent-0', + origin: { kind: 'user' }, + }), + ); expect(scripted.calls).toHaveLength(1); expect(scripted.calls[0]?.systemPrompt).toBe(''); expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([ @@ -250,7 +255,7 @@ describe('AgentAPI.startBtw', () => { ]); const historyText = JSON.stringify(scripted.calls[0]?.history); expect(historyText).toContain('Main task: implement /btw.'); - expect(historyText).toContain('This is a side question from the user.'); + expect(historyText).toContain('This is a side-channel conversation with the user.'); expect(historyText).toContain('All tool calls are disabled and will be rejected.'); expect(historyText).toContain('What are you working on right now?'); expect(historyText).not.toContain('call-open'); @@ -260,6 +265,17 @@ describe('AgentAPI.startBtw', () => { expect(JSON.stringify(session.agents.get('agent-0')?.context.history)).toContain( 'What are you working on right now?', ); + scripted.mockNextResponse({ type: 'text', text: 'Follow-up answer from the same side agent.' }); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'Can you say that another way?' }], + }); + await vi.waitFor(() => { + expect(scripted.calls).toHaveLength(2); + }); + const followUpHistoryText = JSON.stringify(scripted.calls[1]?.history); + expect(followUpHistoryText).toContain('What are you working on right now?'); + expect(followUpHistoryText).toContain('Can you say that another way?'); await expect(access(join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'))).rejects.toThrow(); } finally { await session.close(); @@ -300,9 +316,12 @@ describe('AgentAPI.startBtw', () => { }); try { - await new SessionAPIImpl(session).startBtw({ - agentId: 'main', - prompt: 'What are you working on right now?', + const api = new SessionAPIImpl(session); + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'What are you working on right now?' }], }); await vi.waitFor(() => { @@ -346,7 +365,7 @@ describe('AgentAPI.startBtw', () => { } }); - it('rejects concurrent side subagents and cancels the current one', async () => { + it('cancels a btw turn through the returned agent id', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); @@ -386,16 +405,11 @@ describe('AgentAPI.startBtw', () => { try { const api = new SessionAPIImpl(session); - const start = api.startBtw({ - agentId: 'main', - prompt: 'Where are things right now?', - }); - await expect( - api.startBtw({ agentId: 'main', prompt: 'Ask another question' }), - ).rejects.toMatchObject({ - name: 'KimiError', - code: 'request.invalid', - message: 'BTW is already running', + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'Where are things right now?' }], }); await vi.waitFor(() => { @@ -403,13 +417,12 @@ describe('AgentAPI.startBtw', () => { expect.objectContaining({ type: 'turn.started', agentId: 'agent-0', - origin: { kind: 'system_trigger', name: 'btw' }, + origin: { kind: 'user' }, }), ); }); - await api.cancelBtw({ agentId: 'main' }); - await expect(start).resolves.toBeUndefined(); + await api.cancel({ agentId }); await vi.waitFor(() => { expect(events).toContainEqual( diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 39578d6728..0d85828296 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -98,10 +98,6 @@ export interface ActivateSkillRpcInput extends SessionIdRpcInput { readonly args?: string | undefined; } -export interface StartBtwRpcInput extends SessionIdRpcInput { - readonly prompt: string; -} - export interface ReconnectMcpServerRpcInput extends SessionIdRpcInput { readonly name: string; } @@ -240,20 +236,11 @@ export class SDKRpcClient { return rpc.generateAgentsMd({ sessionId: input.sessionId }); } - async startBtw(input: StartBtwRpcInput): Promise { + async startBtw(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.startBtw({ sessionId: input.sessionId, agentId: this.interactiveAgentId, - prompt: input.prompt, - }); - } - - async cancelBtw(input: SessionIdRpcInput): Promise { - const rpc = await this.getRpc(); - return rpc.cancelBtw({ - sessionId: input.sessionId, - agentId: this.interactiveAgentId, }); } diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index fa5c5c9144..d8c3cea634 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -95,19 +95,9 @@ export class Session { await this.rpc.generateAgentsMd({ sessionId: this.id }); } - async startBtw(prompt: string): Promise { + async startBtw(): Promise { this.ensureOpen(); - const normalized = normalizeRequiredString( - prompt, - 'BTW prompt cannot be empty', - ErrorCodes.REQUEST_PROMPT_INPUT_EMPTY, - ); - await this.rpc.startBtw({ sessionId: this.id, prompt: normalized }); - } - - async cancelBtw(): Promise { - this.ensureOpen(); - await this.rpc.cancelBtw({ sessionId: this.id }); + return this.rpc.startBtw({ sessionId: this.id }); } async cancel(): Promise { diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 46efacd3da..3c7c0eef65 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -338,22 +338,24 @@ describe('Session.prompt events', () => { (event) => event.type === 'turn.ended' && event.agentId !== 'main', ); - await session.startBtw('你现在在做啥?'); + const agentId = await session.startBtw(); + harness.interactiveAgentId = agentId; + await session.prompt('What are you working on right now?'); await done; unsubscribe(); const started = events.find( (event) => event.type === 'turn.started' && - event.origin.kind === 'system_trigger' && - event.origin.name === 'btw', + event.agentId === agentId && + event.origin.kind === 'user', ); expect(events).toContainEqual( expect.objectContaining({ type: 'turn.started', sessionId: session.id, - agentId: started?.agentId, - origin: { kind: 'system_trigger', name: 'btw' }, + agentId, + origin: { kind: 'user' }, }), ); expect(started?.agentId).not.toBe('main'); @@ -370,7 +372,7 @@ describe('Session.prompt events', () => { ); const btwHistoryText = JSON.stringify(fakeProviderState.calls[1]?.history); expect(btwHistoryText).toContain('main task context'); - expect(btwHistoryText).toContain('你现在在做啥?'); + expect(btwHistoryText).toContain('What are you working on right now?'); const statePath = join(session.summary!.sessionDir, 'state.json'); const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index 330dc2eccb..a3d2f04835 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -25,39 +25,17 @@ describe('Session.prompt input normalization', () => { }); }); - it('normalizes btw prompt text before calling the core RPC client', async () => { - const startBtw = vi.fn(async () => {}); + it('starts btw and returns the forked agent id', async () => { + const startBtw = vi.fn(async () => 'agent-btw'); const session = new Session({ - id: 'ses_btw_prompt', + id: 'ses_btw_start', workDir: '/tmp/work', rpc: { startBtw } as unknown as SDKRpcClient, }); - await expect(session.startBtw(' side question ')).resolves.toBeUndefined(); + await expect(session.startBtw()).resolves.toBe('agent-btw'); expect(startBtw).toHaveBeenCalledWith({ - sessionId: 'ses_btw_prompt', - prompt: 'side question', - }); - - await expect(session.startBtw(' ')).rejects.toMatchObject({ - name: 'KimiError', - code: 'request.prompt_input_empty', - }); - expect(startBtw).toHaveBeenCalledTimes(1); - }); - - it('calls the core RPC client to cancel btw', async () => { - const cancelBtw = vi.fn(async () => {}); - const session = new Session({ - id: 'ses_btw_cancel', - workDir: '/tmp/work', - rpc: { cancelBtw } as unknown as SDKRpcClient, - }); - - await session.cancelBtw(); - - expect(cancelBtw).toHaveBeenCalledWith({ - sessionId: 'ses_btw_cancel', + sessionId: 'ses_btw_start', }); }); }); From d02b48cc405bf27c904c44bec5dd1e645e81de42 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 20:57:22 +0800 Subject: [PATCH 03/18] fix: restore main agent after btw prompt --- apps/kimi-code/src/tui/kimi-tui.ts | 4 +-- .../test/tui/kimi-tui-message-flow.test.ts | 28 +++++++++++++++++++ packages/node-sdk/src/rpc.ts | 12 +++++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 115c7f5b68..b0546f8e3b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1683,11 +1683,11 @@ export class KimiTUI { }); } - private async withInteractiveAgent(agentId: string, fn: () => Promise): Promise { + private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { const previousAgentId = this.harness.interactiveAgentId; this.harness.interactiveAgentId = agentId; try { - return await fn(); + return fn(); } finally { this.harness.interactiveAgentId = previousAgentId; } 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 045dc1ca15..87a8271c69 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 @@ -1560,6 +1560,34 @@ describe('KimiTUI message flow', () => { expect(driver.state.editor.focused).toBe(false); }); + it('restores the main interactive agent immediately after starting a /btw prompt', async () => { + let resolveBtwPrompt: (() => void) | undefined; + const session = makeSession({ + prompt: vi.fn( + () => + new Promise((resolve) => { + resolveBtwPrompt = resolve; + }), + ), + }); + const { driver, harness } = await makeDriver(session); + + await openBtwPanel(driver, session, 'slow side question'); + + expect(harness.interactiveAgentId).toBe('main'); + driver.state.appState.streamingPhase = 'waiting'; + driver.handleUserInput('main follow-up while btw prompt is pending'); + + expect(driver.state.queuedMessages).toEqual([ + expect.objectContaining({ + text: 'main follow-up while btw prompt is pending', + agentId: 'main', + }), + ]); + + resolveBtwPrompt?.(); + }); + it('does not run /btw without a question or selected model', async () => { const { driver, session } = await makeDriver(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 0d85828296..9b22ef2b91 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -214,19 +214,21 @@ export class SDKRpcClient { } async prompt(input: SessionPromptRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.prompt({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, input: input.input, }); } async steer(input: SessionPromptRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.steer({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, input: input.input, }); } @@ -237,18 +239,20 @@ export class SDKRpcClient { } async startBtw(input: SessionIdRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.startBtw({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, }); } async cancel(input: SessionIdRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.cancel({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, }); } From c70d3e846d74beaa3c592e8477df876759999050 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 21:02:52 +0800 Subject: [PATCH 04/18] fix: satisfy btw lint checks --- packages/agent-core/src/agent/config/index.ts | 2 +- packages/agent-core/src/session/subagent-host.ts | 8 +++++++- packages/agent-core/test/session/init.test.ts | 10 ++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 65e64904d5..45aca21367 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -27,7 +27,7 @@ export class ConfigState { this._modelAlias = agent.modelProvider?.defaultModel; } - update(changed: AgentConfigUpdateData | ConfigState): void { + update(changed: AgentConfigUpdateData): void { if (Object.keys(changed).length === 0) return; this.agent.records.logRecord({ diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 54bac47712..ed01777246 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -197,7 +197,13 @@ export class SessionSubagentHost { this.ownerAgentId, ); - child.config.update(parent.config); + child.config.update({ + cwd: parent.config.cwd, + modelAlias: parent.config.modelAlias, + profileName: parent.config.profileName, + thinkingLevel: parent.config.thinkingLevel, + systemPrompt: parent.config.systemPrompt, + }); child.tools.copyLoopToolsFrom(parent.tools); child.context.appendProjectedHistoryFrom(parent.context); child.context.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 891e5fbc53..05da037b11 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -376,12 +376,18 @@ describe('AgentAPI.startBtw', () => { if (signal === undefined) { throw new Error('Expected generate signal'); } - return await new Promise((_resolve, reject) => { + return new Promise((_resolve, reject) => { if (signal.aborted) { reject(signal.reason); return; } - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + signal.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true }, + ); }); }, ); From 59586a69cc76bc9dbea4822acac5bf196f22399a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 21:07:09 +0800 Subject: [PATCH 05/18] fix: limit btw config sync --- packages/agent-core/src/session/subagent-host.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index ed01777246..98b1c3fd77 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -198,9 +198,7 @@ export class SessionSubagentHost { ); child.config.update({ - cwd: parent.config.cwd, modelAlias: parent.config.modelAlias, - profileName: parent.config.profileName, thinkingLevel: parent.config.thinkingLevel, systemPrompt: parent.config.systemPrompt, }); From 7d05c461175acb4e0ed6cdaee1538376d9f527ed Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 13:11:19 +0800 Subject: [PATCH 06/18] feat: refine btw panel interactions --- apps/kimi-code/src/tui/commands/btw.ts | 85 +++---------- .../src/tui/controllers/editor-keyboard.ts | 9 ++ apps/kimi-code/src/tui/kimi-tui.ts | 53 ++++++-- .../test/tui/kimi-tui-message-flow.test.ts | 119 ++++++++++++------ 4 files changed, 150 insertions(+), 116 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index 1bf0f64050..5dbf98877f 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -1,10 +1,7 @@ -import type { Component, Focusable, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; import { - Input, - Key, Markdown, Text, - matchesKey, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui'; @@ -30,8 +27,6 @@ export interface BtwPanelOptions { readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; readonly onPrompt: (prompt: string) => void; - readonly onClose: () => void; - readonly onCancel: () => void; } export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { @@ -55,30 +50,11 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr } } -export class BtwPanelComponent implements Component, Focusable { +export class BtwPanelComponent implements Component { private readonly turns: BtwTurn[] = []; - private readonly input = new Input(); - focused = false; + private minBodyLines = 0; - constructor(private readonly options: BtwPanelOptions) { - this.input.onSubmit = (value) => { - if (this.isRunning()) return; - const prompt = value.trim(); - if (prompt.length === 0) { - this.options.onClose(); - return; - } - this.input.setValue(''); - this.submit(prompt); - }; - this.input.onEscape = () => { - if (this.isRunning()) { - this.options.onCancel(); - } else { - this.options.onClose(); - } - }; - } + constructor(private readonly options: BtwPanelOptions) {} submit(prompt: string): void { const normalized = prompt.trim(); @@ -141,38 +117,19 @@ export class BtwPanelComponent implements Component, Focusable { return lines; } - handleInput(data: string): void { - if (this.isRunning()) { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { - this.options.onCancel(); - } - return; - } - this.input.handleInput(data); - } - private renderTopBorder(width: number): string { const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); - const title = `${chalk.hex(this.options.colors.accent).bold(' BTW ')}${this.renderPhase()} `; - const innerWidth = Math.max(1, width - 2); + const title = + chalk.hex(this.options.colors.accent).bold(' BTW ') + + paint('─ ') + + chalk.hex(this.options.colors.textMuted)('Esc to close '); + const innerWidth = Math.max(1, width - 1); const clippedTitle = visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); } - private renderPhase(): string { - const phase = this.currentTurn()?.phase ?? 'done'; - switch (phase) { - case 'done': - return chalk.hex(this.options.colors.success)('done'); - case 'failed': - return chalk.hex(this.options.colors.error)('failed'); - case 'running': - return chalk.hex(this.options.colors.textMuted)('running'); - } - } - private renderBody(width: number): string[] { const lines: string[] = []; for (const [index, turn] of this.turns.entries()) { @@ -182,12 +139,12 @@ export class BtwPanelComponent implements Component, Focusable { if (this.turns.length === 0) { lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); } - if (!this.isRunning()) { - this.input.focused = this.focused; + if (lines.length > this.minBodyLines) { + this.minBodyLines = lines.length; + } + while (lines.length < this.minBodyLines) { lines.push(''); - lines.push(this.renderInput(width)); } - lines.push(this.renderHint()); return lines; } @@ -223,13 +180,6 @@ export class BtwPanelComponent implements Component, Focusable { ]; } - private renderInput(width: number): string { - const label = chalk.hex(this.options.colors.textMuted)('Ask: '); - const inputWidth = Math.max(1, width - visibleWidth(label)); - const [line = ''] = this.input.render(inputWidth); - return label + line; - } - private renderBodyLine(line: string, width: number): string { const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); const contentWidth = Math.max(1, width - 4); @@ -239,18 +189,11 @@ export class BtwPanelComponent implements Component, Focusable { return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); } - private renderHint(): string { - const text = this.isRunning() - ? 'Esc/Ctrl-C cancel' - : 'Type follow-up, Enter send, empty Enter/Esc/Ctrl-C close'; - return chalk.hex(this.options.colors.textMuted)(text); - } - private currentTurn(): BtwTurn | undefined { return this.turns.at(-1); } - private isRunning(): boolean { + isRunning(): boolean { return this.currentTurn()?.phase === 'running'; } } diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 75473d7ae2..4fdeef204b 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -22,6 +22,7 @@ export interface EditorKeyboardHost { cancelInFlight: (() => void) | undefined; handleUserInput(text: string): void; + closeOrCancelBtwPanel(): boolean; steerMessage(session: Session, input: string[]): void; recallLastQueued(): string | undefined; showError(msg: string): void; @@ -73,6 +74,11 @@ export class EditorKeyboardController { return; } + if (editor.getText().length === 0 && host.closeOrCancelBtwPanel()) { + this.clearPendingExit(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); this.cancelCurrentStream(); @@ -110,6 +116,9 @@ export class EditorKeyboardController { this.cancelCurrentCompaction(); return; } + if (host.closeOrCancelBtwPanel()) { + return; + } if (host.state.appState.streamingPhase !== 'idle') { this.cancelCurrentStream(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b0546f8e3b..2036d8f147 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -212,6 +212,12 @@ export class KimiTUI { private startupNotice: string | undefined; private lastActivityMode: string | undefined; private lastHistoryContent: string | undefined; + private activeBtw: + | { + readonly agentId: string; + readonly panel: BtwPanelComponent; + } + | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly sessionEventHandler: SessionEventHandler; @@ -675,6 +681,7 @@ export class KimiTUI { } sendNormalUserInput(text: string): void { + if (this.sendBtwUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; @@ -699,6 +706,20 @@ export class KimiTUI { this.state.ui.requestRender(); } + private sendBtwUserInput(text: string): boolean { + const active = this.activeBtw; + if (active === undefined) return false; + if (active.panel.isRunning()) { + this.state.editor.setText(text); + this.showStatus('Wait for /btw to finish before sending another question.'); + return true; + } + active.panel.submit(text); + this.state.ui.setFocus(this.state.editor); + this.state.ui.requestRender(); + return true; + } + private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -1083,7 +1104,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); - this.state.btwPanelContainer.clear(); + this.clearBtwPanel(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); @@ -1334,7 +1355,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); - this.state.btwPanelContainer.clear(); + this.clearBtwPanel(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); @@ -1629,18 +1650,26 @@ export class KimiTUI { this.state.btwPanelContainer.clear(); this.state.btwPanelContainer.addChild(new Spacer(1)); this.state.btwPanelContainer.addChild(panel); - this.state.ui.setFocus(panel); + this.state.ui.setFocus(this.state.editor); this.state.ui.requestRender(); } closeBtwPanel(panel: BtwPanelComponent): void { if (!this.state.btwPanelContainer.children.includes(panel)) return; this.sessionEventHandler.unregisterBtwPanel(panel); + if (this.activeBtw?.panel === panel) this.activeBtw = undefined; this.state.btwPanelContainer.clear(); this.state.ui.setFocus(this.state.editor); this.state.ui.requestRender(); } + private clearBtwPanel(): void { + const panel = this.activeBtw?.panel; + if (panel !== undefined) this.sessionEventHandler.unregisterBtwPanel(panel); + this.activeBtw = undefined; + this.state.btwPanelContainer.clear(); + } + openBtwPanel(agentId: string, initialPrompt: string): void { let panel: BtwPanelComponent; panel = new BtwPanelComponent({ @@ -1649,19 +1678,23 @@ export class KimiTUI { onPrompt: (prompt) => { this.promptBtwAgent(agentId, prompt, panel); }, - onClose: () => { - this.closeBtwPanel(panel); - }, - onCancel: () => { - this.closeBtwPanel(panel); - void this.cancelSideAgent(agentId); - }, }); + this.activeBtw = { agentId, panel }; this.sessionEventHandler.registerBtwPanel(agentId, panel); this.showBtwPanel(panel); panel.submit(initialPrompt); } + closeOrCancelBtwPanel(): boolean { + const active = this.activeBtw; + if (active === undefined) return false; + this.closeBtwPanel(active.panel); + if (active.panel.isRunning()) { + void this.cancelSideAgent(active.agentId); + } + return true; + } + private promptBtwAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { const session = this.session; if (session === undefined) { 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 87a8271c69..4c025be018 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 @@ -12,6 +12,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { BtwPanelComponent } from '#/tui/commands/btw'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; @@ -246,18 +247,12 @@ function renderBtwPanel(driver: MessageDriver): string { return driver.state.btwPanelContainer.render(120).join('\n'); } -interface MountedBtwPanel { - readonly focused: boolean; - handleInput(data: string): void; -} - -function getMountedBtwPanel(driver: MessageDriver): MountedBtwPanel { - const panel = driver.state.btwPanelContainer.children.find((child) => { - if (typeof child !== 'object' || child === null) return false; - return 'focused' in child && typeof (child as { handleInput?: unknown }).handleInput === 'function'; - }); +function getMountedBtwPanel(driver: MessageDriver): BtwPanelComponent { + const panel = driver.state.btwPanelContainer.children.find( + (child) => child instanceof BtwPanelComponent, + ); if (panel === undefined) throw new Error('Expected a mounted /btw panel.'); - return panel as unknown as MountedBtwPanel; + return panel; } async function openBtwPanel( @@ -1390,16 +1385,22 @@ describe('KimiTUI message flow', () => { expect(driver.state.btwPanelContainer.children).toHaveLength(2); expect(driver.state.btwPanelContainer.render(120)[0]?.trim()).toBe(''); - expect(getMountedBtwPanel(driver).focused).toBe(true); - expect(driver.state.editor.focused).toBe(false); + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); - expect(panel).toContain('BTW done'); + expect(panel).toContain('BTW ── Esc to close'); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + 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).not.toContain('Agent'); - expect(transcript).not.toContain('BTW done'); + expect(transcript).not.toContain('BTW'); + expect(transcript).not.toContain('Esc to close'); expect(transcript).not.toContain('正在实现 /btw 的独立面板。'); }); @@ -1459,7 +1460,10 @@ describe('KimiTUI message flow', () => { ); expect(transcript).toContain('main answer after btw'); expect(transcript).not.toContain('side answer'); - expect(panel).toContain('BTW done'); + expect(panel).toContain('BTW'); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + expect(panel).not.toContain('BTW failed'); expect(panel).toContain('side answer'); expect(panel).not.toContain('main answer after btw'); }); @@ -1489,22 +1493,69 @@ describe('KimiTUI message flow', () => { expect(panel).toContain('line7'); }); + it('keeps /btw panel height stable when final output is shorter than thinking', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'thinking line 1\nthinking line 2', + } as Event, + () => {}, + ); + + const mountedPanel = getMountedBtwPanel(driver); + const thinkingLines = mountedPanel.render(80).map(stripSgr); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'final answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const finalLines = mountedPanel.render(80).map(stripSgr); + expect(finalLines).toHaveLength(thinkingLines.length); + expect(finalLines.join('\n')).toContain('final answer'); + expect(finalLines.at(-1)).toMatch(/^│\s+│$/); + }); + it('cancels and closes a running /btw panel on Escape', async () => { const session = makeSession(); const { driver } = await makeDriver(session); await openBtwPanel(driver, session); const panel = getMountedBtwPanel(driver); - expect(panel.focused).toBe(true); + expect(panel.isRunning()).toBe(true); + expect(driver.state.editor.focused).toBe(true); - panel.handleInput('\x1b'); + driver.state.editor.onEscape?.(); expect(session.cancel).toHaveBeenCalledOnce(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(driver.state.editor.focused).toBe(true); }); - it('closes a completed /btw panel on Enter without cancelling it', async () => { + it('closes a completed /btw panel on Escape without cancelling it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); await openBtwPanel(driver, session); @@ -1521,9 +1572,10 @@ describe('KimiTUI message flow', () => { ); const panel = getMountedBtwPanel(driver); - expect(panel.focused).toBe(true); + expect(panel.isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); - panel.handleInput('\r'); + driver.state.editor.onEscape?.(); expect(session.cancel).not.toHaveBeenCalled(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); @@ -1547,20 +1599,18 @@ describe('KimiTUI message flow', () => { ); const panel = getMountedBtwPanel(driver); - for (const ch of 'follow up') { - panel.handleInput(ch); - } - panel.handleInput('\r'); + expect(panel.isRunning()).toBe(false); + driver.handleUserInput('follow up'); await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledWith('follow up'); }); expect(session.prompt).toHaveBeenCalledTimes(2); expect(driver.state.btwPanelContainer.children).toHaveLength(2); - expect(driver.state.editor.focused).toBe(false); + expect(driver.state.editor.focused).toBe(true); }); - it('restores the main interactive agent immediately after starting a /btw prompt', async () => { + it('keeps main input pointed at /btw while the panel is open', async () => { let resolveBtwPrompt: (() => void) | undefined; const session = makeSession({ prompt: vi.fn( @@ -1575,15 +1625,14 @@ describe('KimiTUI message flow', () => { await openBtwPanel(driver, session, 'slow side question'); expect(harness.interactiveAgentId).toBe('main'); - driver.state.appState.streamingPhase = 'waiting'; - driver.handleUserInput('main follow-up while btw prompt is pending'); + driver.handleUserInput('follow-up while btw prompt is pending'); - expect(driver.state.queuedMessages).toEqual([ - expect.objectContaining({ - text: 'main follow-up while btw prompt is pending', - agentId: 'main', - }), - ]); + expect(session.prompt).toHaveBeenCalledTimes(1); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.editor.getText()).toBe('follow-up while btw prompt is pending'); + expect(stripSgr(renderTranscript(driver))).toContain( + 'Wait for /btw to finish before sending another question.', + ); resolveBtwPrompt?.(); }); From fb8fc3dd8b50595c97c48fc115e19072b5f2a086 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:05:11 +0800 Subject: [PATCH 07/18] feat: improve btw panel scrolling --- apps/kimi-code/src/tui/commands/btw.ts | 88 ++++++++++++-- .../tui/components/editor/custom-editor.ts | 18 ++- .../src/tui/controllers/editor-keyboard.ts | 6 + apps/kimi-code/src/tui/kimi-tui.ts | 32 +++++- .../components/editor/side-borders.test.ts | 8 ++ .../test/tui/kimi-tui-message-flow.test.ts | 107 +++++++++++++++++- 6 files changed, 237 insertions(+), 22 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index 5dbf98877f..16e79543ac 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -15,6 +15,8 @@ import type { SlashCommandHost } from './dispatch'; type BtwPanelPhase = 'running' | 'done' | 'failed'; +const MIN_COLLAPSED_PANEL_LINES = 3; + interface BtwTurn { readonly prompt: string; answer: string; @@ -23,10 +25,16 @@ interface BtwTurn { phase: BtwPanelPhase; } +interface BtwBodyRender { + readonly lines: string[]; + readonly truncated: boolean; +} + export interface BtwPanelOptions { readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; readonly onPrompt: (prompt: string) => void; + readonly terminalRows: () => number; } export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { @@ -53,12 +61,18 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr export class BtwPanelComponent implements Component { private readonly turns: BtwTurn[] = []; private minBodyLines = 0; + private expanded = false; + private followTail = true; + private scrollTop = 0; + private maxScrollTop = 0; constructor(private readonly options: BtwPanelOptions) {} submit(prompt: string): void { const normalized = prompt.trim(); if (normalized.length === 0 || this.isRunning()) return; + this.followTail = true; + this.scrollTop = 0; this.turns.push({ prompt: normalized, answer: '', @@ -110,27 +124,31 @@ export class BtwPanelComponent implements Component { render(width: number): string[] { const safeWidth = Math.max(4, width); const contentWidth = Math.max(1, safeWidth - 4); - const lines = [this.renderTopBorder(safeWidth)]; - for (const line of this.renderBody(contentWidth)) { + const body = this.renderBody(contentWidth); + const lines = [this.renderTopBorder(safeWidth, body.truncated)]; + for (const line of body.lines) { lines.push(this.renderBodyLine(line, safeWidth)); } return lines; } - private renderTopBorder(width: number): string { + private renderTopBorder(width: number, truncated: boolean): string { const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const hint = truncated + ? 'Esc close · ↑↓ scroll · ctrl+o expand ' + : 'Esc close '; const title = chalk.hex(this.options.colors.accent).bold(' BTW ') + paint('─ ') + - chalk.hex(this.options.colors.textMuted)('Esc to close '); - const innerWidth = Math.max(1, width - 1); + chalk.hex(this.options.colors.textMuted)(hint); + const innerWidth = Math.max(1, width - 2); const clippedTitle = visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); } - private renderBody(width: number): string[] { + private renderBody(width: number): BtwBodyRender { const lines: string[] = []; for (const [index, turn] of this.turns.entries()) { if (index > 0) lines.push(''); @@ -139,13 +157,42 @@ export class BtwPanelComponent implements Component { if (this.turns.length === 0) { lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); } - if (lines.length > this.minBodyLines) { - this.minBodyLines = lines.length; + return this.fitBodyLines(lines); + } + + private fitBodyLines(lines: string[]): BtwBodyRender { + const bodyLimit = this.expanded ? undefined : this.collapsedBodyLimit(); + const targetUncapped = Math.max(this.minBodyLines, lines.length); + const target = + bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); + this.minBodyLines = Math.max(this.minBodyLines, target); + + if (lines.length > target) { + this.maxScrollTop = lines.length - target; + if (this.followTail) { + this.scrollTop = this.maxScrollTop; + } else { + this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); + } + const start = this.scrollTop; + return { lines: lines.slice(start, start + target), truncated: true }; } - while (lines.length < this.minBodyLines) { - lines.push(''); + + this.followTail = true; + this.scrollTop = 0; + this.maxScrollTop = 0; + const padded = [...lines]; + while (padded.length < target) { + padded.push(''); } - return lines; + return { lines: padded, truncated: false }; + } + + private collapsedBodyLimit(): number | undefined { + const terminalRows = this.options.terminalRows(); + if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); + return Math.max(1, maxPanelLines - 1); } private renderTurn(turn: BtwTurn, width: number): string[] { @@ -196,4 +243,23 @@ export class BtwPanelComponent implements Component { isRunning(): boolean { return this.currentTurn()?.phase === 'running'; } + + toggleExpanded(): boolean { + this.expanded = !this.expanded; + this.followTail = true; + this.scrollTop = 0; + return this.expanded; + } + + scroll(direction: 'up' | 'down'): boolean { + if (this.maxScrollTop <= 0) return false; + const current = this.followTail ? this.maxScrollTop : this.scrollTop; + const next = + direction === 'up' + ? Math.max(0, current - 1) + : Math.min(this.maxScrollTop, current + 1); + this.scrollTop = next; + this.followTail = next === this.maxScrollTop; + return true; + } } diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 3ceff7a9d3..c77f4af24e 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -111,7 +111,10 @@ export class CustomEditor extends Editor { * through so pi-tui's built-in history navigation runs. */ public onUpArrowEmpty?: () => boolean; + public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + public connectedAbove = false; + public borderHighlighted = false; /** * Called when the user triggers "paste image" (Ctrl-V on Unix, * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return @@ -213,7 +216,9 @@ export class CustomEditor extends Editor { // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and // side bars through the same hook to stay in sync. - return wrapWithSideBorders(lines, (s) => this.borderColor(s)); + return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + connectedAbove: this.connectedAbove && !this.borderHighlighted, + }); } override handleInput(data: string): void { @@ -320,6 +325,12 @@ export class CustomEditor extends Editor { } } + if (matchesKey(normalized, Key.down)) { + if (this.getText().length === 0 && this.onDownArrowEmpty) { + if (this.onDownArrowEmpty()) return; + } + } + if (matchesKey(normalized, Key.escape)) { if (this.hasAutocompleteActivity()) { this.cancelAutocompleteActivity(); @@ -398,13 +409,14 @@ export function injectPromptSymbol(line: string): string | undefined { export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, + options: { readonly connectedAbove?: boolean } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { - const leftCorner = seenTop ? '╰' : '╭'; - const rightCorner = seenTop ? '╯' : '╮'; + const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; + const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 4fdeef204b..5995d67af9 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -29,6 +29,8 @@ export interface EditorKeyboardHost { track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; updateQueueDisplay(): void; + scrollBtwPanel(direction: 'up' | 'down'): boolean; + toggleBtwPanelExpansion(): boolean; toggleToolOutputExpansion(): void; togglePlanExpansion(): boolean; hideSessionPicker(): void; @@ -142,6 +144,7 @@ export class EditorKeyboardController { editor.onToggleToolExpand = () => { host.track('shortcut_expand'); + if (host.toggleBtwPanelExpansion()) return; host.toggleToolOutputExpansion(); }; @@ -186,6 +189,7 @@ export class EditorKeyboardController { }; editor.onUpArrowEmpty = () => { + if (host.scrollBtwPanel('up')) return true; if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { @@ -197,6 +201,8 @@ export class EditorKeyboardController { return false; }; + editor.onDownArrowEmpty = () => host.scrollBtwPanel('down'); + editor.onPasteImage = async () => this.handleClipboardImagePaste(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2036d8f147..9c7b2f68c5 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1544,10 +1544,9 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const colorToken = - this.state.appState.planMode || trimmed.startsWith('/') - ? this.state.theme.colors.primary - : this.state.theme.colors.border; + const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); + const colorToken = highlighted ? this.state.theme.colors.primary : this.state.theme.colors.border; + this.state.editor.borderHighlighted = highlighted; this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); this.state.ui.requestRender(); } @@ -1650,6 +1649,7 @@ export class KimiTUI { this.state.btwPanelContainer.clear(); this.state.btwPanelContainer.addChild(new Spacer(1)); this.state.btwPanelContainer.addChild(panel); + this.state.editor.connectedAbove = true; this.state.ui.setFocus(this.state.editor); this.state.ui.requestRender(); } @@ -1659,8 +1659,9 @@ export class KimiTUI { this.sessionEventHandler.unregisterBtwPanel(panel); if (this.activeBtw?.panel === panel) this.activeBtw = undefined; this.state.btwPanelContainer.clear(); + this.state.editor.connectedAbove = false; this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); + this.state.ui.requestRender(true); } private clearBtwPanel(): void { @@ -1668,6 +1669,7 @@ export class KimiTUI { if (panel !== undefined) this.sessionEventHandler.unregisterBtwPanel(panel); this.activeBtw = undefined; this.state.btwPanelContainer.clear(); + this.state.editor.connectedAbove = false; } openBtwPanel(agentId: string, initialPrompt: string): void { @@ -1675,6 +1677,7 @@ export class KimiTUI { panel = new BtwPanelComponent({ colors: this.state.theme.colors, markdownTheme: this.state.theme.markdownTheme, + terminalRows: () => this.state.terminal.rows, onPrompt: (prompt) => { this.promptBtwAgent(agentId, prompt, panel); }, @@ -1695,6 +1698,25 @@ export class KimiTUI { return true; } + toggleBtwPanelExpansion(): boolean { + const panel = this.activeBtw?.panel; + if (panel === undefined) return false; + const expanded = panel.toggleExpanded(); + if (expanded) { + this.state.ui.requestRender(); + } else { + this.state.ui.requestRender(true); + } + return true; + } + + scrollBtwPanel(direction: 'up' | 'down'): boolean { + const panel = this.activeBtw?.panel; + if (panel === undefined || !panel.scroll(direction)) return false; + this.state.ui.requestRender(); + return true; + } + private promptBtwAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { const session = this.session; if (session === undefined) { diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index 11801aa71c..d137558b99 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -10,6 +10,14 @@ describe('wrapWithSideBorders', () => { expect(out[0]).toBe('╭────────╮'); }); + it('turns the top horizontal border into connectors when connected above', () => { + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id, { + connectedAbove: true, + }); + expect(out[0]).toBe('├────────┤'); + expect(out[2]).toBe('╰────────╯'); + }); + it('turns the bottom horizontal border into a ╰…╯ run', () => { const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); expect(out[2]).toBe('╰────────╯'); 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 4c025be018..d694c598b6 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 @@ -267,6 +267,13 @@ async function openBtwPanel( }); } +function setTerminalRows(driver: MessageDriver, rows: number): void { + Object.defineProperty(driver.state.terminal, 'rows', { + configurable: true, + get: () => rows, + }); +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -1390,7 +1397,16 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); - expect(panel).toContain('BTW ── Esc to close'); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(panel).toContain('BTW ─ Esc close'); + expect(panel).not.toContain('ctrl+o expand'); + expect(editorTopBorder.startsWith('├')).toBe(true); + expect(editorTopBorder.endsWith('┤')).toBe(true); + + driver.state.editor.handleInput('/'); + const highlightedEditorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(highlightedEditorTopBorder.startsWith('╭')).toBe(true); + expect(highlightedEditorTopBorder.endsWith('╮')).toBe(true); expect(panel).not.toContain('BTW done'); expect(panel).not.toContain('BTW running'); expect(panel).not.toContain('BTW failed'); @@ -1400,7 +1416,7 @@ describe('KimiTUI message flow', () => { expect(panel).toContain('正在实现 /btw 的独立面板。'); expect(panel).not.toContain('Agent'); expect(transcript).not.toContain('BTW'); - expect(transcript).not.toContain('Esc to close'); + expect(transcript).not.toContain('Esc close'); expect(transcript).not.toContain('正在实现 /btw 的独立面板。'); }); @@ -1493,6 +1509,17 @@ describe('KimiTUI message flow', () => { expect(panel).toContain('line7'); }); + it('renders /btw body at its actual content height when under the cap', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + const lines = getMountedBtwPanel(driver).render(80).map(stripSgr); + expect(lines).toHaveLength(3); + expect(lines.join('\n')).toContain('Q: side question'); + expect(lines.join('\n')).toContain('Waiting for answer...'); + }); + it('keeps /btw panel height stable when final output is shorter than thinking', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -1539,6 +1566,74 @@ describe('KimiTUI message flow', () => { expect(finalLines.at(-1)).toMatch(/^│\s+│$/); }); + it('caps collapsed /btw height to half the terminal and Ctrl-O toggles expansion', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + setTerminalRows(driver, 12); + await openBtwPanel(driver, session, 'question 1'); + + const panel = getMountedBtwPanel(driver); + panel.appendAnswer('answer 1'); + panel.markDone(); + for (let i = 2; i <= 8; i++) { + panel.submit(`question ${String(i)}`); + panel.appendAnswer(`answer ${String(i)}`); + panel.markDone(); + } + + const collapsed = panel.render(80).map(stripSgr); + expect(collapsed).toHaveLength(6); + expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll · ctrl+o expand'); + expect(collapsed.join('\n')).toContain('question 8'); + expect(collapsed.join('\n')).toContain('answer 8'); + expect(collapsed.join('\n')).not.toContain('question 1'); + + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[A'); + } + const scrolledUp = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledUp.join('\n')).toContain('question 1'); + expect(scrolledUp.join('\n')).not.toContain('answer 8'); + + panel.appendAnswer('\nstreamed tail while scrolled'); + expect(panel.render(80).map(stripSgr)).toEqual(scrolledUp); + + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[B'); + } + const scrolledDown = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledDown.join('\n')).toContain('question 8'); + expect(scrolledDown.join('\n')).toContain('answer 8'); + expect(scrolledDown.join('\n')).toContain('streamed tail while scrolled'); + + setTerminalRows(driver, 4); + const tiny = panel.render(80).map(stripSgr); + expect(tiny).toHaveLength(3); + expect(tiny.join('\n')).toContain('ctrl+o expand'); + expect(tiny.join('\n')).toContain('answer 8'); + + requestRender.mockClear(); + driver.state.editor.onToggleToolExpand?.(); + + const expanded = panel.render(80).map(stripSgr); + expect(requestRender.mock.calls.at(-1)).toEqual([]); + expect(expanded.length).toBeGreaterThan(6); + expect(expanded.join('\n')).not.toContain('ctrl+o expand'); + expect(expanded.join('\n')).toContain('question 1'); + expect(expanded.join('\n')).toContain('answer 8'); + + setTerminalRows(driver, 12); + requestRender.mockClear(); + driver.state.editor.onToggleToolExpand?.(); + expect(requestRender.mock.calls.at(-1)).toEqual([true]); + expect(panel.render(80).map(stripSgr)).toHaveLength(6); + }); + it('cancels and closes a running /btw panel on Escape', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -1548,10 +1643,16 @@ describe('KimiTUI message flow', () => { expect(panel.isRunning()).toBe(true); expect(driver.state.editor.focused).toBe(true); + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); driver.state.editor.onEscape?.(); expect(session.cancel).toHaveBeenCalledOnce(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(requestRender.mock.calls.at(-1)).toEqual([true]); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(editorTopBorder.startsWith('╭')).toBe(true); + expect(editorTopBorder.endsWith('╮')).toBe(true); expect(driver.state.editor.focused).toBe(true); }); @@ -2612,7 +2713,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('t7'); - expect(transcript).not.toContain('ctrl+o to expand'); + expect(transcript).not.toContain('ctrl+o expand'); }); it('renders hook results without XML tags', async () => { From 571a57dabfc7d854984bc11a70a1c165a3db5997 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:08:57 +0800 Subject: [PATCH 08/18] fix: clarify btw side-agent reminder --- packages/agent-core/src/session/subagent-host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 98b1c3fd77..6580987b52 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -28,7 +28,7 @@ const SUBAGENT_MAX_TOKENS_ERROR = const TOOL_CALL_DISABLED_MESSAGE = 'Tool calls are disabled for side questions. Answer with text only.'; const SIDE_QUESTION_SYSTEM_REMINDER = ` -This is a side-channel conversation with the user. +This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. IMPORTANT: - You are a separate, lightweight instance. From b920cbbd8f42e1a74adc64bebf1c95485fdff67c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:23:03 +0800 Subject: [PATCH 09/18] fix --- apps/kimi-code/src/tui/commands/btw.ts | 243 +----------------- apps/kimi-code/src/tui/commands/dispatch.ts | 10 +- .../src/tui/components/panes/btw-panel.ts | 241 +++++++++++++++++ .../src/tui/controllers/btw-panel.ts | 224 ++++++++++++++++ .../src/tui/controllers/editor-keyboard.ts | 15 +- .../tui/controllers/session-event-handler.ts | 92 +------ apps/kimi-code/src/tui/kimi-tui.ts | 133 +--------- .../test/tui/kimi-tui-message-flow.test.ts | 2 +- 8 files changed, 487 insertions(+), 473 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/panes/btw-panel.ts create mode 100644 apps/kimi-code/src/tui/controllers/btw-panel.ts diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index 16e79543ac..a3a5ab0ed8 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -1,42 +1,7 @@ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; -import { - Markdown, - Text, - truncateToWidth, - visibleWidth, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; -import { THINKING_PREVIEW_LINES } from '../constant/rendering'; -import type { ColorPalette } from '../theme/colors'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; -type BtwPanelPhase = 'running' | 'done' | 'failed'; - -const MIN_COLLAPSED_PANEL_LINES = 3; - -interface BtwTurn { - readonly prompt: string; - answer: string; - thinking: string; - error?: string | undefined; - phase: BtwPanelPhase; -} - -interface BtwBodyRender { - readonly lines: string[]; - readonly truncated: boolean; -} - -export interface BtwPanelOptions { - readonly colors: ColorPalette; - readonly markdownTheme: MarkdownTheme; - readonly onPrompt: (prompt: string) => void; - readonly terminalRows: () => number; -} - export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { const prompt = args.trim(); if (prompt.length === 0) { @@ -52,214 +17,8 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr try { const agentId = await session.startBtw(); - host.openBtwPanel(agentId, prompt); + host.btwPanelController.open(agentId, prompt); } catch (error) { host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); } } - -export class BtwPanelComponent implements Component { - private readonly turns: BtwTurn[] = []; - private minBodyLines = 0; - private expanded = false; - private followTail = true; - private scrollTop = 0; - private maxScrollTop = 0; - - constructor(private readonly options: BtwPanelOptions) {} - - submit(prompt: string): void { - const normalized = prompt.trim(); - if (normalized.length === 0 || this.isRunning()) return; - this.followTail = true; - this.scrollTop = 0; - this.turns.push({ - prompt: normalized, - answer: '', - thinking: '', - phase: 'running', - }); - this.options.onPrompt(normalized); - } - - appendAnswer(delta: string): void { - const turn = this.currentTurn(); - if (turn === undefined) return; - turn.answer += delta; - } - - appendThinking(delta: string): void { - const turn = this.currentTurn(); - if (turn === undefined) return; - turn.thinking += delta; - } - - markDone(resultSummary?: string | undefined): void { - const turn = this.currentTurn(); - if (turn === undefined) return; - if (turn.answer.trim().length === 0 && resultSummary !== undefined) { - turn.answer = resultSummary; - } - turn.phase = 'done'; - } - - markFailed(error: string): void { - const turn = this.currentTurn(); - if (turn === undefined || turn.phase !== 'running') { - this.turns.push({ - prompt: '', - answer: '', - thinking: '', - error, - phase: 'failed', - }); - return; - } - turn.error = error; - turn.phase = 'failed'; - } - - invalidate(): void {} - - render(width: number): string[] { - const safeWidth = Math.max(4, width); - const contentWidth = Math.max(1, safeWidth - 4); - const body = this.renderBody(contentWidth); - const lines = [this.renderTopBorder(safeWidth, body.truncated)]; - for (const line of body.lines) { - lines.push(this.renderBodyLine(line, safeWidth)); - } - return lines; - } - - private renderTopBorder(width: number, truncated: boolean): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); - const hint = truncated - ? 'Esc close · ↑↓ scroll · ctrl+o expand ' - : 'Esc close '; - const title = - chalk.hex(this.options.colors.accent).bold(' BTW ') + - paint('─ ') + - chalk.hex(this.options.colors.textMuted)(hint); - const innerWidth = Math.max(1, width - 2); - const clippedTitle = - visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; - const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); - return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); - } - - private renderBody(width: number): BtwBodyRender { - const lines: string[] = []; - for (const [index, turn] of this.turns.entries()) { - if (index > 0) lines.push(''); - lines.push(...this.renderTurn(turn, width)); - } - if (this.turns.length === 0) { - lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); - } - return this.fitBodyLines(lines); - } - - private fitBodyLines(lines: string[]): BtwBodyRender { - const bodyLimit = this.expanded ? undefined : this.collapsedBodyLimit(); - const targetUncapped = Math.max(this.minBodyLines, lines.length); - const target = - bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); - this.minBodyLines = Math.max(this.minBodyLines, target); - - if (lines.length > target) { - this.maxScrollTop = lines.length - target; - if (this.followTail) { - this.scrollTop = this.maxScrollTop; - } else { - this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); - } - const start = this.scrollTop; - return { lines: lines.slice(start, start + target), truncated: true }; - } - - this.followTail = true; - this.scrollTop = 0; - this.maxScrollTop = 0; - const padded = [...lines]; - while (padded.length < target) { - padded.push(''); - } - return { lines: padded, truncated: false }; - } - - private collapsedBodyLimit(): number | undefined { - const terminalRows = this.options.terminalRows(); - if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; - const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); - return Math.max(1, maxPanelLines - 1); - } - - private renderTurn(turn: BtwTurn, width: number): string[] { - const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); - if (turn.error !== undefined) { - return [ - ...new Text(prompt, 0, 0).render(width), - ...new Text(chalk.hex(this.options.colors.error)(turn.error), 0, 0).render(width), - ]; - } - const answer = turn.answer.trim(); - if (answer.length > 0) { - return [ - ...new Text(prompt, 0, 0).render(width), - ...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width), - ]; - } - const thinking = turn.thinking.trim(); - if (thinking.length > 0) { - const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( - width, - ); - const visibleThinking = - thinkingLines.length > THINKING_PREVIEW_LINES - ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) - : thinkingLines; - return [...new Text(prompt, 0, 0).render(width), ...visibleThinking]; - } - return [ - ...new Text(prompt, 0, 0).render(width), - chalk.hex(this.options.colors.textDim)('Waiting for answer...'), - ]; - } - - private renderBodyLine(line: string, width: number): string { - const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); - const contentWidth = Math.max(1, width - 4); - const clipped = - visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; - const padding = Math.max(0, contentWidth - visibleWidth(clipped)); - return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); - } - - private currentTurn(): BtwTurn | undefined { - return this.turns.at(-1); - } - - isRunning(): boolean { - return this.currentTurn()?.phase === 'running'; - } - - toggleExpanded(): boolean { - this.expanded = !this.expanded; - this.followTail = true; - this.scrollTop = 0; - return this.expanded; - } - - scroll(direction: 'up' | 'down'): boolean { - if (this.maxScrollTop <= 0) return false; - const current = this.followTail ? this.maxScrollTop : this.scrollTop; - const next = - direction === 'up' - ? Math.max(0, current - 1) - : Math.min(this.maxScrollTop, current + 1); - this.scrollTop = next; - this.followTail = next === this.maxScrollTop; - return true; - } -} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index ddf7a8ec69..2770345dda 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -15,6 +15,7 @@ import { } from './resolve'; import type { BuiltinSlashCommandName } from './registry'; import type { AuthFlowController } from '../controllers/auth-flow'; +import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; @@ -101,7 +102,6 @@ export interface SlashCommandHost { showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; track(event: string, props?: Record): void; - openBtwPanel(agentId: string, initialPrompt: string): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; @@ -132,6 +132,7 @@ export interface SlashCommandHost { // Controller refs readonly streamingUI: StreamingUIController; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; readonly authFlow: AuthFlowController; } @@ -164,13 +165,6 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); return; - case 'invalid': - host.track('input_command_invalid', { reason: intent.reason, command: intent.commandName }); - if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { - return; - } - host.sendNormalUserInput(input); - return; case 'skill': { const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts new file mode 100644 index 0000000000..7f8b8a1f15 --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -0,0 +1,241 @@ +import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import { + Markdown, + Text, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; +import type { ColorPalette } from '../../theme/colors'; + +type BtwPanelPhase = 'running' | 'done' | 'failed'; + +const MIN_COLLAPSED_PANEL_LINES = 3; + +interface BtwTurn { + readonly prompt: string; + answer: string; + thinking: string; + error?: string | undefined; + phase: BtwPanelPhase; +} + +interface BtwBodyRender { + readonly lines: string[]; + readonly truncated: boolean; +} + +export interface BtwPanelOptions { + readonly colors: ColorPalette; + readonly markdownTheme: MarkdownTheme; + readonly onPrompt: (prompt: string) => void; + readonly terminalRows: () => number; +} + +export class BtwPanelComponent implements Component { + private readonly turns: BtwTurn[] = []; + private minBodyLines = 0; + private expanded = false; + private followTail = true; + private scrollTop = 0; + private maxScrollTop = 0; + + constructor(private readonly options: BtwPanelOptions) {} + + submit(prompt: string): void { + const normalized = prompt.trim(); + if (normalized.length === 0 || this.isRunning()) return; + this.followTail = true; + this.scrollTop = 0; + this.turns.push({ + prompt: normalized, + answer: '', + thinking: '', + phase: 'running', + }); + this.options.onPrompt(normalized); + } + + appendAnswer(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.answer += delta; + } + + appendThinking(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.thinking += delta; + } + + markDone(resultSummary?: string | undefined): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + if (turn.answer.trim().length === 0 && resultSummary !== undefined) { + turn.answer = resultSummary; + } + turn.phase = 'done'; + } + + markFailed(error: string): void { + const turn = this.currentTurn(); + if (turn === undefined || turn.phase !== 'running') { + this.turns.push({ + prompt: '', + answer: '', + thinking: '', + error, + phase: 'failed', + }); + return; + } + turn.error = error; + turn.phase = 'failed'; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const contentWidth = Math.max(1, safeWidth - 4); + const body = this.renderBody(contentWidth); + const lines = [this.renderTopBorder(safeWidth, body.truncated)]; + for (const line of body.lines) { + lines.push(this.renderBodyLine(line, safeWidth)); + } + return lines; + } + + private renderTopBorder(width: number, truncated: boolean): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const hint = truncated + ? 'Esc close · ↑↓ scroll · ctrl+o expand ' + : 'Esc close '; + const title = + chalk.hex(this.options.colors.accent).bold(' BTW ') + + paint('─ ') + + chalk.hex(this.options.colors.textMuted)(hint); + const innerWidth = Math.max(1, width - 2); + const clippedTitle = + visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; + const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); + return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); + } + + private renderBody(width: number): BtwBodyRender { + const lines: string[] = []; + for (const [index, turn] of this.turns.entries()) { + if (index > 0) lines.push(''); + lines.push(...this.renderTurn(turn, width)); + } + if (this.turns.length === 0) { + lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + } + return this.fitBodyLines(lines); + } + + private fitBodyLines(lines: string[]): BtwBodyRender { + const bodyLimit = this.expanded ? undefined : this.collapsedBodyLimit(); + const targetUncapped = Math.max(this.minBodyLines, lines.length); + const target = + bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); + this.minBodyLines = Math.max(this.minBodyLines, target); + + if (lines.length > target) { + this.maxScrollTop = lines.length - target; + if (this.followTail) { + this.scrollTop = this.maxScrollTop; + } else { + this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); + } + const start = this.scrollTop; + return { lines: lines.slice(start, start + target), truncated: true }; + } + + this.followTail = true; + this.scrollTop = 0; + this.maxScrollTop = 0; + const padded = [...lines]; + while (padded.length < target) { + padded.push(''); + } + return { lines: padded, truncated: false }; + } + + private collapsedBodyLimit(): number | undefined { + const terminalRows = this.options.terminalRows(); + if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); + return Math.max(1, maxPanelLines - 1); + } + + private renderTurn(turn: BtwTurn, width: number): string[] { + const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + if (turn.error !== undefined) { + return [ + ...new Text(prompt, 0, 0).render(width), + ...new Text(chalk.hex(this.options.colors.error)(turn.error), 0, 0).render(width), + ]; + } + const answer = turn.answer.trim(); + if (answer.length > 0) { + return [ + ...new Text(prompt, 0, 0).render(width), + ...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width), + ]; + } + const thinking = turn.thinking.trim(); + if (thinking.length > 0) { + const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + return [...new Text(prompt, 0, 0).render(width), ...visibleThinking]; + } + return [ + ...new Text(prompt, 0, 0).render(width), + chalk.hex(this.options.colors.textDim)('Waiting for answer...'), + ]; + } + + private renderBodyLine(line: string, width: number): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const contentWidth = Math.max(1, width - 4); + const clipped = + visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; + const padding = Math.max(0, contentWidth - visibleWidth(clipped)); + return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); + } + + private currentTurn(): BtwTurn | undefined { + return this.turns.at(-1); + } + + isRunning(): boolean { + return this.currentTurn()?.phase === 'running'; + } + + toggleExpanded(): boolean { + this.expanded = !this.expanded; + this.followTail = true; + this.scrollTop = 0; + return this.expanded; + } + + scroll(direction: 'up' | 'down'): boolean { + if (this.maxScrollTop <= 0) return false; + const current = this.followTail ? this.maxScrollTop : this.scrollTop; + const next = + direction === 'up' + ? Math.max(0, current - 1) + : Math.min(this.maxScrollTop, current + 1); + this.scrollTop = next; + this.followTail = next === this.maxScrollTop; + return true; + } +} diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts new file mode 100644 index 0000000000..0815dd7358 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -0,0 +1,224 @@ +import { Spacer } from '@earendil-works/pi-tui'; +import type { + Event, + KimiHarness, + Session, + TurnEndedEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { BtwPanelComponent } from '../components/panes/btw-panel'; +import { formatErrorMessage } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import type { TUIState } from '../tui-state'; + +export interface BtwPanelHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + + showError(msg: string): void; + showStatus(msg: string, color?: string): void; +} + +export class BtwPanelController { + private active: + | { + readonly agentId: string; + readonly panel: BtwPanelComponent; + } + | undefined; + private readonly panelsByAgentId = new Map(); + + constructor(private readonly host: BtwPanelHost) {} + + open(agentId: string, initialPrompt: string): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + colors: this.host.state.theme.colors, + markdownTheme: this.host.state.theme.markdownTheme, + terminalRows: () => this.host.state.terminal.rows, + onPrompt: (prompt) => { + this.promptAgent(agentId, prompt, panel); + }, + }); + this.active = { agentId, panel }; + this.panelsByAgentId.set(agentId, panel); + this.mount(panel); + panel.submit(initialPrompt); + } + + clear(): void { + this.active = undefined; + this.panelsByAgentId.clear(); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + } + + closeOrCancel(): boolean { + const active = this.active; + if (active === undefined) return false; + const wasRunning = active.panel.isRunning(); + this.close(active.panel); + if (wasRunning) { + void this.cancelAgent(active.agentId); + } + return true; + } + + sendUserInput(text: string): boolean { + const active = this.active; + if (active === undefined) return false; + if (active.panel.isRunning()) { + this.host.state.editor.setText(text); + this.host.showStatus('Wait for /btw to finish before sending another question.'); + return true; + } + active.panel.submit(text); + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + return true; + } + + toggleExpansion(): boolean { + const panel = this.active?.panel; + if (panel === undefined) return false; + const expanded = panel.toggleExpanded(); + if (expanded) { + this.host.state.ui.requestRender(); + } else { + this.host.state.ui.requestRender(true); + } + return true; + } + + scroll(direction: 'up' | 'down'): boolean { + const panel = this.active?.panel; + if (panel === undefined || !panel.scroll(direction)) return false; + this.host.state.ui.requestRender(); + return true; + } + + routeEvent(event: Event): boolean { + const panel = this.panelsByAgentId.get(event.agentId); + if (panel === undefined) return false; + + switch (event.type) { + case 'assistant.delta': + panel.appendAnswer(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'thinking.delta': + panel.appendThinking(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'hook.result': + panel.appendAnswer(formatHookResultPlain(event)); + this.host.state.ui.requestRender(); + return true; + case 'turn.ended': + if (event.reason === 'completed') { + panel.markDone(); + } else { + panel.markFailed(formatBtwTurnEnd(event)); + } + this.host.state.ui.requestRender(); + return true; + case 'agent.status.updated': + case 'background.task.started': + case 'background.task.terminated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'cron.fired': + case 'error': + case 'mcp.server.status': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.call.delta': + case 'tool.call.started': + case 'tool.list.updated': + case 'tool.progress': + case 'tool.result': + case 'turn.started': + case 'turn.step.completed': + case 'turn.step.interrupted': + case 'turn.step.retrying': + case 'turn.step.started': + case 'warning': + return true; + default: + return true; + } + } + + private mount(panel: BtwPanelComponent): void { + this.host.state.btwPanelContainer.clear(); + this.host.state.btwPanelContainer.addChild(new Spacer(1)); + this.host.state.btwPanelContainer.addChild(panel); + this.host.state.editor.connectedAbove = true; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + } + + private close(panel: BtwPanelComponent): void { + if (!this.host.state.btwPanelContainer.children.includes(panel)) return; + this.unregister(panel); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(true); + } + + private unregister(panel: BtwPanelComponent): void { + for (const [agentId, candidate] of this.panelsByAgentId) { + if (candidate === panel) { + this.panelsByAgentId.delete(agentId); + } + } + if (this.active?.panel === panel) this.active = undefined; + } + + private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { + const session = this.host.session; + if (session === undefined) { + panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); + this.host.state.ui.requestRender(); + return; + } + void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.host.state.ui.requestRender(); + }); + } + + private async cancelAgent(agentId: string): Promise { + const session = this.host.session; + if (session === undefined) return; + await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + } + + private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { + const previousAgentId = this.host.harness.interactiveAgentId; + this.host.harness.interactiveAgentId = agentId; + try { + // SDK RPC methods snapshot interactiveAgentId before their first await. + return fn(); + } finally { + this.host.harness.interactiveAgentId = previousAgentId; + } + } +} + +function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.error !== undefined) { + return `[${event.error.code}] ${event.error.message}`; + } + return `BTW turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 5995d67af9..25e27743dc 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -15,6 +15,7 @@ import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; import type { PendingExit } from '../types'; import type { TUIState } from '../tui-state'; +import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; @@ -22,15 +23,13 @@ export interface EditorKeyboardHost { cancelInFlight: (() => void) | undefined; handleUserInput(text: string): void; - closeOrCancelBtwPanel(): boolean; + readonly btwPanelController: BtwPanelController; steerMessage(session: Session, input: string[]): void; recallLastQueued(): string | undefined; showError(msg: string): void; track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; updateQueueDisplay(): void; - scrollBtwPanel(direction: 'up' | 'down'): boolean; - toggleBtwPanelExpansion(): boolean; toggleToolOutputExpansion(): void; togglePlanExpansion(): boolean; hideSessionPicker(): void; @@ -76,7 +75,7 @@ export class EditorKeyboardController { return; } - if (editor.getText().length === 0 && host.closeOrCancelBtwPanel()) { + if (editor.getText().length === 0 && host.btwPanelController.closeOrCancel()) { this.clearPendingExit(); return; } @@ -118,7 +117,7 @@ export class EditorKeyboardController { this.cancelCurrentCompaction(); return; } - if (host.closeOrCancelBtwPanel()) { + if (host.btwPanelController.closeOrCancel()) { return; } if (host.state.appState.streamingPhase !== 'idle') { @@ -144,7 +143,7 @@ export class EditorKeyboardController { editor.onToggleToolExpand = () => { host.track('shortcut_expand'); - if (host.toggleBtwPanelExpansion()) return; + if (host.btwPanelController.toggleExpansion()) return; host.toggleToolOutputExpansion(); }; @@ -189,7 +188,7 @@ export class EditorKeyboardController { }; editor.onUpArrowEmpty = () => { - if (host.scrollBtwPanel('up')) return true; + if (host.btwPanelController.scroll('up')) return true; if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { @@ -201,7 +200,7 @@ export class EditorKeyboardController { return false; }; - editor.onDownArrowEmpty = () => host.scrollBtwPanel('down'); + editor.onDownArrowEmpty = () => host.btwPanelController.scroll('down'); editor.onPasteImage = async () => this.handleClipboardImagePaste(); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 7a24843306..6b0e49565b 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -57,9 +57,9 @@ import { import { openUrl } from '../utils/open-url'; import { setProcessTitle } from '../utils/proctitle'; import { errorReportHintLine } from '../constant/feedback'; -import { BtwPanelComponent } from '../commands/btw'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; +import type { BtwPanelController } from './btw-panel'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; import type { @@ -88,10 +88,9 @@ export interface SessionEventHost { showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; - showBtwPanel(panel: BtwPanelComponent): void; - closeBtwPanel(panel: BtwPanelComponent): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; } @@ -102,10 +101,7 @@ export class SessionEventHandler { backgroundAgentMetadata: Map = new Map(); backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); - subagentInfo: Map< - string, - { parentToolCallId: string; name: string; btwPanel?: BtwPanelComponent | undefined } - > = new Map(); + subagentInfo: Map = new Map(); renderedSkillActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); @@ -227,22 +223,6 @@ export class SessionEventHandler { this.mcpServerStatusSpinners.clear(); } - registerBtwPanel(agentId: string, panel: BtwPanelComponent): void { - this.subagentInfo.set(agentId, { - parentToolCallId: '', - name: 'btw', - btwPanel: panel, - }); - } - - unregisterBtwPanel(panel: BtwPanelComponent): void { - for (const [agentId, info] of this.subagentInfo) { - if (info.btwPanel === panel) { - this.subagentInfo.delete(agentId); - } - } - } - // --------------------------------------------------------------------------- // Private handlers // --------------------------------------------------------------------------- @@ -252,11 +232,10 @@ export class SessionEventHandler { if (subagentId === MAIN_AGENT_ID) return false; const { streamingUI } = this.host; + if (this.host.btwPanelController.routeEvent(event)) return true; + const info = this.subagentInfo.get(subagentId); if (info === undefined) return true; - if (info.btwPanel !== undefined) { - return this.routeBtwEvent(info.btwPanel, event); - } if (info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; @@ -333,60 +312,6 @@ export class SessionEventHandler { } } - private routeBtwEvent(panel: BtwPanelComponent, event: Event): boolean { - switch (event.type) { - case 'assistant.delta': - panel.appendAnswer(event.delta); - this.host.state.ui.requestRender(); - return true; - case 'thinking.delta': - panel.appendThinking(event.delta); - this.host.state.ui.requestRender(); - return true; - case 'hook.result': - panel.appendAnswer(formatHookResultPlain(event)); - this.host.state.ui.requestRender(); - return true; - case 'turn.ended': - if (event.reason === 'completed') { - panel.markDone(); - } else { - panel.markFailed(formatBtwTurnEnd(event)); - } - this.host.state.ui.requestRender(); - return true; - case 'agent.status.updated': - case 'background.task.started': - case 'background.task.terminated': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - case 'compaction.started': - case 'cron.fired': - case 'error': - case 'mcp.server.status': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'tool.call.delta': - case 'tool.call.started': - case 'tool.list.updated': - case 'tool.progress': - case 'tool.result': - case 'turn.started': - case 'turn.step.completed': - case 'turn.step.interrupted': - case 'turn.step.retrying': - case 'turn.step.started': - case 'warning': - return true; - default: - return true; - } - } - private handleTurnBegin(_event: TurnStartedEvent): void { void _event; this.host.streamingUI.resetToolUi(); @@ -1067,10 +992,3 @@ export class SessionEventHandler { state.ui.requestRender(); } } - -function formatBtwTurnEnd(event: TurnEndedEvent): string { - if (event.error !== undefined) { - return `[${event.error.code}] ${event.error.message}`; - } - return `BTW turn ended with reason: ${event.reason}`; -} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9c7b2f68c5..38432d8a4f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -40,7 +40,6 @@ import { type KimiSlashCommand, type SkillListSession, } from './commands'; -import { BtwPanelComponent } from './commands/btw'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; import { CHROME_GUTTER } from './constant/rendering'; @@ -59,6 +58,7 @@ import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; import { EditorKeyboardController } from './controllers/editor-keyboard'; import { SessionEventHandler } from './controllers/session-event-handler'; import * as slashCommands from './commands/dispatch'; @@ -212,14 +212,9 @@ export class KimiTUI { private startupNotice: string | undefined; private lastActivityMode: string | undefined; private lastHistoryContent: string | undefined; - private activeBtw: - | { - readonly agentId: string; - readonly panel: BtwPanelComponent; - } - | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; + readonly btwPanelController: BtwPanelController; readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; @@ -291,6 +286,7 @@ export class KimiTUI { ); this.streamingUI = new StreamingUIController(this); this.authFlow = new AuthFlowController(this); + this.btwPanelController = new BtwPanelController(this); this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); @@ -681,7 +677,7 @@ export class KimiTUI { } sendNormalUserInput(text: string): void { - if (this.sendBtwUserInput(text)) return; + if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; @@ -706,20 +702,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - private sendBtwUserInput(text: string): boolean { - const active = this.activeBtw; - if (active === undefined) return false; - if (active.panel.isRunning()) { - this.state.editor.setText(text); - this.showStatus('Wait for /btw to finish before sending another question.'); - return true; - } - active.panel.submit(text); - this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); - return true; - } - private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -1104,7 +1086,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); - this.clearBtwPanel(); + this.btwPanelController.clear(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); @@ -1355,7 +1337,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); - this.clearBtwPanel(); + this.btwPanelController.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); @@ -1645,109 +1627,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - showBtwPanel(panel: BtwPanelComponent): void { - this.state.btwPanelContainer.clear(); - this.state.btwPanelContainer.addChild(new Spacer(1)); - this.state.btwPanelContainer.addChild(panel); - this.state.editor.connectedAbove = true; - this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); - } - - closeBtwPanel(panel: BtwPanelComponent): void { - if (!this.state.btwPanelContainer.children.includes(panel)) return; - this.sessionEventHandler.unregisterBtwPanel(panel); - if (this.activeBtw?.panel === panel) this.activeBtw = undefined; - this.state.btwPanelContainer.clear(); - this.state.editor.connectedAbove = false; - this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(true); - } - - private clearBtwPanel(): void { - const panel = this.activeBtw?.panel; - if (panel !== undefined) this.sessionEventHandler.unregisterBtwPanel(panel); - this.activeBtw = undefined; - this.state.btwPanelContainer.clear(); - this.state.editor.connectedAbove = false; - } - - openBtwPanel(agentId: string, initialPrompt: string): void { - let panel: BtwPanelComponent; - panel = new BtwPanelComponent({ - colors: this.state.theme.colors, - markdownTheme: this.state.theme.markdownTheme, - terminalRows: () => this.state.terminal.rows, - onPrompt: (prompt) => { - this.promptBtwAgent(agentId, prompt, panel); - }, - }); - this.activeBtw = { agentId, panel }; - this.sessionEventHandler.registerBtwPanel(agentId, panel); - this.showBtwPanel(panel); - panel.submit(initialPrompt); - } - - closeOrCancelBtwPanel(): boolean { - const active = this.activeBtw; - if (active === undefined) return false; - this.closeBtwPanel(active.panel); - if (active.panel.isRunning()) { - void this.cancelSideAgent(active.agentId); - } - return true; - } - - toggleBtwPanelExpansion(): boolean { - const panel = this.activeBtw?.panel; - if (panel === undefined) return false; - const expanded = panel.toggleExpanded(); - if (expanded) { - this.state.ui.requestRender(); - } else { - this.state.ui.requestRender(true); - } - return true; - } - - scrollBtwPanel(direction: 'up' | 'down'): boolean { - const panel = this.activeBtw?.panel; - if (panel === undefined || !panel.scroll(direction)) return false; - this.state.ui.requestRender(); - return true; - } - - private promptBtwAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { - const session = this.session; - if (session === undefined) { - panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); - this.state.ui.requestRender(); - return; - } - void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { - panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); - this.state.ui.requestRender(); - }); - } - - private async cancelSideAgent(agentId: string): Promise { - const session = this.session; - if (session === undefined) return; - await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { - this.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); - }); - } - - private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { - const previousAgentId = this.harness.interactiveAgentId; - this.harness.interactiveAgentId = agentId; - try { - return fn(); - } finally { - this.harness.interactiveAgentId = previousAgentId; - } - } - private async runMigrationScreen(plan: MigrationPlan): Promise { const result = await new Promise((resolve) => { const screen = new MigrationScreenComponent({ 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 d694c598b6..00b7fe8073 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 @@ -12,7 +12,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; -import { BtwPanelComponent } from '#/tui/commands/btw'; +import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; From e4ec50667fcf3b79cd754a12963447ec908a33c6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:26:42 +0800 Subject: [PATCH 10/18] fix --- .../src/tui/components/panes/btw-panel.ts | 19 ++++++++++++++ .../src/tui/controllers/btw-panel.ts | 6 +++-- .../test/tui/kimi-tui-message-flow.test.ts | 26 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) 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 7f8b8a1f15..80ec405df1 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -36,6 +36,7 @@ export interface BtwPanelOptions { export class BtwPanelComponent implements Component { private readonly turns: BtwTurn[] = []; + private readonly transientNotices: string[] = []; private minBodyLines = 0; private expanded = false; private followTail = true; @@ -49,6 +50,7 @@ export class BtwPanelComponent implements Component { if (normalized.length === 0 || this.isRunning()) return; this.followTail = true; this.scrollTop = 0; + this.transientNotices.length = 0; this.turns.push({ prompt: normalized, answer: '', @@ -58,6 +60,11 @@ export class BtwPanelComponent implements Component { this.options.onPrompt(normalized); } + addTransientNotice(message: string): void { + this.transientNotices.push(message); + this.followTail = true; + } + appendAnswer(delta: string): void { const turn = this.currentTurn(); if (turn === undefined) return; @@ -76,6 +83,7 @@ export class BtwPanelComponent implements Component { if (turn.answer.trim().length === 0 && resultSummary !== undefined) { turn.answer = resultSummary; } + this.transientNotices.length = 0; turn.phase = 'done'; } @@ -89,9 +97,11 @@ export class BtwPanelComponent implements Component { error, phase: 'failed', }); + this.transientNotices.length = 0; return; } turn.error = error; + this.transientNotices.length = 0; turn.phase = 'failed'; } @@ -133,9 +143,18 @@ export class BtwPanelComponent implements Component { if (this.turns.length === 0) { lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); } + lines.push(...this.renderTransientNotices(width)); return this.fitBodyLines(lines); } + private renderTransientNotices(width: number): string[] { + const lines: string[] = []; + for (const notice of this.transientNotices) { + lines.push(...new Text(chalk.hex(this.options.colors.textDim)(notice), 0, 0).render(width)); + } + return lines; + } + private fitBodyLines(lines: string[]): BtwBodyRender { const bodyLimit = this.expanded ? undefined : this.collapsedBodyLimit(); const targetUncapped = Math.max(this.minBodyLines, lines.length); diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 0815dd7358..8c34398e21 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -12,13 +12,14 @@ import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; import type { TUIState } from '../tui-state'; +const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; + export interface BtwPanelHost { state: TUIState; session: Session | undefined; readonly harness: KimiHarness; showError(msg: string): void; - showStatus(msg: string, color?: string): void; } export class BtwPanelController { @@ -71,7 +72,8 @@ export class BtwPanelController { if (active === undefined) return false; if (active.panel.isRunning()) { this.host.state.editor.setText(text); - this.host.showStatus('Wait for /btw to finish before sending another question.'); + active.panel.addTransientNotice(BTW_BUSY_NOTICE); + this.host.state.ui.requestRender(); return true; } active.panel.submit(text); 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 00b7fe8073..9628bdeb78 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 @@ -1727,11 +1727,33 @@ describe('KimiTUI message flow', () => { expect(harness.interactiveAgentId).toBe('main'); driver.handleUserInput('follow-up while btw prompt is pending'); + driver.handleUserInput('another follow-up while btw prompt is pending'); expect(session.prompt).toHaveBeenCalledTimes(1); expect(driver.state.queuedMessages).toEqual([]); - expect(driver.state.editor.getText()).toBe('follow-up while btw prompt is pending'); - expect(stripSgr(renderTranscript(driver))).toContain( + expect(driver.state.editor.getText()).toBe('another follow-up while btw prompt is pending'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Wait for /btw to finish before sending another question.', + ); + expect( + countOccurrences( + stripSgr(renderBtwPanel(driver)), + 'Wait for /btw to finish before sending another question.', + ), + ).toBe(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(stripSgr(renderBtwPanel(driver))).not.toContain( 'Wait for /btw to finish before sending another question.', ); From 1af3ec03817500543394bf1d28bff1232e95c044 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:31:41 +0800 Subject: [PATCH 11/18] fix --- .../src/tui/components/panes/btw-panel.ts | 14 ++---------- .../src/tui/controllers/btw-panel.ts | 12 ---------- .../src/tui/controllers/editor-keyboard.ts | 1 - .../test/tui/kimi-tui-message-flow.test.ts | 22 +++++-------------- 4 files changed, 8 insertions(+), 41 deletions(-) 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 80ec405df1..891b0a5cea 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -38,7 +38,6 @@ export class BtwPanelComponent implements Component { private readonly turns: BtwTurn[] = []; private readonly transientNotices: string[] = []; private minBodyLines = 0; - private expanded = false; private followTail = true; private scrollTop = 0; private maxScrollTop = 0; @@ -120,9 +119,7 @@ export class BtwPanelComponent implements Component { private renderTopBorder(width: number, truncated: boolean): string { const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); - const hint = truncated - ? 'Esc close · ↑↓ scroll · ctrl+o expand ' - : 'Esc close '; + const hint = truncated ? 'Esc close · ↑↓ scroll ' : 'Esc close '; const title = chalk.hex(this.options.colors.accent).bold(' BTW ') + paint('─ ') + @@ -156,7 +153,7 @@ export class BtwPanelComponent implements Component { } private fitBodyLines(lines: string[]): BtwBodyRender { - const bodyLimit = this.expanded ? undefined : this.collapsedBodyLimit(); + const bodyLimit = this.collapsedBodyLimit(); const targetUncapped = Math.max(this.minBodyLines, lines.length); const target = bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); @@ -239,13 +236,6 @@ export class BtwPanelComponent implements Component { return this.currentTurn()?.phase === 'running'; } - toggleExpanded(): boolean { - this.expanded = !this.expanded; - this.followTail = true; - this.scrollTop = 0; - return this.expanded; - } - 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 8c34398e21..89ef1eb961 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -82,18 +82,6 @@ export class BtwPanelController { return true; } - toggleExpansion(): boolean { - const panel = this.active?.panel; - if (panel === undefined) return false; - const expanded = panel.toggleExpanded(); - if (expanded) { - this.host.state.ui.requestRender(); - } else { - this.host.state.ui.requestRender(true); - } - return true; - } - scroll(direction: 'up' | 'down'): boolean { const panel = this.active?.panel; if (panel === undefined || !panel.scroll(direction)) return false; diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 25e27743dc..71b5ef913c 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -143,7 +143,6 @@ export class EditorKeyboardController { editor.onToggleToolExpand = () => { host.track('shortcut_expand'); - if (host.btwPanelController.toggleExpansion()) return; host.toggleToolOutputExpansion(); }; 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 9628bdeb78..c82ce466f0 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 @@ -1566,7 +1566,7 @@ describe('KimiTUI message flow', () => { expect(finalLines.at(-1)).toMatch(/^│\s+│$/); }); - it('caps collapsed /btw height to half the terminal and Ctrl-O toggles expansion', async () => { + it('caps /btw height to half the terminal and supports scrolling', async () => { const session = makeSession(); const { driver } = await makeDriver(session); setTerminalRows(driver, 12); @@ -1583,7 +1583,8 @@ describe('KimiTUI message flow', () => { const collapsed = panel.render(80).map(stripSgr); expect(collapsed).toHaveLength(6); - expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll · ctrl+o expand'); + expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll'); + expect(collapsed.join('\n')).not.toContain('ctrl+o expand'); expect(collapsed.join('\n')).toContain('question 8'); expect(collapsed.join('\n')).toContain('answer 8'); expect(collapsed.join('\n')).not.toContain('question 1'); @@ -1614,24 +1615,13 @@ describe('KimiTUI message flow', () => { setTerminalRows(driver, 4); const tiny = panel.render(80).map(stripSgr); expect(tiny).toHaveLength(3); - expect(tiny.join('\n')).toContain('ctrl+o expand'); + expect(tiny.join('\n')).not.toContain('ctrl+o expand'); expect(tiny.join('\n')).toContain('answer 8'); requestRender.mockClear(); driver.state.editor.onToggleToolExpand?.(); - - const expanded = panel.render(80).map(stripSgr); - expect(requestRender.mock.calls.at(-1)).toEqual([]); - expect(expanded.length).toBeGreaterThan(6); - expect(expanded.join('\n')).not.toContain('ctrl+o expand'); - expect(expanded.join('\n')).toContain('question 1'); - expect(expanded.join('\n')).toContain('answer 8'); - - setTerminalRows(driver, 12); - requestRender.mockClear(); - driver.state.editor.onToggleToolExpand?.(); - expect(requestRender.mock.calls.at(-1)).toEqual([true]); - expect(panel.render(80).map(stripSgr)).toHaveLength(6); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(panel.render(80).map(stripSgr)).toEqual(tiny); }); it('cancels and closes a running /btw panel on Escape', async () => { From 8e4d550115acb77ddc1a23f056beff8456420420 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:42:37 +0800 Subject: [PATCH 12/18] fix --- apps/kimi-code/src/tui/commands/btw.ts | 1 + .../src/tui/controllers/btw-panel.ts | 13 +++-- .../test/tui/kimi-tui-message-flow.test.ts | 52 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts index a3a5ab0ed8..5b857151a8 100644 --- a/apps/kimi-code/src/tui/commands/btw.ts +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -14,6 +14,7 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr host.showError(LLM_NOT_SET_MESSAGE); return; } + host.btwPanelController.closeOrCancel(); try { const agentId = await session.startBtw(); diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 89ef1eb961..797c7934a4 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -71,9 +71,7 @@ export class BtwPanelController { const active = this.active; if (active === undefined) return false; if (active.panel.isRunning()) { - this.host.state.editor.setText(text); - active.panel.addTransientNotice(BTW_BUSY_NOTICE); - this.host.state.ui.requestRender(); + this.showBusyNotice(active, text); return true; } active.panel.submit(text); @@ -173,6 +171,15 @@ export class BtwPanelController { if (this.active?.panel === panel) this.active = undefined; } + private showBusyNotice( + active: { readonly panel: BtwPanelComponent }, + input: string, + ): void { + this.host.state.editor.setText(input); + active.panel.addTransientNotice(BTW_BUSY_NOTICE); + this.host.state.ui.requestRender(); + } + private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { const session = this.host.session; if (session === undefined) { 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 3f46247fb6..38be35c2b9 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 @@ -1752,6 +1752,58 @@ describe('KimiTUI message flow', () => { resolveBtwPrompt?.(); }); + it('replaces a running /btw panel when another /btw command is submitted', async () => { + const session = makeSession({ + startBtw: vi.fn() + .mockResolvedValueOnce('agent-btw-1') + .mockResolvedValueOnce('agent-btw-2'), + }); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + + const firstPanel = getMountedBtwPanel(driver); + expect(firstPanel.isRunning()).toBe(true); + + driver.handleUserInput('/btw second question'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledTimes(2); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('second question'); + }); + + const secondPanel = getMountedBtwPanel(driver); + expect(secondPanel).not.toBe(firstPanel); + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(session.prompt).toHaveBeenCalledTimes(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-1', + sessionId: 'ses-1', + turnId: 0, + delta: 'answer from old side agent', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-2', + sessionId: 'ses-1', + turnId: 1, + delta: 'answer from new side agent', + } as Event, + () => {}, + ); + + const renderedPanel = stripSgr(renderBtwPanel(driver)); + expect(renderedPanel).not.toContain('answer from old side agent'); + expect(renderedPanel).toContain('answer from new side agent'); + }); + it('does not run /btw without a question or selected model', async () => { const { driver, session } = await makeDriver(); From da56cdef62b7414ebe6c0e041f057ed211e0f04c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:56:29 +0800 Subject: [PATCH 13/18] fix --- .../src/tui/components/panes/btw-panel.ts | 35 ++++--- .../src/tui/controllers/btw-panel.ts | 8 ++ .../src/tui/controllers/editor-keyboard.ts | 6 +- .../test/tui/kimi-tui-message-flow.test.ts | 92 +++++++++++++++++++ 4 files changed, 121 insertions(+), 20 deletions(-) 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 891b0a5cea..252438e08c 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -30,6 +30,7 @@ interface BtwBodyRender { export interface BtwPanelOptions { readonly colors: ColorPalette; readonly markdownTheme: MarkdownTheme; + readonly canUseScrollKeys: () => boolean; readonly onPrompt: (prompt: string) => void; readonly terminalRows: () => number; } @@ -119,7 +120,9 @@ export class BtwPanelComponent implements Component { private renderTopBorder(width: number, truncated: boolean): string { const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); - const hint = truncated ? 'Esc close · ↑↓ scroll ' : 'Esc close '; + const hint = truncated && this.options.canUseScrollKeys() + ? 'Esc close · ↑↓ scroll ' + : 'Esc close '; const title = chalk.hex(this.options.colors.accent).bold(' BTW ') + paint('─ ') + @@ -189,21 +192,12 @@ export class BtwPanelComponent implements Component { private renderTurn(turn: BtwTurn, width: number): string[] { const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); - if (turn.error !== undefined) { - return [ - ...new Text(prompt, 0, 0).render(width), - ...new Text(chalk.hex(this.options.colors.error)(turn.error), 0, 0).render(width), - ]; - } + const lines = [...new Text(prompt, 0, 0).render(width)]; const answer = turn.answer.trim(); - if (answer.length > 0) { - return [ - ...new Text(prompt, 0, 0).render(width), - ...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width), - ]; - } const thinking = turn.thinking.trim(); - if (thinking.length > 0) { + if (answer.length > 0) { + lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + } else if (thinking.length > 0) { const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( width, ); @@ -211,12 +205,15 @@ export class BtwPanelComponent implements Component { thinkingLines.length > THINKING_PREVIEW_LINES ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) : thinkingLines; - return [...new Text(prompt, 0, 0).render(width), ...visibleThinking]; + lines.push(...visibleThinking); + } else if (turn.error === undefined) { + lines.push(chalk.hex(this.options.colors.textDim)('Waiting for answer...')); + } + if (turn.error !== undefined) { + const error = chalk.hex(this.options.colors.error)(turn.error); + lines.push(...new Text(error, 0, 0).render(width)); } - return [ - ...new Text(prompt, 0, 0).render(width), - chalk.hex(this.options.colors.textDim)('Waiting for answer...'), - ]; + return lines; } private renderBodyLine(line: string, width: number): string { diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index 797c7934a4..ef809e5508 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -38,6 +38,7 @@ export class BtwPanelController { panel = new BtwPanelComponent({ colors: this.host.state.theme.colors, markdownTheme: this.host.state.theme.markdownTheme, + canUseScrollKeys: () => this.host.state.editor.getText().length === 0, terminalRows: () => this.host.state.terminal.rows, onPrompt: (prompt) => { this.promptAgent(agentId, prompt, panel); @@ -67,6 +68,13 @@ export class BtwPanelController { return true; } + cancelRunning(): boolean { + const active = this.active; + if (active === undefined || !active.panel.isRunning()) return false; + void this.cancelAgent(active.agentId); + return true; + } + sendUserInput(text: string): boolean { const active = this.active; if (active === undefined) return false; diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 71b5ef913c..b36917263a 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -75,7 +75,11 @@ export class EditorKeyboardController { return; } - if (editor.getText().length === 0 && host.btwPanelController.closeOrCancel()) { + if (host.btwPanelController.cancelRunning()) { + this.clearPendingExit(); + return; + } + if (host.btwPanelController.closeOrCancel()) { this.clearPendingExit(); return; } 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 38be35c2b9..fc045b86fc 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 @@ -1591,6 +1591,12 @@ describe('KimiTUI message flow', () => { expect(collapsed.join('\n')).toContain('answer 8'); expect(collapsed.join('\n')).not.toContain('question 1'); + driver.state.editor.setText('draft main input'); + const collapsedWithInput = panel.render(80).map(stripSgr); + expect(collapsedWithInput.join('\n')).toContain('BTW ─ Esc close'); + expect(collapsedWithInput.join('\n')).not.toContain('↑↓ scroll'); + driver.state.editor.setText(''); + const requestRender = vi.mocked(driver.state.ui.requestRender); requestRender.mockClear(); for (let i = 0; i < 20; i++) { @@ -1648,6 +1654,92 @@ describe('KimiTUI message flow', () => { expect(driver.state.editor.focused).toBe(true); }); + it('cancels a running /btw panel on Ctrl-C without closing it or cancelling main streaming', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + const cancelledAgentIds: string[] = []; + session.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, session); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(getMountedBtwPanel(driver)).toBe(panel); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + + it('preserves rendered /btw output when a running panel is cancelled', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'partial side answer', + } as Event, + () => {}, + ); + + driver.state.editor.onCtrlC?.(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'cancelled', + } as Event, + () => {}, + ); + + const panel = stripSgr(renderBtwPanel(driver)); + expect(panel).toContain('partial side answer'); + expect(panel).toContain('BTW turn ended with reason: cancelled'); + }); + + it('closes a completed /btw panel on Ctrl-C without cancelling main streaming', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + it('closes a completed /btw panel on Escape without cancelling it', async () => { const session = makeSession(); const { driver } = await makeDriver(session); From 7553ac1ad0c7c0a90b93b7b4302357db806151c1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 14:57:38 +0800 Subject: [PATCH 14/18] fix --- apps/kimi-code/src/tui/controllers/btw-panel.ts | 3 +++ apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index ef809e5508..ed78069eb9 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -225,5 +225,8 @@ function formatBtwTurnEnd(event: TurnEndedEvent): string { if (event.error !== undefined) { return `[${event.error.code}] ${event.error.message}`; } + if (event.reason === 'cancelled') { + return 'Interrupted by user'; + } return `BTW turn ended with reason: ${event.reason}`; } 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 fc045b86fc..e6d06875a5 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 @@ -1708,7 +1708,7 @@ describe('KimiTUI message flow', () => { const panel = stripSgr(renderBtwPanel(driver)); expect(panel).toContain('partial side answer'); - expect(panel).toContain('BTW turn ended with reason: cancelled'); + expect(panel).toContain('Interrupted by user'); }); it('closes a completed /btw panel on Ctrl-C without cancelling main streaming', async () => { From 5f1c0f4f643e14d21346fcd7d9ba1654769ff90e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 15:21:25 +0800 Subject: [PATCH 15/18] fix --- .../src/tui/controllers/btw-panel.ts | 4 +++ .../test/tui/kimi-tui-message-flow.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index ed78069eb9..1ec1eab43b 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -51,6 +51,10 @@ export class BtwPanelController { } clear(): void { + const active = this.active; + if (active?.panel.isRunning()) { + void this.cancelAgent(active.agentId); + } this.active = undefined; this.panelsByAgentId.clear(); this.host.state.btwPanelContainer.clear(); 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 e6d06875a5..4655b342db 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 @@ -1711,6 +1711,31 @@ describe('KimiTUI message flow', () => { expect(panel).toContain('Interrupted by user'); }); + it('cancels a running /btw panel when starting a new session clears it', async () => { + const initialSession = makeSession({ id: 'ses-initial' }); + const nextSession = makeSession({ id: 'ses-next' }); + const createSession = vi + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValueOnce(nextSession); + const { driver, harness } = await makeDriver(initialSession, { createSession }); + const cancelledAgentIds: string[] = []; + initialSession.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, initialSession); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-next'); + }); + expect(initialSession.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(nextSession.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + }); + it('closes a completed /btw panel on Ctrl-C without cancelling main streaming', async () => { const session = makeSession(); const { driver } = await makeDriver(session); From e77d343d8101a7565a11c28ce29e919a5b4e2416 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 15:24:15 +0800 Subject: [PATCH 16/18] fix --- packages/agent-core/src/agent/context/index.ts | 5 +++-- packages/agent-core/src/session/subagent-host.ts | 2 +- packages/agent-core/test/session/init.test.ts | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index b82c667bdc..1beebd6592 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -175,8 +175,9 @@ export class ContextMemory { return this.project(this.history); } - appendProjectedHistoryFrom(source: ContextMemory): void { - this._history.push(...trimTrailingOpenToolExchange(source.project(source.history))); + useProjectedHistoryFrom(source: ContextMemory): void { + this.clear(); + this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history))); } appendLoopEvent(event: LoopRecordedEvent): void { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 1fb5a24da3..9d4f8fde9f 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -203,7 +203,7 @@ export class SessionSubagentHost { systemPrompt: parent.config.systemPrompt, }); child.tools.copyLoopToolsFrom(parent.tools); - child.context.appendProjectedHistoryFrom(parent.context); + child.context.useProjectedHistoryFrom(parent.context); child.context.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { kind: 'system_trigger', name: 'btw', diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 05da037b11..a0e198e463 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1,18 +1,19 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'pathe'; -import { fileURLToPath } from 'node:url'; +import { join } from 'pathe'; import { testKaos } from '../fixtures/test-kaos'; import type { ProviderConfig, ToolCall } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; +import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { SessionAPIImpl } from '../../src/session/rpc'; +import { estimateTokensForMessages } from '../../src/utils/tokens'; import { createScriptedGenerate } from '../agent/harness/scripted-generate'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; @@ -224,6 +225,16 @@ describe('AgentAPI.startBtw', () => { const agentId = await api.startBtw({ agentId: 'main' }); expect(agentId).toBe('agent-0'); expect(scripted.calls).toHaveLength(0); + const childAgent = session.agents.get(agentId); + if (childAgent === undefined) throw new Error('Expected /btw child agent'); + const inheritedHistory = trimTrailingOpenToolExchange( + mainAgent.context.project(mainAgent.context.history), + ); + expect(childAgent.context.history.slice(0, inheritedHistory.length)).toEqual(inheritedHistory); + expect(childAgent.context.tokenCount).toBe(0); + expect(childAgent.context.tokenCountWithPending).toBeGreaterThanOrEqual( + estimateTokensForMessages(inheritedHistory), + ); await api.prompt({ agentId, From d855647e867b980f42a38770d0aff395077f47a4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 15:35:04 +0800 Subject: [PATCH 17/18] fix --- packages/agent-core/src/session/index.ts | 34 ++++++++++++------- .../agent-core/src/session/subagent-host.ts | 6 ++-- .../test/harness/goal-session.test.ts | 5 ++- packages/agent-core/test/session/init.test.ts | 9 ++--- .../test/session/subagent-host.test.ts | 34 +++++++++++-------- .../test/session-prompt-events.test.ts | 9 +++++ 6 files changed, 62 insertions(+), 35 deletions(-) diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 1abe7605b1..cfd8adb0b7 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -76,6 +76,12 @@ export interface AgentMeta { readonly parentAgentId: string | null; } +export interface CreateAgentOptions { + readonly profile?: ResolvedAgentProfile; + readonly parentAgentId?: string; + readonly persistMetadata?: boolean; +} + export interface SessionMeta { createdAt: string; updatedAt: string; @@ -170,7 +176,9 @@ export class Session { } async createMain() { - const { agent } = await this.createAgent({ type: 'main' }, DEFAULT_AGENT_PROFILES['agent']); + const { agent } = await this.createAgent({ type: 'main' }, { + profile: DEFAULT_AGENT_PROFILES['agent'], + }); // The main-agent audit sink now exists; flush any goal records queued before it. this.goals.flushPendingRecords(); await this.triggerSessionStart('startup'); @@ -246,25 +254,27 @@ export class Session { async createAgent( config: Partial, - profile?: ResolvedAgentProfile, - parentAgentId?: string | undefined, + options: CreateAgentOptions = {}, ): Promise<{ readonly id: string; readonly agent: Agent }> { await this.skillsReady; const type = config.type ?? 'main'; const id = type === 'main' ? 'main' : this.nextGeneratedAgentId(); const homedir = config.homedir ?? join(this.options.homedir, 'agents', id); - const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null); - if (profile) { - await this.bootstrapAgentProfile(agent, profile); + const parentAgentId = options.parentAgentId ?? null; + const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId); + if (options.profile) { + await this.bootstrapAgentProfile(agent, options.profile); } this.agents.set(id, agent); - this.metadata.agents[id] = { - homedir, - type, - parentAgentId: parentAgentId ?? null, - }; - void this.writeMetadata(); + if (options.persistMetadata !== false) { + this.metadata.agents[id] = { + homedir, + type, + parentAgentId, + }; + void this.writeMetadata(); + } return { id, agent }; } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 9d4f8fde9f..2ea9a4362b 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -89,8 +89,7 @@ export class SessionSubagentHost { const profile = this.resolveProfile(parent, profileName); const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, - undefined, - this.ownerAgentId, + { parentAgentId: this.ownerAgentId }, ); const controller = new AbortController(); const unlinkAbortSignal = linkAbortSignal(options.signal, controller); @@ -193,8 +192,7 @@ export class SessionSubagentHost { generate: parent.rawGenerate, persistence: new InMemoryAgentRecordPersistence(), }, - undefined, - this.ownerAgentId, + { parentAgentId: this.ownerAgentId, persistMetadata: false }, ); child.config.update({ diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 9c9061430e..2751781723 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -97,7 +97,10 @@ async function setupSession( hooks, }), ); - const { agent } = await session.createAgent({ type: 'main', generate: generate ?? scripted.generate }, goalProfile(tools)); + const { agent } = await session.createAgent( + { type: 'main', generate: generate ?? scripted.generate }, + { profile: goalProfile(tools) }, + ); agent.config.update({ modelAlias: 'mock-model', thinkingLevel: 'off' }); agent.permission.setMode('yolo'); return { session, agent, scripted }; diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index a0e198e463..210d5102fc 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -54,7 +54,7 @@ describe('Session.init', () => { }); const { agent: mainAgent } = await session.createAgent( { type: 'main', generate: scripted.generate }, - testProfile(), + { profile: testProfile() }, ); mainAgent.config.update({ modelAlias: 'mock-model', @@ -191,7 +191,7 @@ describe('AgentAPI.startBtw', () => { }); const { agent: mainAgent } = await session.createAgent( { type: 'main', generate: scripted.generate }, - testProfile(), + { profile: testProfile() }, ); mainAgent.config.update({ modelAlias: 'mock-model', @@ -225,6 +225,7 @@ describe('AgentAPI.startBtw', () => { const agentId = await api.startBtw({ agentId: 'main' }); expect(agentId).toBe('agent-0'); expect(scripted.calls).toHaveLength(0); + expect(session.metadata.agents[agentId]).toBeUndefined(); const childAgent = session.agents.get(agentId); if (childAgent === undefined) throw new Error('Expected /btw child agent'); const inheritedHistory = trimTrailingOpenToolExchange( @@ -309,7 +310,7 @@ describe('AgentAPI.startBtw', () => { }); const { agent: mainAgent } = await session.createAgent( { type: 'main', generate: scripted.generate }, - testProfile(), + { profile: testProfile() }, ); mainAgent.config.update({ modelAlias: 'mock-model', @@ -412,7 +413,7 @@ describe('AgentAPI.startBtw', () => { }); const { agent: mainAgent } = await session.createAgent( { type: 'main', generate }, - testProfile(), + { profile: testProfile() }, ); mainAgent.config.update({ modelAlias: 'mock-model', diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 7d80a9b4a7..16bdf416fa 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -727,6 +727,7 @@ describe('SessionSubagentHost', () => { type: 'text', text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', }); + vi.mocked(collectGitContext).mockReset().mockResolvedValue(''); const session = fakeSession(parent.agent, child.agent, { 'agent-0': { @@ -937,7 +938,7 @@ describe('Session.createAgent', () => { initializeMainAgent: false, }); - const created = await session.createAgent({ type: 'main' }, contextProfile()); + const created = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(created.agent.config.systemPrompt).toContain('cwd=/remote/project'); expect(created.agent.config.systemPrompt).toContain('listing=└── README.md'); @@ -1002,7 +1003,7 @@ describe('Session.createAgent', () => { initializeMainAgent: false, }); - const created = await session.createAgent({ type: 'main' }, contextProfile()); + const created = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(created.agent.config.systemPrompt).toContain('cwd=/repo/packages/app'); expect(created.agent.config.systemPrompt).toContain('listing=├── src/'); @@ -1049,14 +1050,17 @@ describe('Session.createAgent', () => { }); // Create a parent agent — it should start at the session workDir. - const parent = await session.createAgent({ type: 'main' }, contextProfile()); + const parent = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(parent.agent.config.systemPrompt).toContain(`cwd=${sessionWorkDir}`); // Move the parent agent to a different cwd (e.g. after a config.update replay). parent.agent.config.update({ cwd: parentWorkDir }); // Create a subagent from the moved parent. - const child = await session.createAgent({ type: 'sub' }, contextProfile(), parent.id); + const child = await session.createAgent( + { type: 'sub' }, + { profile: contextProfile(), parentAgentId: parent.id }, + ); // The subagent should inherit the parent's current cwd, not the session default. expect(child.agent.config.systemPrompt).toContain(`cwd=${parentWorkDir}`); @@ -1104,7 +1108,7 @@ describe('Session.createAgent', () => { const main = await session.createAgent({ type: 'main' }); expect(main.agent.mcp).toBe(session.mcp); - const sub = await session.createAgent({ type: 'sub' }, undefined, main.id); + const sub = await session.createAgent({ type: 'sub' }, { parentAgentId: main.id }); expect(sub.agent.mcp).toBe(session.mcp); }); }); @@ -1132,17 +1136,19 @@ function fakeSession( createAgent: vi.fn( async ( config: Parameters[0], - profile?: ResolvedAgentProfile, - parentAgentId?: string, + options: Parameters[1] = {}, ) => { agents.set('agent-0', child); - metadataAgents['agent-0'] = { - homedir: '/tmp/kimi-session/agents/agent-0', - type: config.type ?? 'main', - parentAgentId: parentAgentId ?? null, - }; - if (profile !== undefined) { - child.useProfile(profile); + const parentAgentId = options.parentAgentId ?? null; + if (options.persistMetadata !== false) { + metadataAgents['agent-0'] = { + homedir: '/tmp/kimi-session/agents/agent-0', + type: config.type ?? 'main', + parentAgentId, + }; + } + if (options.profile !== undefined) { + child.useProfile(options.profile); } return { id: 'agent-0', agent: child }; }, diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 3c7c0eef65..21bb33e295 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -377,6 +377,15 @@ describe('Session.prompt events', () => { const statePath = join(session.summary!.sessionDir, 'state.json'); const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; expect(state['lastPrompt']).toBe('main task context'); + expect(state['agents']).toMatchObject({ main: expect.any(Object) }); + expect(state['agents']).not.toHaveProperty(agentId); + + await harness.closeSession(session.id); + const resumed = await harness.resumeSession({ id: session.id }); + const resumeState = resumed.getResumeState(); + expect(resumeState?.agents).toMatchObject({ main: expect.any(Object) }); + expect(resumeState?.agents).not.toHaveProperty(agentId); + expect(resumeState?.sessionMetadata.agents).not.toHaveProperty(agentId); } finally { await harness.close(); } From 59250e6e06126caecac8b1bd05d5f1c714bf6d1b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 15:47:00 +0800 Subject: [PATCH 18/18] fix --- packages/node-sdk/test/session-prompt-events.test.ts | 2 +- packages/node-sdk/test/session-prompt-input.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 30a28be060..dd8f5e1e5e 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -312,7 +312,7 @@ describe('Session.prompt events', () => { it('starts btw through RPC as a forked subagent without prompt metadata updates', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); - const harness = new KimiHarness({ + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir, }); diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index 782668eef4..6a6cf0f779 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -30,7 +30,7 @@ describe('Session.prompt input normalization', () => { const session = new Session({ id: 'ses_btw_start', workDir: '/tmp/work', - rpc: { startBtw } as unknown as SDKRpcClient, + rpc: { startBtw } as unknown as SDKRpcClientBase, }); await expect(session.startBtw()).resolves.toBe('agent-btw');