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 00000000..1d02c5fa --- /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 00000000..d249d7a1 --- /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 259fb02d..f8a5f575 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 643856a7..bbec3091 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 71001d4a..60a9916e 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 6f13a9f9..d7e46d2f 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 1e239e50..8111e20b 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 1fb1f444..41c71463 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 c481aa2c..058e1891 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 dbbdff7b..e404b970 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 8cdac1c1..d831ecf5 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 39552b86..5df274fb 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 4f2a56a0..335dd096 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 26bd6e5a..d71c3837 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 af504f2c..3bd9d122 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 45aca213..65e64904 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 386dccd9..b82c667b 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 fc0ba2e5..e0ae9997 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 cad1e5b4..7d9699b8 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 ddd51d52..06ce01f3 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 00000000..3b5bf500 --- /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 38e0bb9d..5cc0386c 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 f95de837..9b6f8373 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 a636e2ee..f42860c4 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 31fd26a7..b7f4aa33 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 d7172ca7..7f988f29 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 458e291e..8135e045 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 3a501540..e6205cee 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 558af309..4e69c9b5 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 e2df96e7..39578d67 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 c6961577..fa5c5c91 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 98c39aaf..46efacd3 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 df32e2b4..330dc2ec 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 1d02c5fa..9224db09 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 d249d7a1..1bf0f640 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 f8a5f575..ddf7a8ec 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 d7e46d2f..7a248433 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 8111e20b..115c7f5b 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 d831ecf5..045dc1ca 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 335dd096..abbfabe5 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 3bd9d122..e91b2203 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 7d9699b8..1211a068 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 f42860c4..0f26ef69 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 b7f4aa33..4fab97ce 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 7f988f29..a4110725 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 8135e045..54bac477 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 e6205cee..891e5fbc 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 39578d67..0d858282 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 fa5c5c91..d8c3cea6 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 46efacd3..3c7c0eef 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 330dc2ec..a3d2f048 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 115c7f5b..b0546f8e 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 045dc1ca..87a8271c 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 0d858282..9b22ef2b 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 65e64904..45aca213 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 54bac477..ed017772 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 891e5fbc..05da037b 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 ed017772..98b1c3fd 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 1bf0f640..5dbf9887 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 75473d7a..4fdeef20 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 b0546f8e..2036d8f1 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 87a8271c..4c025be0 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 5dbf9887..16e79543 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 3ceff7a9..c77f4af2 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 4fdeef20..5995d67a 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 2036d8f1..9c7b2f68 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 11801aa7..d137558b 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 4c025be0..d694c598 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 98b1c3fd..6580987b 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 16e79543..a3a5ab0e 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 ddf7a8ec..2770345d 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 00000000..7f8b8a1f --- /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 00000000..0815dd73 --- /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 5995d67a..25e27743 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 7a248433..6b0e4956 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 9c7b2f68..38432d8a 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 d694c598..00b7fe80 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 7f8b8a1f..80ec405d 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 0815dd73..8c34398e 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 00b7fe80..9628bdeb 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 80ec405d..891b0a5c 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 8c34398e..89ef1eb9 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 25e27743..71b5ef91 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 9628bdeb..c82ce466 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 a3a5ab0e..5b857151 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 89ef1eb9..797c7934 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 3f46247f..38be35c2 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 891b0a5c..252438e0 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 797c7934..ef809e55 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 71b5ef91..b3691726 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 38be35c2..fc045b86 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 ef809e55..ed78069e 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 fc045b86..e6d06875 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 ed78069e..1ec1eab4 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 e6d06875..4655b342 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 b82c667b..1beebd65 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 1fb5a24d..9d4f8fde 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 05da037b..a0e198e4 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 1abe7605..cfd8adb0 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 9d4f8fde..2ea9a436 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 9c906143..27517817 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 a0e198e4..210d5102 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 7d80a9b4..16bdf416 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 3c7c0eef..21bb33e2 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 30a28be0..dd8f5e1e 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 782668ee..6a6cf0f7 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');