From 758ab5e381a0de842ad2ba844027b5b7ed9e49b0 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 14:25:36 +0800 Subject: [PATCH 01/28] refactor(tui): extract TasksBrowserController from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 /tasks 浏览器相关的 16 个方法(~450 行)从 KimiTUI 提取到独立的 TasksBrowserController。KimiTUI 通过 Host 接口传 this 给 controller, 保留 4 个一行委托方法供外部调用点使用。 kimi-tui.ts: 6160 → 5711 行 (-449) --- .../src/tui/controllers/tasks-browser.ts | 439 ++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 475 +----------------- 2 files changed, 452 insertions(+), 462 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/tasks-browser.ts diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts new file mode 100644 index 0000000000..af2d217e90 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -0,0 +1,439 @@ +import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; + +import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; +import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; +import type { ColorPalette } from '../theme'; +import type { CustomEditor } from '../components/editor/custom-editor'; + +export interface TasksBrowserHost { + readonly state: { + tasksBrowser: TasksBrowserState | undefined; + readonly backgroundTasks: Map; + readonly theme: { readonly colors: ColorPalette }; + readonly terminal: ProcessTerminal; + readonly ui: TUI; + readonly editor: CustomEditor; + }; + readonly session: Session | undefined; + showError(msg: string): void; +} + +export type TasksBrowserState = { + component: TasksBrowserApp; + savedChildren: readonly Component[]; + filter: TasksFilter; + selectedTaskId: string | undefined; + tailOutput: string | undefined; + tailLoading: boolean; + tailRequestId: number; + flashMessage: string | undefined; + flashTimer: NodeJS.Timeout | undefined; + pollTimer: NodeJS.Timeout | undefined; + viewer: + | { + component: TaskOutputViewer; + savedChildren: readonly Component[]; + taskId: string; + output: string; + refreshId: number; + pollTimer: NodeJS.Timeout; + } + | undefined; +}; + +export class TasksBrowserController { + constructor(private readonly host: TasksBrowserHost) {} + + async show(): Promise { + const { state } = this.host; + if (state.tasksBrowser !== undefined) return; + + const session = this.host.session; + if (session === undefined) { + this.host.showError('No active session.'); + return; + } + + let tasks: readonly BackgroundTaskInfo[] = []; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + this.host.showError( + `Failed to load tasks: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + if (state.tasksBrowser !== undefined) return; + + const filter: TasksFilter = 'all'; + const selectedTaskId = this.pickInitialSelection(tasks, filter); + const component = new TasksBrowserApp( + { + tasks, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + flashMessage: undefined, + colors: state.theme.colors, + ...this.buildCallbacks(), + }, + state.terminal, + ); + + const savedChildren = [...state.ui.children]; + state.ui.clear(); + state.ui.addChild(component); + state.ui.setFocus(component); + state.ui.requestRender(true); + + const pollTimer = setInterval(() => { + void this.refresh({ silent: true }); + }, 1000); + + state.tasksBrowser = { + component, + savedChildren, + filter, + selectedTaskId, + tailOutput: undefined, + tailLoading: false, + tailRequestId: 0, + flashMessage: undefined, + flashTimer: undefined, + pollTimer, + viewer: undefined, + }; + + if (selectedTaskId !== undefined) { + this.loadTail(selectedTaskId); + } + } + + close(): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + if (browser.viewer !== undefined) this.closeOutputViewer(); + if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + + state.ui.clear(); + for (const child of browser.savedChildren) { + state.ui.addChild(child); + } + state.tasksBrowser = undefined; + state.ui.setFocus(state.editor); + state.ui.requestRender(true); + } + + repaint(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + const tasks = [...this.host.state.backgroundTasks.values()]; + this.pushProps(tasks); + } + + async refreshOutputViewer(opts: { silent?: boolean } = {}): Promise { + const { state } = this.host; + const browser = state.tasksBrowser; + const viewer = browser?.viewer; + if (browser === undefined || viewer === undefined) return; + + const session = this.host.session; + if (session === undefined) return; + + const myRefreshId = ++viewer.refreshId; + let output: string; + try { + output = await session.getBackgroundTaskOutput(viewer.taskId); + } catch (error) { + if (!opts.silent) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Output refresh failed: ${message}`); + } + return; + } + const current = state.tasksBrowser?.viewer; + if (current === undefined || current !== viewer || current.refreshId !== myRefreshId) { + return; + } + if (output === viewer.output) return; + viewer.output = output; + const info = state.backgroundTasks.get(viewer.taskId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + output, + colors: state.theme.colors, + onClose: () => { + this.closeOutputViewer(); + }, + }); + state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + + private pickInitialSelection( + tasks: readonly BackgroundTaskInfo[], + filter: TasksFilter, + ): string | undefined { + const candidates = + filter === 'all' + ? tasks + : tasks.filter( + (t) => + t.status !== 'completed' && + t.status !== 'failed' && + t.status !== 'killed' && + t.status !== 'lost', + ); + if (candidates.length === 0) return undefined; + return ( + candidates.find( + (t) => t.status === 'running' || t.status === 'awaiting_approval', + )?.taskId ?? candidates[0]!.taskId + ); + } + + private async refresh(opts: { silent?: boolean } = {}): Promise { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + + const session = this.host.session; + if (session === undefined) return; + + let tasks: readonly BackgroundTaskInfo[]; + try { + tasks = await session.listBackgroundTasks({ activeOnly: false }); + } catch (error) { + if (!opts.silent) { + this.flash( + `Refresh failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + if (state.tasksBrowser !== browser) return; + this.pushProps(tasks); + } + + private pushProps(tasks: readonly BackgroundTaskInfo[]): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + browser.component.setProps({ + tasks, + filter: browser.filter, + selectedTaskId: browser.selectedTaskId, + tailOutput: browser.tailOutput, + tailLoading: browser.tailLoading, + flashMessage: browser.flashMessage, + colors: this.host.state.theme.colors, + ...this.buildCallbacks(), + }); + this.host.state.ui.requestRender(); + } + + private buildCallbacks(): { + onSelect: (taskId: string) => void; + onToggleFilter: () => void; + onRefresh: () => void; + onCancel: () => void; + onStopConfirmed: (taskId: string) => void; + onOpenOutput: (taskId: string) => void; + onStopIgnored: (taskId: string, reason: 'terminal') => void; + } { + return { + onSelect: (taskId) => { + this.handleSelect(taskId); + }, + onToggleFilter: () => { + this.handleToggleFilter(); + }, + onRefresh: () => { + this.handleRefresh(); + }, + onCancel: () => { + this.close(); + }, + onStopConfirmed: (taskId) => { + void this.handleStop(taskId); + }, + onOpenOutput: (taskId) => { + void this.handleOpenOutput(taskId); + }, + onStopIgnored: (taskId, reason) => { + if (reason === 'terminal') { + this.flash(`${taskId} is already terminal — nothing to stop.`); + } + }, + }; + } + + private handleSelect(taskId: string): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + if (browser.selectedTaskId === taskId) return; + browser.selectedTaskId = taskId; + browser.tailOutput = undefined; + browser.tailLoading = true; + this.repaint(); + this.loadTail(taskId); + } + + private handleToggleFilter(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + browser.filter = browser.filter === 'all' ? 'active' : 'all'; + this.repaint(); + } + + private handleRefresh(): void { + this.flash('Refreshing…', 600); + void this.refresh(); + } + + private async handleStop(taskId: string): Promise { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + + const session = this.host.session; + if (session === undefined) { + this.flash('No active session.'); + return; + } + + this.flash(`Stopping ${taskId}…`, 1500); + try { + await session.stopBackgroundTask(taskId, { reason: 'stopped from /tasks' }); + await this.refresh({ silent: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Stop failed: ${message}`); + } + } + + private async handleOpenOutput(taskId: string): Promise { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + if (browser.viewer !== undefined) return; + + const session = this.host.session; + if (session === undefined) { + this.flash('No active session.'); + return; + } + + let output: string; + try { + output = await session.getBackgroundTaskOutput(taskId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.flash(`Cannot open output: ${message}`); + return; + } + const current = state.tasksBrowser; + if (current === undefined || current !== browser) return; + + const info = state.backgroundTasks.get(taskId); + const viewer = new TaskOutputViewer( + { + taskId, + info, + output, + colors: state.theme.colors, + onClose: () => { + this.closeOutputViewer(); + }, + }, + state.terminal, + ); + + const savedBrowserChildren = [...state.ui.children]; + state.ui.clear(); + state.ui.addChild(viewer); + state.ui.setFocus(viewer); + state.ui.requestRender(true); + + const pollTimer = setInterval(() => { + void this.refreshOutputViewer({ silent: true }); + }, 1000); + + browser.viewer = { + component: viewer, + savedChildren: savedBrowserChildren, + taskId, + output, + refreshId: 0, + pollTimer, + }; + } + + private loadTail(taskId: string): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined) return; + + const session = this.host.session; + if (session === undefined) { + browser.tailLoading = false; + this.repaint(); + return; + } + + const requestId = ++browser.tailRequestId; + void session + .getBackgroundTaskOutput(taskId, { tail: 4000 }) + .then((output) => { + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = output; + current.tailLoading = false; + this.repaint(); + }) + .catch(() => { + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + current.tailOutput = ''; + current.tailLoading = false; + this.repaint(); + }); + } + + private flash(message: string, durationMs = 2500): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined) return; + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + browser.flashMessage = message; + browser.flashTimer = setTimeout(() => { + const current = this.host.state.tasksBrowser; + if (current !== browser) return; + current.flashMessage = undefined; + current.flashTimer = undefined; + this.repaint(); + }, durationMs); + this.repaint(); + } + + private closeOutputViewer(): void { + const browser = this.host.state.tasksBrowser; + if (browser === undefined || browser.viewer === undefined) return; + const viewer = browser.viewer; + clearInterval(viewer.pollTimer); + browser.viewer = undefined; + this.host.state.ui.clear(); + for (const child of viewer.savedChildren) { + this.host.state.ui.addChild(child); + } + this.host.state.ui.setFocus(browser.component); + this.host.state.ui.requestRender(true); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79ed27c4b0..9e6382941f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -147,8 +147,7 @@ import { PlatformSelectorComponent } from './components/dialogs/platform-selecto import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; -import { TaskOutputViewer } from './components/dialogs/task-output-viewer'; -import { TasksBrowserApp, type TasksFilter } from './components/dialogs/tasks-browser'; +import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; import { SettingsSelectorComponent, type SettingsSelection, @@ -381,43 +380,7 @@ export interface TUIState { loadingSessions: boolean; showingSessionPicker: boolean; showingHelpPanel: boolean; - /** - * Active `/tasks` full-screen takeover. When non-undefined, the main - * TUI's children have been replaced by `component`; `savedChildren` - * holds the original list so we can restore on exit. - */ - tasksBrowser: - | { - component: TasksBrowserApp; - savedChildren: readonly Component[]; - filter: TasksFilter; - selectedTaskId: string | undefined; - tailOutput: string | undefined; - tailLoading: boolean; - tailRequestId: number; - flashMessage: string | undefined; - flashTimer: NodeJS.Timeout | undefined; - pollTimer: NodeJS.Timeout | undefined; - /** - * Active nested output viewer (TaskOutputViewer). Undefined when - * the browser is showing its normal 3-pane layout. - */ - viewer: - | { - component: TaskOutputViewer; - savedChildren: readonly Component[]; - /** Task whose output the viewer is currently following. */ - taskId: string; - /** Latest output snapshot pushed into the viewer. */ - output: string; - /** Last in-flight refresh — used to ignore late responses. */ - refreshId: number; - /** 1s background poll so live tail still works if events drop. */ - pollTimer: NodeJS.Timeout; - } - | undefined; - } - | undefined; + tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; currentTurnId: string | undefined; currentStep: number; @@ -586,8 +549,8 @@ interface LoginProgressSpinnerHandle { export class KimiTUI { private readonly harness: KimiHarness; private readonly options: KimiTUIOptions; - private session: Session | undefined; - private state: TUIState; + session: Session | undefined; + state: TUIState; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; @@ -622,6 +585,7 @@ export class KimiTUI { private pendingAssistantFlush = false; private pendingThinkingFlush = false; private readonly pendingToolCallFlushIds = new Set(); + private readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -670,6 +634,7 @@ export class KimiTUI { }, }), ); + this.tasksBrowserController = new TasksBrowserController(this); this.setupEditorHandlers(); this.buildLayout(); } @@ -4142,7 +4107,7 @@ export class KimiTUI { } // Appends an error status message to the transcript. - private showError(message: string): void { + showError(message: string): void { this.showStatus(`Error: ${message}`, this.state.theme.colors.error); } @@ -4531,428 +4496,14 @@ export class KimiTUI { } // ========================================================================= - // Background tasks browser (`/tasks`) + // Background tasks browser (`/tasks`) — delegated to TasksBrowserController // ========================================================================= - /** - * Open the `/tasks` overlay. Idempotent: a second `/tasks` while the - * panel is already open is a no-op (the focus stays on the existing - * overlay) — prevents accidental stacking. - */ - private async showTasksBrowser(): Promise { - if (this.state.tasksBrowser !== undefined) return; - const session = this.session; - if (session === undefined) { - this.showError('No active session.'); - return; - } - - let tasks: readonly BackgroundTaskInfo[] = []; - try { - tasks = await session.listBackgroundTasks({ activeOnly: false }); - } catch (error) { - this.showError( - `Failed to load tasks: ${error instanceof Error ? error.message : String(error)}`, - ); - return; - } - // Race: panel might have been opened then immediately closed by - // another path while the await above was in flight. Bail out then. - if (this.state.tasksBrowser !== undefined) return; - - const filter: TasksFilter = 'all'; - const selectedTaskId = this.pickInitialSelection(tasks, filter); - const component = new TasksBrowserApp( - { - tasks, - filter, - selectedTaskId, - tailOutput: undefined, - tailLoading: false, - flashMessage: undefined, - colors: this.state.theme.colors, - ...this.buildTasksBrowserCallbacks(), - }, - this.state.terminal, - ); - - // Alt-screen takeover: save the main TUI's children, then replace - // them with this single full-screen component. `closeTasksBrowser` - // restores the original layout. Mirrors the Python `Application( - // full_screen=True, erase_when_done=True)` pattern. - const savedChildren = [...this.state.ui.children]; - this.state.ui.clear(); - this.state.ui.addChild(component); - this.state.ui.setFocus(component); - this.state.ui.requestRender(true); - - const pollTimer = setInterval(() => { - void this.refreshTasksBrowser({ silent: true }); - }, 1000); - - this.state.tasksBrowser = { - component, - savedChildren, - filter, - selectedTaskId, - tailOutput: undefined, - tailLoading: false, - tailRequestId: 0, - flashMessage: undefined, - flashTimer: undefined, - pollTimer, - viewer: undefined, - }; - - if (selectedTaskId !== undefined) { - this.loadTasksBrowserTail(selectedTaskId); - } - } - - private pickInitialSelection( - tasks: readonly BackgroundTaskInfo[], - filter: TasksFilter, - ): string | undefined { - const candidates = - filter === 'all' - ? tasks - : tasks.filter( - (t) => - t.status !== 'completed' && - t.status !== 'failed' && - t.status !== 'killed' && - t.status !== 'lost', - ); - if (candidates.length === 0) return undefined; - // Prefer the first non-terminal task; fall back to the first one. - return ( - candidates.find( - (t) => t.status === 'running' || t.status === 'awaiting_approval', - )?.taskId ?? candidates[0]!.taskId - ); - } - - private async refreshTasksBrowser(opts: { silent?: boolean } = {}): Promise { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - const session = this.session; - if (session === undefined) return; - - let tasks: readonly BackgroundTaskInfo[]; - try { - tasks = await session.listBackgroundTasks({ activeOnly: false }); - } catch (error) { - if (!opts.silent) { - this.flashTasksBrowser( - `Refresh failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - return; - } - if (this.state.tasksBrowser !== browser) return; - this.pushTasksBrowserProps(tasks); - } - - private pushTasksBrowserProps(tasks: readonly BackgroundTaskInfo[]): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - browser.component.setProps({ - tasks, - filter: browser.filter, - selectedTaskId: browser.selectedTaskId, - tailOutput: browser.tailOutput, - tailLoading: browser.tailLoading, - flashMessage: browser.flashMessage, - colors: this.state.theme.colors, - ...this.buildTasksBrowserCallbacks(), - }); - this.state.ui.requestRender(); - } - - /** Callback bundle for `TasksBrowserComponent`. Single source of truth. */ - private buildTasksBrowserCallbacks(): { - onSelect: (taskId: string) => void; - onToggleFilter: () => void; - onRefresh: () => void; - onCancel: () => void; - onStopConfirmed: (taskId: string) => void; - onOpenOutput: (taskId: string) => void; - onStopIgnored: (taskId: string, reason: 'terminal') => void; - } { - return { - onSelect: (taskId) => { - this.handleTasksBrowserSelect(taskId); - }, - onToggleFilter: () => { - this.handleTasksBrowserToggleFilter(); - }, - onRefresh: () => { - this.handleTasksBrowserRefresh(); - }, - onCancel: () => { - this.closeTasksBrowser(); - }, - onStopConfirmed: (taskId) => { - void this.handleTasksBrowserStop(taskId); - }, - onOpenOutput: (taskId) => { - void this.handleTasksBrowserOpenOutput(taskId); - }, - onStopIgnored: (taskId, reason) => { - if (reason === 'terminal') { - this.flashTasksBrowser(`${taskId} is already terminal — nothing to stop.`); - } - }, - }; - } - - private handleTasksBrowserSelect(taskId: string): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - if (browser.selectedTaskId === taskId) return; - browser.selectedTaskId = taskId; - browser.tailOutput = undefined; - browser.tailLoading = true; - this.repaintTasksBrowser(); - this.loadTasksBrowserTail(taskId); - } - - private handleTasksBrowserToggleFilter(): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - browser.filter = browser.filter === 'all' ? 'active' : 'all'; - this.repaintTasksBrowser(); - } - - private handleTasksBrowserRefresh(): void { - this.flashTasksBrowser('Refreshing…', 600); - void this.refreshTasksBrowser(); - } - - /** - * Re-render the `/tasks` panel from the in-memory BPM store (no RPC - * fetch). Safe to call when the panel is closed (no-op). Use this - * after any local state change — selection, filter, flash message, - * or an incoming `background.task.*` event — so the UI stays in sync. - * Use `refreshTasksBrowser` instead when you also want fresh data - * from the agent (e.g. a manual `R refresh`). - */ - private repaintTasksBrowser(): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - const tasks = [...this.state.backgroundTasks.values()]; - this.pushTasksBrowserProps(tasks); - } - - private loadTasksBrowserTail(taskId: string): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - const session = this.session; - if (session === undefined) { - browser.tailLoading = false; - this.repaintTasksBrowser(); - return; - } - const requestId = ++browser.tailRequestId; - void session - .getBackgroundTaskOutput(taskId, { tail: 4000 }) - .then((output) => { - const current = this.state.tasksBrowser; - if (current === undefined) return; - if (current !== browser || current.tailRequestId !== requestId) return; - if (current.selectedTaskId !== taskId) return; - current.tailOutput = output; - current.tailLoading = false; - this.repaintTasksBrowser(); - }) - .catch(() => { - const current = this.state.tasksBrowser; - if (current === undefined) return; - if (current !== browser || current.tailRequestId !== requestId) return; - if (current.selectedTaskId !== taskId) return; - current.tailOutput = ''; - current.tailLoading = false; - this.repaintTasksBrowser(); - }); - } - - private flashTasksBrowser(message: string, durationMs = 2500): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); - browser.flashMessage = message; - browser.flashTimer = setTimeout(() => { - const current = this.state.tasksBrowser; - if (current !== browser) return; - current.flashMessage = undefined; - current.flashTimer = undefined; - this.repaintTasksBrowser(); - }, durationMs); - this.repaintTasksBrowser(); - } - - private async handleTasksBrowserStop(taskId: string): Promise { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - const session = this.session; - if (session === undefined) { - this.flashTasksBrowser('No active session.'); - return; - } - this.flashTasksBrowser(`Stopping ${taskId}…`, 1500); - try { - await session.stopBackgroundTask(taskId, { reason: 'stopped from /tasks' }); - // Force a refresh so the row flips to `killed` immediately. The - // `background.task.terminated` event will repaint again shortly. - await this.refreshTasksBrowser({ silent: true }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.flashTasksBrowser(`Stop failed: ${message}`); - } - } - - private async handleTasksBrowserOpenOutput(taskId: string): Promise { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - if (browser.viewer !== undefined) return; // already viewing - const session = this.session; - if (session === undefined) { - this.flashTasksBrowser('No active session.'); - return; - } - - let output: string; - try { - output = await session.getBackgroundTaskOutput(taskId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.flashTasksBrowser(`Cannot open output: ${message}`); - return; - } - // Race: panel might have been closed while the await was in flight. - const current = this.state.tasksBrowser; - if (current === undefined || current !== browser) return; - - const info = this.state.backgroundTasks.get(taskId); - const viewer = new TaskOutputViewer( - { - taskId, - info, - output, - colors: this.state.theme.colors, - onClose: () => { - this.closeTaskOutputViewer(); - }, - }, - this.state.terminal, - ); - - // Nested takeover: save the TasksBrowser layer (which itself is a - // single-child swap of the main TUI), then put the viewer in its - // place. `closeTaskOutputViewer` reverses this without touching the - // outer "main TUI ↔ TasksBrowser" swap state. - const savedBrowserChildren = [...this.state.ui.children]; - this.state.ui.clear(); - this.state.ui.addChild(viewer); - this.state.ui.setFocus(viewer); - this.state.ui.requestRender(true); - - // Live-tail: keep re-fetching the output every second so the viewer - // shows new content as the task writes it. The viewer itself decides - // whether to follow the tail or preserve scroll position. - const pollTimer = setInterval(() => { - void this.refreshTaskOutputViewer({ silent: true }); - }, 1000); - - browser.viewer = { - component: viewer, - savedChildren: savedBrowserChildren, - taskId, - output, - refreshId: 0, - pollTimer, - }; - } - - /** - * Re-fetch the current viewer task's output and push it into the - * component. Safe to call when the viewer is closed (no-op). Stale - * responses (issued before a more recent call) are discarded via the - * monotonically-increasing `refreshId`. - */ - private async refreshTaskOutputViewer(opts: { silent?: boolean } = {}): Promise { - const browser = this.state.tasksBrowser; - const viewer = browser?.viewer; - if (browser === undefined || viewer === undefined) return; - const session = this.session; - if (session === undefined) return; - - const myRefreshId = ++viewer.refreshId; - let output: string; - try { - output = await session.getBackgroundTaskOutput(viewer.taskId); - } catch (error) { - if (!opts.silent) { - const message = error instanceof Error ? error.message : String(error); - this.flashTasksBrowser(`Output refresh failed: ${message}`); - } - return; - } - // If the viewer was closed or another refresh raced ahead, drop this result. - const current = this.state.tasksBrowser?.viewer; - if (current === undefined || current !== viewer || current.refreshId !== myRefreshId) { - return; - } - // Skip the setProps round-trip when nothing changed — keeps the - // differential renderer from re-emitting the same frame. - if (output === viewer.output) return; - viewer.output = output; - const info = this.state.backgroundTasks.get(viewer.taskId); - viewer.component.setProps({ - taskId: viewer.taskId, - info, - output, - colors: this.state.theme.colors, - onClose: () => { - this.closeTaskOutputViewer(); - }, - }); - this.state.ui.requestRender(); - } - - private closeTaskOutputViewer(): void { - const browser = this.state.tasksBrowser; - if (browser === undefined || browser.viewer === undefined) return; - const viewer = browser.viewer; - clearInterval(viewer.pollTimer); - browser.viewer = undefined; - this.state.ui.clear(); - for (const child of viewer.savedChildren) { - this.state.ui.addChild(child); - } - this.state.ui.setFocus(browser.component); - this.state.ui.requestRender(true); - } - - private closeTasksBrowser(): void { - const browser = this.state.tasksBrowser; - if (browser === undefined) return; - // If the output viewer is open, fold it back before tearing down - // the browser so the saved-children stack stays consistent. - if (browser.viewer !== undefined) this.closeTaskOutputViewer(); - if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); - if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); - - // Restore the main TUI's children we saved when opening. After - // clearing, re-add in original order, then return focus to the - // editor so the user is back at the prompt. - this.state.ui.clear(); - for (const child of browser.savedChildren) { - this.state.ui.addChild(child); - } - this.state.tasksBrowser = undefined; - this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(true); + private showTasksBrowser(): Promise { return this.tasksBrowserController.show(); } + private closeTasksBrowser(): void { this.tasksBrowserController.close(); } + private repaintTasksBrowser(): void { this.tasksBrowserController.repaint(); } + private refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise { + return this.tasksBrowserController.refreshOutputViewer(opts); } // Shows the editor command selector. From 1462c33b8457184c9c98d7e7917e30ba166ddefe Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 14:32:07 +0800 Subject: [PATCH 02/28] refactor(tui): extract StreamingUIController from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Streaming Flush(15 个方法)和 Live Render Hooks(15 个方法) 共 ~475 行代码从 KimiTUI 提取到 StreamingUIController。这两个区域 之间紧密耦合(flush 方法直接调用 render hooks),合并提取避免循环依赖。 同时迁移了 5 个实例变量:flushTimer, lastFlushAt, pendingAssistantFlush, pendingThinkingFlush, pendingToolCallFlushIds。 kimi-tui.ts: 5711 → 5268 行 (-443) --- .../src/tui/controllers/streaming-ui.ts | 527 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 527 ++---------------- 2 files changed, 569 insertions(+), 485 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/streaming-ui.ts diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts new file mode 100644 index 0000000000..dd333a813b --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -0,0 +1,527 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AgentGroupComponent } from '../components/messages/agent-group'; +import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { CompactionComponent } from '../components/dialogs/compaction'; +import { ReadGroupComponent } from '../components/messages/read-group'; +import { ThinkingComponent } from '../components/messages/thinking'; +import { ToolCallComponent } from '../components/messages/tool-call'; +import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; +import { parseStreamingArgs } from '../utils/event-payload'; +import { notifyTerminalOnce } from '../utils/terminal-notification'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { TodoItem } from '../components/chrome/todo-panel'; +import type { + AppState, + LivePaneState, + QueuedMessage, + ToolCallBlockData, + ToolResultBlockData, +} from '../types'; +import type { TUIState } from '../kimi-tui'; + +export interface StreamingUIHost { + state: TUIState; + session: Session | undefined; + setAppState(patch: Partial): void; + patchLivePane(patch: Partial): void; + resetLivePane(): void; + updateActivityPane(): void; + updateQueueDisplay(): void; + disposeActiveThinkingComponent(): void; + disposeAndClearPendingToolComponents(): void; + requireSession(): Session; + deferUserMessages: boolean; +} + +export class StreamingUIController { + private flushTimer: ReturnType | undefined; + private lastFlushAt: number | undefined; + private pendingAssistantFlush = false; + private pendingThinkingFlush = false; + readonly pendingToolCallFlushIds = new Set(); + + constructor(private readonly host: StreamingUIHost) {} + + // --------------------------------------------------------------------------- + // Flush control + // --------------------------------------------------------------------------- + + hasPending(): boolean { + return ( + this.pendingAssistantFlush || + this.pendingThinkingFlush || + this.pendingToolCallFlushIds.size > 0 + ); + } + + clearFlushTimer(): void { + if (this.flushTimer === undefined) return; + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + + private clearFlushTimerIfIdle(): void { + if (this.hasPending()) return; + this.clearFlushTimer(); + } + + discardPending(): void { + this.clearFlushTimer(); + this.pendingAssistantFlush = false; + this.pendingThinkingFlush = false; + this.pendingToolCallFlushIds.clear(); + } + + scheduleFlush(): void { + if (!this.hasPending()) return; + if (this.flushTimer !== undefined) return; + const delay = + this.lastFlushAt === undefined + ? 0 + : Math.max(0, STREAMING_UI_FLUSH_MS - (Date.now() - this.lastFlushAt)); + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.flush(); + }, delay); + } + + flushNow(): void { + this.clearFlushTimer(); + this.flush(); + } + + private flush(): void { + if (!this.hasPending()) return; + this.lastFlushAt = Date.now(); + const shouldFlushThinking = this.pendingThinkingFlush; + const shouldFlushAssistant = this.pendingAssistantFlush; + const toolCallIds = [...this.pendingToolCallFlushIds]; + this.pendingThinkingFlush = false; + this.pendingAssistantFlush = false; + this.pendingToolCallFlushIds.clear(); + + if (shouldFlushThinking && this.host.state.thinkingDraft.length > 0) { + this.onThinkingUpdate(this.host.state.thinkingDraft); + } + if (shouldFlushAssistant) { + this.onStreamingTextUpdate(this.host.state.assistantDraft); + } + for (const id of toolCallIds) { + this.flushToolCallPreview(id); + } + } + + markAssistantDirty(): void { + this.pendingAssistantFlush = true; + } + + markThinkingDirty(): void { + this.pendingThinkingFlush = true; + } + + // --------------------------------------------------------------------------- + // Text streaming + // --------------------------------------------------------------------------- + + flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { + this.flushNow(); + if (this.host.state.thinkingDraft.length === 0) { + this.host.patchLivePane({ mode: nextMode }); + return; + } + this.host.state.thinkingDraft = ''; + this.onThinkingEnd(); + this.host.patchLivePane({ mode: nextMode }); + } + + finalizeAssistantStream(): void { + this.flushNow(); + if (this.host.state.assistantStreamActive) { + this.onStreamingTextEnd(); + this.host.state.assistantStreamActive = false; + } + this.host.state.assistantDraft = ''; + this.host.updateActivityPane(); + this.host.state.ui.requestRender(); + } + + resetLiveText(): void { + this.pendingAssistantFlush = false; + this.pendingThinkingFlush = false; + this.clearFlushTimerIfIdle(); + this.host.state.assistantDraft = ''; + this.host.state.assistantStreamActive = false; + this.host.state.streamingComponent = undefined; + this.host.state.streamingTranscriptEntry = undefined; + this.host.state.thinkingDraft = ''; + this.host.disposeActiveThinkingComponent(); + } + + resetToolUi(): void { + this.pendingToolCallFlushIds.clear(); + this.clearFlushTimerIfIdle(); + this.host.state.streamingToolCallArguments.clear(); + this.host.disposeAndClearPendingToolComponents(); + this.host.state.pendingAgentGroup = null; + this.host.state.pendingReadGroup = null; + } + + resetToolCallState(): void { + this.host.state.activeToolCalls.clear(); + } + + finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { + this.flushThinkingToTranscript(nextMode); + this.finalizeAssistantStream(); + } + + finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { + const { state } = this.host; + if (!state.appState.isStreaming) return; + this.host.deferUserMessages = false; + const completedTurnKey = + state.currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; + this.finalizeLiveTextBuffers('idle'); + this.resetToolCallState(); + state.currentTurnId = undefined; + + if (state.queuedMessages.length > 0) { + const [next, ...rest] = state.queuedMessages; + state.queuedMessages = rest; + this.host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.host.resetLivePane(); + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); + } + return; + } + + this.host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.host.resetLivePane(); + notifyTerminalOnce(state, `turn-complete:${completedTurnKey}`, { + title: 'Kimi Code task complete', + body: state.appState.sessionTitle ?? undefined, + }); + } + + // --------------------------------------------------------------------------- + // Live Render Hooks + // --------------------------------------------------------------------------- + + onStreamingTextStart(): void { + const { state } = this.host; + state.pendingAgentGroup = null; + state.pendingReadGroup = null; + const entry = { + id: nextTranscriptId(), + kind: 'assistant' as const, + turnId: state.currentTurnId, + renderMode: 'markdown' as const, + content: '', + }; + state.streamingComponent = new AssistantMessageComponent( + state.theme.markdownTheme, + state.theme.colors, + ); + state.streamingTranscriptEntry = entry; + state.transcriptEntries.push(entry); + state.transcriptContainer.addChild(state.streamingComponent); + state.ui.requestRender(); + } + + onStreamingTextUpdate(fullText: string): void { + const { state } = this.host; + if (state.streamingTranscriptEntry !== undefined) { + state.streamingTranscriptEntry.content = fullText; + } + if (state.streamingComponent) { + state.streamingComponent.updateContent(fullText); + state.ui.requestRender(); + } + } + + onStreamingTextEnd(): void { + this.host.state.streamingComponent = undefined; + this.host.state.streamingTranscriptEntry = undefined; + } + + onThinkingUpdate(fullText: string): void { + const { state } = this.host; + if (state.activeThinkingComponent === undefined) { + state.pendingAgentGroup = null; + state.pendingReadGroup = null; + state.activeThinkingComponent = new ThinkingComponent( + fullText, + state.theme.colors, + true, + 'live', + state.ui, + ); + if (state.toolOutputExpanded) state.activeThinkingComponent.setExpanded(true); + state.transcriptContainer.addChild(state.activeThinkingComponent); + } else { + state.activeThinkingComponent.setText(fullText); + } + state.ui.requestRender(); + } + + onThinkingEnd(): void { + const { state } = this.host; + if (state.activeThinkingComponent === undefined) return; + state.activeThinkingComponent.finalize(); + state.activeThinkingComponent = undefined; + state.ui.requestRender(); + } + + onToolCallStart(toolCall: ToolCallBlockData): void { + if (toolCall.name === 'AskUserQuestion') return; + + const { state } = this.host; + const tc = new ToolCallComponent( + toolCall, + undefined, + state.theme.colors, + state.ui, + state.theme.markdownTheme, + state.appState.workDir, + ); + if (state.toolOutputExpanded) tc.setExpanded(true); + if (state.planExpanded) tc.setPlanExpanded(true); + state.pendingToolComponents.set(toolCall.id, tc); + + if (toolCall.name !== 'Agent') state.pendingAgentGroup = null; + if (toolCall.name !== 'Read') state.pendingReadGroup = null; + + let handled = this.tryAttachAgentToolCall(toolCall, tc); + if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); + if (!handled) { + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + } + + if (toolCall.name === 'ExitPlanMode' && typeof toolCall.args['plan'] !== 'string') { + const session = this.host.requireSession(); + void (async () => { + try { + const plan = await session.getPlan(); + tc.setPlanInfo(plan === null ? {} : { plan: plan.content, path: plan.path }); + } catch { + tc.setPlanInfo({}); + } + })(); + } + } + + onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { + const { state } = this.host; + const matchedCall = state.activeToolCalls.get(toolCallId); + const tc = state.pendingToolComponents.get(toolCallId); + if (tc) { + tc.setResult(result); + state.pendingToolComponents.delete(toolCallId); + state.ui.requestRender(); + return; + } + + if (matchedCall?.name === 'AskUserQuestion') { + const completed = new ToolCallComponent( + matchedCall, + result, + state.theme.colors, + state.ui, + state.theme.markdownTheme, + state.appState.workDir, + ); + if (state.toolOutputExpanded) completed.setExpanded(true); + if (state.planExpanded) completed.setPlanExpanded(true); + state.transcriptContainer.addChild(completed); + state.ui.requestRender(); + } + } + + setTodoList(todos: readonly TodoItem[]): void { + const { state } = this.host; + state.todoPanel.setTodos(todos); + state.todoPanelContainer.clear(); + if (!state.todoPanel.isEmpty()) { + state.todoPanelContainer.addChild(state.todoPanel); + } + state.ui.requestRender(); + } + + beginCompaction(instruction?: string): void { + const { state } = this.host; + if (state.activeCompactionBlock !== undefined) { + state.activeCompactionBlock.markDone(); + state.activeCompactionBlock = undefined; + } + const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + state.activeCompactionBlock = block; + state.transcriptContainer.addChild(block); + state.ui.requestRender(); + } + + endCompaction(tokensBefore?: number, tokensAfter?: number): void { + const block = this.host.state.activeCompactionBlock; + if (block === undefined) return; + block.markDone(tokensBefore, tokensAfter); + this.host.state.activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + cancelCompaction(): void { + const block = this.host.state.activeCompactionBlock; + if (block === undefined) return; + block.markCanceled(); + this.host.state.activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Tool call grouping + // --------------------------------------------------------------------------- + + private flushToolCallPreview(id: string): void { + const { state } = this.host; + const streaming = state.streamingToolCallArguments.get(id); + if (streaming === undefined) return; + const toolCall: ToolCallBlockData = { + id, + name: streaming.name ?? state.activeToolCalls.get(id)?.name ?? 'Tool', + args: parseStreamingArgs(streaming.argumentsText), + streamingArguments: streaming.argumentsText, + streamingStartedAtMs: streaming.startedAtMs, + step: state.currentStep, + turnId: state.currentTurnId, + }; + state.activeToolCalls.set(id, toolCall); + + if (state.thinkingDraft.length > 0 || state.assistantStreamActive) { + this.finalizeLiveTextBuffers('tool'); + } + + const existingComponent = state.pendingToolComponents.get(id); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (toolCall.name !== 'Agent') { + this.onToolCallStart(toolCall); + } + } + + private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + const { state } = this.host; + if (toolCall.name !== 'Agent') { + state.pendingAgentGroup = null; + return false; + } + + const step = toolCall.step ?? state.currentStep; + const turnId = toolCall.turnId ?? state.currentTurnId; + const pending = state.pendingAgentGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + state.pendingAgentGroup = null; + } + + const cur = state.pendingAgentGroup; + if (cur === null) { + state.pendingAgentGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + state.pendingAgentGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloAgentToGroup(solo); + group.attach(toolCall.id, tc); + state.pendingAgentGroup = { step, turnId, group }; + state.ui.requestRender(); + return true; + } + + private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { + const { state } = this.host; + const group = new AgentGroupComponent(state.theme.colors, state.ui); + const children = state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + children[idx] = group; + state.transcriptContainer.invalidate(); + } else { + state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } + + private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { + const { state } = this.host; + if (toolCall.name !== 'Read') { + state.pendingReadGroup = null; + return false; + } + + const step = toolCall.step ?? state.currentStep; + const turnId = toolCall.turnId ?? state.currentTurnId; + const pending = state.pendingReadGroup; + + if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { + state.pendingReadGroup = null; + } + + const cur = state.pendingReadGroup; + if (cur === null) { + state.pendingReadGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + + if (cur.group !== undefined) { + cur.group.attach(toolCall.id, tc); + return true; + } + + const solo = cur.solo; + if (solo === undefined) { + state.pendingReadGroup = { step, turnId, solo: tc }; + state.transcriptContainer.addChild(tc); + state.ui.requestRender(); + return true; + } + const group = this.upgradeSoloReadToGroup(solo); + group.attach(toolCall.id, tc); + state.pendingReadGroup = { step, turnId, group }; + state.ui.requestRender(); + return true; + } + + private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { + const { state } = this.host; + const group = new ReadGroupComponent(state.theme.colors, state.ui); + const children = state.transcriptContainer.children; + const idx = children.indexOf(solo); + if (idx >= 0) { + children[idx] = group; + state.transcriptContainer.invalidate(); + } else { + state.transcriptContainer.addChild(group); + } + group.attach(solo.toolCallView.id, solo); + return group; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9e6382941f..eb33d8f119 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -147,6 +147,7 @@ import { PlatformSelectorComponent } from './components/dialogs/platform-selecto import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; import { SettingsSelectorComponent, @@ -563,7 +564,7 @@ export class KimiTUI { private pendingExit: PendingExit | null = null; private cancelInFlight: (() => void) | undefined; // Queues editor messages instead of sending or steering them. Used by /init. - private deferUserMessages = false; + deferUserMessages = false; private aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; @@ -578,13 +579,7 @@ export class KimiTUI { private readonly migrationPlan: MigrationPlan | null; // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; - // High-frequency model/tool deltas update draft state immediately, then use - // these flags to coalesce expensive component rebuilds into periodic flushes. - private streamingUiFlushTimer: ReturnType | undefined; - private lastStreamingUiFlushAt: number | undefined; - private pendingAssistantFlush = false; - private pendingThinkingFlush = false; - private readonly pendingToolCallFlushIds = new Set(); + readonly streamingUI: StreamingUIController; private readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -634,6 +629,7 @@ export class KimiTUI { }, }), ); + this.streamingUI = new StreamingUIController(this); this.tasksBrowserController = new TasksBrowserController(this); this.setupEditorHandlers(); this.buildLayout(); @@ -1790,191 +1786,25 @@ export class KimiTUI { }); } - private hasPendingStreamingUiUpdates(): boolean { - return ( - this.pendingAssistantFlush || - this.pendingThinkingFlush || - this.pendingToolCallFlushIds.size > 0 - ); - } - - private clearStreamingUiFlushTimer(): void { - if (this.streamingUiFlushTimer === undefined) return; - clearTimeout(this.streamingUiFlushTimer); - this.streamingUiFlushTimer = undefined; - } - - private clearStreamingUiFlushTimerIfIdle(): void { - if (this.hasPendingStreamingUiUpdates()) return; - this.clearStreamingUiFlushTimer(); - } - - private discardPendingStreamingUiUpdates(): void { - this.clearStreamingUiFlushTimer(); - this.pendingAssistantFlush = false; - this.pendingThinkingFlush = false; - this.pendingToolCallFlushIds.clear(); - } - - // Schedule trailing UI work for streaming deltas. Terminal drawing is already - // coalesced by pi-tui; this avoids doing our own markdown/tool preview rebuild - // work on every chunk before pi-tui even gets a chance to render. - private scheduleStreamingUiFlush(): void { - if (!this.hasPendingStreamingUiUpdates()) return; - if (this.streamingUiFlushTimer !== undefined) return; - const delay = - this.lastStreamingUiFlushAt === undefined - ? 0 - : Math.max(0, STREAMING_UI_FLUSH_MS - (Date.now() - this.lastStreamingUiFlushAt)); - this.streamingUiFlushTimer = setTimeout(() => { - this.streamingUiFlushTimer = undefined; - this.flushStreamingUiUpdates(); - }, delay); - } - - // Final events such as tool.result or turn.ended must observe all streamed - // draft content, so they bypass the timer and drain pending UI work first. - private flushStreamingUiUpdatesNow(): void { - this.clearStreamingUiFlushTimer(); - this.flushStreamingUiUpdates(); - } - - private flushStreamingUiUpdates(): void { - if (!this.hasPendingStreamingUiUpdates()) return; - this.lastStreamingUiFlushAt = Date.now(); - const shouldFlushThinking = this.pendingThinkingFlush; - const shouldFlushAssistant = this.pendingAssistantFlush; - const toolCallIds = [...this.pendingToolCallFlushIds]; - this.pendingThinkingFlush = false; - this.pendingAssistantFlush = false; - this.pendingToolCallFlushIds.clear(); - - if (shouldFlushThinking && this.state.thinkingDraft.length > 0) { - this.onThinkingUpdate(this.state.thinkingDraft); - } - if (shouldFlushAssistant) { - this.onStreamingTextUpdate(this.state.assistantDraft); - } - for (const id of toolCallIds) { - this.flushStreamingToolCallPreview(id); - } - } - - // Materializes the latest bounded argument preview for one in-flight tool - // call. The final tool.call event still replaces this with authoritative args. - private flushStreamingToolCallPreview(id: string): void { - const streaming = this.state.streamingToolCallArguments.get(id); - if (streaming === undefined) return; - const toolCall: ToolCallBlockData = { - id, - name: streaming.name ?? this.state.activeToolCalls.get(id)?.name ?? 'Tool', - args: parseStreamingArgs(streaming.argumentsText), - streamingArguments: streaming.argumentsText, - streamingStartedAtMs: streaming.startedAtMs, - step: this.state.currentStep, - turnId: this.state.currentTurnId, - }; - this.state.activeToolCalls.set(id, toolCall); - - if (this.state.thinkingDraft.length > 0 || this.state.assistantStreamActive) { - this.finalizeLiveTextBuffers('tool'); - } - - const existingComponent = this.state.pendingToolComponents.get(id); - if (existingComponent !== undefined) { - existingComponent.updateToolCall(toolCall); - } else if (toolCall.name !== 'Agent') { - this.onToolCallStart(toolCall); - } - } + // --------------------------------------------------------------------------- + // Streaming UI — delegated to StreamingUIController + // --------------------------------------------------------------------------- - // Finalizes live thinking output and moves the live pane to the next mode. + private scheduleStreamingUiFlush(): void { this.streamingUI.scheduleFlush(); } + private flushStreamingUiUpdatesNow(): void { this.streamingUI.flushNow(); } + private discardPendingStreamingUiUpdates(): void { this.streamingUI.discardPending(); } private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { - this.flushStreamingUiUpdatesNow(); - if (this.state.thinkingDraft.length === 0) { - this.patchLivePane({ mode: nextMode }); - return; - } - this.state.thinkingDraft = ''; - this.onThinkingEnd(); - this.patchLivePane({ mode: nextMode }); + this.streamingUI.flushThinkingToTranscript(nextMode); } - - // Finalizes live assistant text and clears streaming component state. - private finalizeAssistantStream(): void { - this.flushStreamingUiUpdatesNow(); - if (this.state.assistantStreamActive) { - this.onStreamingTextEnd(); - this.state.assistantStreamActive = false; - } - this.state.assistantDraft = ''; - this.updateActivityPane(); - this.state.ui.requestRender(); - } - - // Discards live thinking and assistant text state without finalizing transcript output. - private resetLiveTextRuntime(): void { - this.pendingAssistantFlush = false; - this.pendingThinkingFlush = false; - this.clearStreamingUiFlushTimerIfIdle(); - this.state.assistantDraft = ''; - this.state.assistantStreamActive = false; - this.state.streamingComponent = undefined; - this.state.streamingTranscriptEntry = undefined; - this.state.thinkingDraft = ''; - this.disposeActiveThinkingComponent(); - } - - // Clears live tool UI state while preserving active tool-call tracking. - private resetLiveToolUiState(): void { - this.pendingToolCallFlushIds.clear(); - this.clearStreamingUiFlushTimerIfIdle(); - this.state.streamingToolCallArguments.clear(); - this.disposeAndClearPendingToolComponents(); - this.state.pendingAgentGroup = null; - this.state.pendingReadGroup = null; - } - - // Clears SDK tool-call tracking. - private resetToolCallState(): void { - this.state.activeToolCalls.clear(); - } - - // Finalizes any live thinking and assistant text for a phase transition. + private finalizeAssistantStream(): void { this.streamingUI.finalizeAssistantStream(); } + private resetLiveTextRuntime(): void { this.streamingUI.resetLiveText(); } + private resetLiveToolUiState(): void { this.streamingUI.resetToolUi(); } + private resetToolCallState(): void { this.streamingUI.resetToolCallState(); } private finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { - this.flushThinkingToTranscript(nextMode); - this.finalizeAssistantStream(); + this.streamingUI.finalizeLiveTextBuffers(nextMode); } - - // Completes a turn, dispatches queued work, and sends completion notification. private finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { - if (!this.state.appState.isStreaming) return; - this.deferUserMessages = false; - const completedTurnKey = - this.state.currentTurnId ?? `local:${String(this.state.appState.streamingStartTime)}`; - this.finalizeLiveTextBuffers('idle'); - this.resetToolCallState(); - this.state.currentTurnId = undefined; - - if (this.state.queuedMessages.length > 0) { - const [next, ...rest] = this.state.queuedMessages; - this.state.queuedMessages = rest; - this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); - this.resetLivePane(); - if (next !== undefined) { - setTimeout(() => { - sendQueued(next); - }, 0); - } - return; - } - - this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); - this.resetLivePane(); - notifyTerminalOnce(this.state, `turn-complete:${completedTurnKey}`, { - title: 'Kimi Code task complete', - body: this.state.appState.sessionTitle ?? undefined, - }); + this.streamingUI.finalizeTurn(sendQueued); } // ========================================================================= @@ -1982,7 +1812,7 @@ export class KimiTUI { // ========================================================================= // Applies app-state changes and refreshes dependent UI surfaces. - private setAppState(patch: Partial): void { + setAppState(patch: Partial): void { if (!hasPatchChanges(this.state.appState, patch)) return; const busyChanged = 'isStreaming' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); @@ -1994,7 +1824,7 @@ export class KimiTUI { } // Applies live-pane changes and refreshes activity presentation. - private patchLivePane(patch: Partial): void { + patchLivePane(patch: Partial): void { if (!hasPatchChanges(this.state.livePane, patch)) return; Object.assign(this.state.livePane, patch); this.updateActivityPane(); @@ -2002,7 +1832,7 @@ export class KimiTUI { } // Restores the live pane to its initial idle state. - private resetLivePane(): void { + resetLivePane(): void { this.state.livePane = { ...INITIAL_LIVE_PANE }; this.updateActivityPane(); this.state.ui.requestRender(); @@ -2013,7 +1843,7 @@ export class KimiTUI { // ========================================================================= // Returns the active session or raises the standard no-session error. - private requireSession(): Session { + requireSession(): Session { if (this.session === undefined) { throw new Error(NO_ACTIVE_SESSION_MESSAGE); } @@ -2486,7 +2316,7 @@ export class KimiTUI { this.state.currentTurnId = undefined; this.state.currentStep = 0; this.state.streamingToolCallArguments.clear(); - this.pendingToolCallFlushIds.clear(); + this.streamingUI.pendingToolCallFlushIds.clear(); this.state.ui.requestRender(); } @@ -3020,7 +2850,7 @@ export class KimiTUI { // Appends a thinking delta to the live thinking block. private handleThinkingDelta(event: ThinkingDeltaEvent): void { this.state.thinkingDraft += event.delta; - this.pendingThinkingFlush = true; + this.streamingUI.markThinkingDirty(); this.patchLivePane({ mode: 'idle' }); if (this.state.appState.streamingPhase !== 'thinking') { this.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); @@ -3040,7 +2870,7 @@ export class KimiTUI { } this.state.assistantDraft += event.delta; - this.pendingAssistantFlush = true; + this.streamingUI.markAssistantDirty(); this.patchLivePane({ mode: 'idle', @@ -3087,7 +2917,7 @@ export class KimiTUI { }; const existing = this.state.activeToolCalls.get(event.toolCallId); this.state.activeToolCalls.set(event.toolCallId, toolCall); - this.pendingToolCallFlushIds.delete(event.toolCallId); + this.streamingUI.pendingToolCallFlushIds.delete(event.toolCallId); this.state.streamingToolCallArguments.delete(event.toolCallId); const existingComponent = this.state.pendingToolComponents.get(event.toolCallId); if (existingComponent !== undefined) { @@ -3117,7 +2947,7 @@ export class KimiTUI { const name = event.name ?? existing?.name ?? this.state.activeToolCalls.get(id)?.name ?? 'Tool'; const startedAtMs = existing?.startedAtMs ?? Date.now(); this.state.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); - this.pendingToolCallFlushIds.add(id); + this.streamingUI.pendingToolCallFlushIds.add(id); this.patchLivePane({ mode: 'tool', @@ -3631,293 +3461,20 @@ export class KimiTUI { } // ========================================================================= - // Live Render Hooks + // Live Render Hooks — delegated to StreamingUIController // ========================================================================= - // Creates the live assistant transcript component. - private onStreamingTextStart(): void { - this.state.pendingAgentGroup = null; - this.state.pendingReadGroup = null; - const entry = { - id: nextTranscriptId(), - kind: 'assistant' as const, - turnId: this.state.currentTurnId, - renderMode: 'markdown' as const, - content: '', - }; - this.state.streamingComponent = new AssistantMessageComponent( - this.state.theme.markdownTheme, - this.state.theme.colors, - ); - this.state.streamingTranscriptEntry = entry; - this.state.transcriptEntries.push(entry); - this.state.transcriptContainer.addChild(this.state.streamingComponent); - this.state.ui.requestRender(); - } - - // Updates the live assistant transcript component. - private onStreamingTextUpdate(fullText: string): void { - if (this.state.streamingTranscriptEntry !== undefined) { - this.state.streamingTranscriptEntry.content = fullText; - } - if (this.state.streamingComponent) { - this.state.streamingComponent.updateContent(fullText); - this.state.ui.requestRender(); - } - } - - // Clears live assistant component references after streaming ends. - private onStreamingTextEnd(): void { - this.state.streamingComponent = undefined; - this.state.streamingTranscriptEntry = undefined; - } - - // Creates or updates the live thinking transcript component. - private onThinkingUpdate(fullText: string): void { - if (this.state.activeThinkingComponent === undefined) { - this.state.pendingAgentGroup = null; - this.state.pendingReadGroup = null; - this.state.activeThinkingComponent = new ThinkingComponent( - fullText, - this.state.theme.colors, - true, - 'live', - this.state.ui, - ); - if (this.state.toolOutputExpanded) this.state.activeThinkingComponent.setExpanded(true); - this.state.transcriptContainer.addChild(this.state.activeThinkingComponent); - } else { - this.state.activeThinkingComponent.setText(fullText); - } - this.state.ui.requestRender(); - } - - // Finalizes the live thinking transcript component. - private onThinkingEnd(): void { - if (this.state.activeThinkingComponent === undefined) return; - this.state.activeThinkingComponent.finalize(); - this.state.activeThinkingComponent = undefined; - this.state.ui.requestRender(); - } - - // Creates and mounts a live tool-call component. - private onToolCallStart(toolCall: ToolCallBlockData): void { - if (toolCall.name === 'AskUserQuestion') return; - - const tc = new ToolCallComponent( - toolCall, - undefined, - this.state.theme.colors, - this.state.ui, - this.state.theme.markdownTheme, - this.state.appState.workDir, - ); - if (this.state.toolOutputExpanded) tc.setExpanded(true); - if (this.state.planExpanded) tc.setPlanExpanded(true); - this.state.pendingToolComponents.set(toolCall.id, tc); - - if (toolCall.name !== 'Agent') this.state.pendingAgentGroup = null; - if (toolCall.name !== 'Read') this.state.pendingReadGroup = null; - - let handled = this.tryAttachAgentToolCall(toolCall, tc); - if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); - if (!handled) { - this.state.transcriptContainer.addChild(tc); - this.state.ui.requestRender(); - } - - if (toolCall.name === 'ExitPlanMode' && typeof toolCall.args['plan'] !== 'string') { - const session = this.requireSession(); - void (async () => { - try { - const plan = await session.getPlan(); - tc.setPlanInfo(plan === null ? {} : { plan: plan.content, path: plan.path }); - } catch { - tc.setPlanInfo({}); - } - })(); - } - } - - // Applies a tool result to a live or completed tool-call component. - private onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { - const matchedCall = this.state.activeToolCalls.get(toolCallId); - const tc = this.state.pendingToolComponents.get(toolCallId); - if (tc) { - tc.setResult(result); - this.state.pendingToolComponents.delete(toolCallId); - this.state.ui.requestRender(); - return; - } - - if (matchedCall?.name === 'AskUserQuestion') { - const completed = new ToolCallComponent( - matchedCall, - result, - this.state.theme.colors, - this.state.ui, - this.state.theme.markdownTheme, - this.state.appState.workDir, - ); - if (this.state.toolOutputExpanded) completed.setExpanded(true); - if (this.state.planExpanded) completed.setPlanExpanded(true); - this.state.transcriptContainer.addChild(completed); - this.state.ui.requestRender(); - } - } - - // Replaces the visible todo list panel. - private setTodoList(todos: readonly TodoItem[]): void { - this.state.todoPanel.setTodos(todos); - this.state.todoPanelContainer.clear(); - if (!this.state.todoPanel.isEmpty()) { - this.state.todoPanelContainer.addChild(this.state.todoPanel); - } - this.state.ui.requestRender(); - } - - // Renders a compaction block in the transcript. - private beginCompaction(instruction?: string): void { - if (this.state.activeCompactionBlock !== undefined) { - this.state.activeCompactionBlock.markDone(); - this.state.activeCompactionBlock = undefined; - } - const block = new CompactionComponent(this.state.theme.colors, this.state.ui, instruction); - this.state.activeCompactionBlock = block; - this.state.transcriptContainer.addChild(block); - this.state.ui.requestRender(); - } - - // Marks the active compaction block complete. - private endCompaction(tokensBefore?: number, tokensAfter?: number): void { - const block = this.state.activeCompactionBlock; - if (block === undefined) return; - block.markDone(tokensBefore, tokensAfter); - this.state.activeCompactionBlock = undefined; - this.state.ui.requestRender(); - } - - private cancelCompactionBlock(): void { - const block = this.state.activeCompactionBlock; - if (block === undefined) return; - block.markCanceled(); - this.state.activeCompactionBlock = undefined; - this.state.ui.requestRender(); - } - - // Groups Agent tool calls that belong to the same turn step. - private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { - if (toolCall.name !== 'Agent') { - this.state.pendingAgentGroup = null; - return false; - } - - const step = toolCall.step ?? this.state.currentStep; - const turnId = toolCall.turnId ?? this.state.currentTurnId; - const pending = this.state.pendingAgentGroup; - - if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - this.state.pendingAgentGroup = null; - } - - const cur = this.state.pendingAgentGroup; - if (cur === null) { - this.state.pendingAgentGroup = { step, turnId, solo: tc }; - this.state.transcriptContainer.addChild(tc); - this.state.ui.requestRender(); - return true; - } - - if (cur.group !== undefined) { - cur.group.attach(toolCall.id, tc); - return true; - } - - const solo = cur.solo; - if (solo === undefined) { - this.state.pendingAgentGroup = { step, turnId, solo: tc }; - this.state.transcriptContainer.addChild(tc); - this.state.ui.requestRender(); - return true; - } - const group = this.upgradeSoloAgentToGroup(solo); - group.attach(toolCall.id, tc); - this.state.pendingAgentGroup = { step, turnId, group }; - this.state.ui.requestRender(); - return true; - } - - // Replaces a single Agent tool call with an Agent group component. - private upgradeSoloAgentToGroup(solo: ToolCallComponent): AgentGroupComponent { - const group = new AgentGroupComponent(this.state.theme.colors, this.state.ui); - const children = this.state.transcriptContainer.children; - const idx = children.indexOf(solo); - if (idx >= 0) { - children[idx] = group; - this.state.transcriptContainer.invalidate(); - } else { - this.state.transcriptContainer.addChild(group); - } - group.attach(solo.toolCallView.id, solo); - return group; - } - - // Groups Read tool calls that belong to the same turn step. - private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { - if (toolCall.name !== 'Read') { - this.state.pendingReadGroup = null; - return false; - } - - const step = toolCall.step ?? this.state.currentStep; - const turnId = toolCall.turnId ?? this.state.currentTurnId; - const pending = this.state.pendingReadGroup; - - if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - this.state.pendingReadGroup = null; - } - - const cur = this.state.pendingReadGroup; - if (cur === null) { - this.state.pendingReadGroup = { step, turnId, solo: tc }; - this.state.transcriptContainer.addChild(tc); - this.state.ui.requestRender(); - return true; - } - - if (cur.group !== undefined) { - cur.group.attach(toolCall.id, tc); - return true; - } - - const solo = cur.solo; - if (solo === undefined) { - this.state.pendingReadGroup = { step, turnId, solo: tc }; - this.state.transcriptContainer.addChild(tc); - this.state.ui.requestRender(); - return true; - } - const group = this.upgradeSoloReadToGroup(solo); - group.attach(toolCall.id, tc); - this.state.pendingReadGroup = { step, turnId, group }; - this.state.ui.requestRender(); - return true; - } - - // Replaces a single Read tool call with a Read group component. - private upgradeSoloReadToGroup(solo: ToolCallComponent): ReadGroupComponent { - const group = new ReadGroupComponent(this.state.theme.colors, this.state.ui); - const children = this.state.transcriptContainer.children; - const idx = children.indexOf(solo); - if (idx >= 0) { - children[idx] = group; - this.state.transcriptContainer.invalidate(); - } else { - this.state.transcriptContainer.addChild(group); - } - group.attach(solo.toolCallView.id, solo); - return group; - } + private onStreamingTextStart(): void { this.streamingUI.onStreamingTextStart(); } + private onStreamingTextUpdate(t: string): void { this.streamingUI.onStreamingTextUpdate(t); } + private onStreamingTextEnd(): void { this.streamingUI.onStreamingTextEnd(); } + private onThinkingUpdate(t: string): void { this.streamingUI.onThinkingUpdate(t); } + private onThinkingEnd(): void { this.streamingUI.onThinkingEnd(); } + private onToolCallStart(tc: ToolCallBlockData): void { this.streamingUI.onToolCallStart(tc); } + private onToolCallEnd(id: string, r: ToolResultBlockData): void { this.streamingUI.onToolCallEnd(id, r); } + private setTodoList(todos: readonly TodoItem[]): void { this.streamingUI.setTodoList(todos); } + private beginCompaction(instruction?: string): void { this.streamingUI.beginCompaction(instruction); } + private endCompaction(before?: number, after?: number): void { this.streamingUI.endCompaction(before, after); } + private cancelCompactionBlock(): void { this.streamingUI.cancelCompaction(); } // ========================================================================= // Transcript Rendering @@ -4054,7 +3611,7 @@ export class KimiTUI { } // Disposes the active thinking component if one is mounted. - private disposeActiveThinkingComponent(): void { + disposeActiveThinkingComponent(): void { if (this.state.activeThinkingComponent !== undefined) { this.state.activeThinkingComponent.dispose(); this.state.activeThinkingComponent = undefined; @@ -4062,7 +3619,7 @@ export class KimiTUI { } // Disposes and forgets all pending live tool-call components. - private disposeAndClearPendingToolComponents(): void { + disposeAndClearPendingToolComponents(): void { for (const component of this.state.pendingToolComponents.values()) { if (hasDispose(component)) component.dispose(); } @@ -4150,7 +3707,7 @@ export class KimiTUI { // ========================================================================= // Rebuilds the activity pane for the current live and streaming state. - private updateActivityPane(): void { + updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); @@ -4232,7 +3789,7 @@ export class KimiTUI { } // Re-renders the queued-message pane. - private updateQueueDisplay(): void { + updateQueueDisplay(): void { this.state.queueContainer.clear(); const queued = this.state.queuedMessages; if (queued.length === 0) return; From 66a48fc90cc58ffc3c0a8821da69a51e52631d7f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 14:44:07 +0800 Subject: [PATCH 03/28] refactor(tui): extract SessionEventHandler from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Session Events(39 个方法)和 Background Task Lifecycle(4 个方法) 共 ~960 行代码从 KimiTUI 提取到 SessionEventHandler。包含事件分发、 turn/step 生命周期、thinking/assistant delta 处理、tool call 管理、 compaction 流程、subagent 管理、MCP 状态渲染、background task 事件。 kimi-tui.ts: 5268 → 4316 行 (-952) --- .../tui/controllers/session-event-handler.ts | 957 +++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 982 +----------------- 2 files changed, 972 insertions(+), 967 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/session-event-handler.ts diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts new file mode 100644 index 0000000000..9498739af4 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -0,0 +1,957 @@ +import chalk from 'chalk'; +import type { + AgentStatusUpdatedEvent, + AssistantDeltaEvent, + BackgroundTaskInfo, + BackgroundTaskStartedEvent, + BackgroundTaskTerminatedEvent, + BackgroundTaskUpdatedEvent, + CompactionCancelledEvent, + CompactionCompletedEvent, + CompactionStartedEvent, + ErrorEvent, + Event, + HookResultEvent, + Session, + SessionMetaUpdatedEvent, + SkillActivatedEvent, + SubagentCompletedEvent, + SubagentFailedEvent, + SubagentSpawnedEvent, + ThinkingDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolProgressEvent, + ToolResultEvent, + TurnEndedEvent, + TurnStartedEvent, + TurnStepCompletedEvent, + TurnStepInterruptedEvent, + TurnStepStartedEvent, + WarningEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { MoonLoader } from '../components/chrome/moon-loader'; +import { StatusMessageComponent } from '../components/messages/status-message'; +import { + MAIN_AGENT_ID, + OAUTH_LOGIN_REQUIRED_CODE, + OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, +} from '../constant/kimi-tui'; +import { + appendStreamingArgsPreview, + argsRecord, + isTodoItemShape, + serializeToolResultOutput, + stringValue, +} from '../utils/event-payload'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { formatHookResultMarkdown, formatHookResultPlain } from '../utils/hook-result-format'; +import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; +import { + formatMcpStartupStatusSummary, + mcpServerStatusKey, + type McpServerStatusSnapshot, + selectMcpStartupStatusRows, +} from '../utils/mcp-server-status'; +import { openUrl } from '../utils/open-url'; +import { setProcessTitle } from '../utils/proctitle'; +import { errorReportHintLine } from '../constant/feedback'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { StreamingUIController } from './streaming-ui'; +import type { + AppState, + BackgroundAgentMetadata, + LivePaneState, + QueuedMessage, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import type { TUIState } from '../kimi-tui'; + +export interface SessionEventHost { + state: TUIState; + session: Session | undefined; + aborted: boolean; + sessionEventUnsubscribe: (() => void) | undefined; + readonly streamingUI: StreamingUIController; + + requireSession(): Session; + setAppState(patch: Partial): void; + patchLivePane(patch: Partial): void; + resetLivePane(): void; + showError(msg: string): void; + showStatus(msg: string, color?: string): void; + showNotice(title: string, detail?: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; + sendQueuedMessage(session: Session, item: QueuedMessage): void; + repaintTasksBrowser(): void; + refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise; +} + +export class SessionEventHandler { + constructor(private readonly host: SessionEventHost) {} + + startSubscription(): void { + const { host } = this; + const session = host.requireSession(); + const sendQueued = (item: QueuedMessage): void => { + host.sendQueuedMessage(session, item); + }; + host.sessionEventUnsubscribe?.(); + const mcpOAuthOpener = new McpOAuthAuthorizationUrlOpener(openUrl); + const { sessionId } = host.state.appState; + host.sessionEventUnsubscribe = session.onEvent((event) => { + if (host.aborted) return; + if (event.sessionId !== sessionId) return; + if (event.type === 'tool.progress') { + mcpOAuthOpener.handleToolProgress(event); + } + this.handleEvent(event, sendQueued); + }); + void this.syncMcpServerStatusSnapshot(session); + } + + async syncMcpServerStatusSnapshot(session: Session): Promise { + const { host } = this; + let servers: readonly McpServerStatusSnapshot[]; + try { + servers = await session.listMcpServers(); + } catch (error) { + if (host.session !== session || host.aborted) return; + const message = error instanceof Error ? error.message : String(error); + host.showError(`Failed to sync MCP server status: ${message}`); + return; + } + if (host.session !== session || host.state.appState.sessionId !== session.id) return; + + const visible = selectMcpStartupStatusRows(servers); + const visibleNames = new Set(visible.map((server) => server.name)); + for (const server of visible) { + if (host.state.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderMcpServerStatus(server); + } + + const hidden: McpServerStatusSnapshot[] = []; + for (const server of servers) { + if (visibleNames.has(server.name)) continue; + if (host.state.renderedMcpServerStatusKeys.has(server.name)) continue; + host.state.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); + hidden.push(server); + } + if (hidden.length > 0) { + host.showStatus( + formatMcpStartupStatusSummary(hidden, visible.length), + host.state.theme.colors.textMuted, + ); + } + } + + handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { + if (this.routeSubagentEvent(event)) return; + + if ('turnId' in event && event.turnId !== undefined) { + this.host.state.currentTurnId = String(event.turnId); + } + + switch (event.type) { + case 'turn.started': this.handleTurnBegin(event); break; + case 'turn.ended': this.handleTurnEnd(event, sendQueued); break; + case 'turn.step.started': this.handleStepBegin(event); break; + case 'turn.step.interrupted': this.handleStepInterrupted(event); break; + case 'turn.step.completed': this.handleStepCompleted(event); break; + case 'turn.step.retrying': break; + case 'tool.progress': this.handleToolProgress(event); break; + case 'assistant.delta': this.handleAssistantDelta(event); break; + case 'hook.result': this.handleHookResult(event); break; + case 'thinking.delta': this.handleThinkingDelta(event); break; + case 'tool.call.started': this.handleToolCall(event); break; + case 'tool.call.delta': this.handleToolCallDelta(event); break; + case 'tool.result': this.handleToolResult(event); break; + case 'agent.status.updated': this.handleStatusUpdate(event); break; + case 'session.meta.updated': this.handleSessionMetaChanged(event); break; + case 'skill.activated': this.handleSkillActivated(event); break; + case 'error': this.handleSessionError(event); break; + case 'warning': this.handleSessionWarning(event); break; + case 'compaction.started': this.handleCompactionBegin(event); break; + case 'compaction.completed': this.handleCompactionEnd(event, sendQueued); break; + case 'compaction.blocked': break; + case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; + case 'subagent.spawned': this.handleSubagentSpawned(event); break; + case 'subagent.completed': this.handleSubagentCompleted(event); break; + case 'subagent.failed': this.handleSubagentFailed(event); break; + case 'background.task.started': + case 'background.task.updated': + case 'background.task.terminated': + this.handleBackgroundTaskEvent(event); break; + case 'mcp.server.status': this.renderMcpServerStatus(event.server); break; + case 'tool.list.updated': break; + default: break; + } + } + + stopAllMcpServerStatusSpinners(): void { + for (const spinner of this.host.state.mcpServerStatusSpinners.values()) { + spinner.stop(); + } + this.host.state.mcpServerStatusSpinners.clear(); + } + + // --------------------------------------------------------------------------- + // Private handlers + // --------------------------------------------------------------------------- + + private routeSubagentEvent(event: Event): boolean { + const subagentId = event.agentId; + if (subagentId === MAIN_AGENT_ID) return false; + + const { state } = this.host; + const parentToolCallId = state.subagentParentToolCallIds.get(subagentId); + if (parentToolCallId === undefined || parentToolCallId.length === 0) return true; + const sourceName = state.subagentNames.get(subagentId); + const toolCall = state.pendingToolComponents.get(parentToolCallId); + if (toolCall === undefined) return true; + toolCall.setSubagentMeta(subagentId, sourceName); + + switch (event.type) { + case 'hook.result': + toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); + return true; + case 'assistant.delta': + toolCall.appendSubagentText(event.delta, 'text'); + return true; + case 'thinking.delta': + toolCall.appendSubagentText(event.delta, 'thinking'); + return true; + case 'tool.call.started': + toolCall.appendSubToolCall({ + id: `${subagentId}:${event.toolCallId}`, + name: event.name, + args: argsRecord(event.args), + }); + return true; + case 'tool.call.delta': + toolCall.appendSubToolCallDelta({ + id: `${subagentId}:${event.toolCallId}`, + name: event.name, + argumentsPart: event.argumentsPart ?? null, + }); + return true; + case 'tool.result': + toolCall.finishSubToolCall({ + tool_call_id: `${subagentId}:${event.toolCallId}`, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + }); + return true; + case 'agent.status.updated': { + const usageObj = event.usage; + const totalUsage = usageObj?.total ?? usageObj?.currentTurn; + toolCall.updateSubagentMetrics({ + contextTokens: event.contextTokens, + usage: totalUsage, + }); + return true; + } + case 'background.task.started': + case 'background.task.updated': + case 'background.task.terminated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'error': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.progress': + case 'tool.list.updated': + case 'mcp.server.status': + case 'turn.ended': + case 'turn.started': + case 'turn.step.completed': + case 'turn.step.interrupted': + case 'turn.step.retrying': + case 'turn.step.started': + return true; + default: + return true; + } + } + + private handleTurnBegin(_event: TurnStartedEvent): void { + void _event; + this.host.streamingUI.resetToolUi(); + this.host.state.currentStep = 0; + this.host.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.host.setAppState({ + isStreaming: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { + void _event; + this.host.streamingUI.flushNow(); + const todos = this.host.state.todoPanel.getTodos(); + if (todos.length > 0 && todos.every((t) => t.status === 'done')) { + this.host.streamingUI.setTodoList([]); + } + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeTurn(sendQueued); + } + + private handleStepBegin(event: TurnStepStartedEvent): void { + this.host.streamingUI.flushNow(); + this.host.state.currentStep = event.step; + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('waiting'); + this.host.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.host.setAppState({ + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + } + + private handleStepCompleted(event: TurnStepCompletedEvent): void { + this.host.streamingUI.flushNow(); + if (event.finishReason !== 'max_tokens') return; + + const { state } = this.host; + const eventTurnId = String(event.turnId); + let truncatedCount = 0; + for (const toolCall of state.activeToolCalls.values()) { + if (toolCall.result !== undefined) continue; + if (toolCall.streamingArguments === undefined) continue; + if (toolCall.turnId !== eventTurnId) continue; + if (toolCall.step !== event.step) continue; + toolCall.truncated = true; + const component = state.pendingToolComponents.get(toolCall.id); + if (component !== undefined) { + component.updateToolCall(toolCall); + } + truncatedCount += 1; + } + state.streamingToolCallArguments.clear(); + + const title = + truncatedCount > 0 + ? 'Model hit max_tokens — tool call was truncated before it could run.' + : 'Model hit max_tokens — no tool call was emitted.'; + const detail = this.isAnthropicSessionActive() + ? 'If this limit is wrong for your model, set `max_output_size` on the model alias in your kimi-code config.' + : undefined; + this.host.showNotice(title, detail); + } + + private isAnthropicSessionActive(): boolean { + const { state } = this.host; + const providerKey = state.appState.availableModels[state.appState.model]?.provider; + if (providerKey === undefined) return false; + return state.appState.availableProviders[providerKey]?.type === 'anthropic'; + } + + private handleStepInterrupted(event: TurnStepInterruptedEvent): void { + this.host.streamingUI.flushNow(); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('idle'); + const reason = event.reason; + if (reason === 'error') return; + if (reason === 'aborted' || reason === undefined || reason === '') { + this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); + return; + } + this.host.showError( + reason === 'max_steps' + ? 'reached per-turn step limit (max_steps)' + : `step interrupted (${reason})`, + ); + } + + private handleThinkingDelta(event: ThinkingDeltaEvent): void { + const { state } = this.host; + state.thinkingDraft += event.delta; + this.host.streamingUI.markThinkingDirty(); + this.host.patchLivePane({ mode: 'idle' }); + if (state.appState.streamingPhase !== 'thinking') { + this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); + } + this.host.streamingUI.scheduleFlush(); + } + + private handleAssistantDelta(event: AssistantDeltaEvent): void { + const { state } = this.host; + if (state.thinkingDraft.length > 0) { + this.host.streamingUI.flushThinkingToTranscript('idle'); + } + + if (!state.assistantStreamActive) { + state.assistantStreamActive = true; + this.host.streamingUI.onStreamingTextStart(); + } + + state.assistantDraft += event.delta; + this.host.streamingUI.markAssistantDirty(); + + this.host.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + if (state.appState.streamingPhase !== 'composing') { + this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); + } + this.host.streamingUI.scheduleFlush(); + } + + private handleHookResult(event: HookResultEvent): void { + this.host.streamingUI.flushNow(); + if (this.host.state.thinkingDraft.length > 0) { + this.host.streamingUI.flushThinkingToTranscript('idle'); + } + this.host.streamingUI.finalizeAssistantStream(); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'assistant', + turnId: String(event.turnId), + renderMode: 'markdown', + content: formatHookResultMarkdown(event), + }); + this.host.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + } + + private handleToolCall(event: ToolCallStartedEvent): void { + const { state, streamingUI } = this.host; + streamingUI.flushNow(); + const toolCall: ToolCallBlockData = { + id: event.toolCallId, + name: event.name, + args: argsRecord(event.args), + description: event.description, + display: event.display, + step: state.currentStep, + turnId: state.currentTurnId, + }; + const existing = state.activeToolCalls.get(event.toolCallId); + state.activeToolCalls.set(event.toolCallId, toolCall); + streamingUI.pendingToolCallFlushIds.delete(event.toolCallId); + state.streamingToolCallArguments.delete(event.toolCallId); + const existingComponent = state.pendingToolComponents.get(event.toolCallId); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (existing === undefined) { + streamingUI.finalizeLiveTextBuffers('tool'); + if (event.name !== 'Agent') { + streamingUI.onToolCallStart(toolCall); + } + } + this.host.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + } + + private handleToolCallDelta(event: ToolCallDeltaEvent): void { + if (event.toolCallId.length === 0) return; + const { state, streamingUI } = this.host; + const id = event.toolCallId; + const existing = state.streamingToolCallArguments.get(id); + const argumentsText = appendStreamingArgsPreview( + existing?.argumentsText, + event.argumentsPart, + ); + const name = event.name ?? existing?.name ?? state.activeToolCalls.get(id)?.name ?? 'Tool'; + const startedAtMs = existing?.startedAtMs ?? Date.now(); + state.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); + streamingUI.pendingToolCallFlushIds.add(id); + + this.host.patchLivePane({ + mode: 'tool', + pendingApproval: null, + pendingQuestion: null, + }); + if (state.appState.streamingPhase !== 'composing') { + this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleToolProgress(event: ToolProgressEvent): void { + if (event.update.kind !== 'status') return; + const text = event.update.text; + if (text === undefined || text.length === 0) return; + const tc = this.host.state.pendingToolComponents.get(event.toolCallId); + if (tc === undefined) return; + tc.appendProgress(text); + } + + private handleToolResult(event: ToolResultEvent): void { + const { state, streamingUI } = this.host; + streamingUI.flushNow(); + const matchedCall = state.activeToolCalls.get(event.toolCallId); + const resultData: ToolResultBlockData = { + tool_call_id: event.toolCallId, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + synthetic: event.synthetic, + }; + if (matchedCall !== undefined) { + streamingUI.onToolCallEnd(event.toolCallId, resultData); + if (matchedCall.name === 'TodoList' && !event.isError) { + const rawTodos = (matchedCall.args as { todos?: unknown }).todos; + if (Array.isArray(rawTodos)) { + const sanitized = rawTodos + .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => + isTodoItemShape(todo), + ) + .map((t) => ({ title: t.title, status: t.status })); + streamingUI.setTodoList(sanitized); + } + } + } + state.activeToolCalls.delete(event.toolCallId); + state.streamingToolCallArguments.delete(event.toolCallId); + this.host.patchLivePane({ mode: 'waiting' }); + } + + private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { + const patch: Partial = {}; + if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; + if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; + if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; + if (event.planMode !== undefined) patch.planMode = event.planMode; + if (event.permission !== undefined) { + patch.permissionMode = event.permission; + patch.yolo = event.permission === 'yolo'; + } + if (event.model !== undefined) patch.model = event.model; + if (Object.keys(patch).length > 0) this.host.setAppState(patch); + } + + private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { + const title = event.title ?? stringValue(event.patch?.['title']); + if (title !== undefined) { + this.host.setAppState({ sessionTitle: title }); + setProcessTitle(title, this.host.state.appState.sessionId); + } + } + + private handleSessionError(event: ErrorEvent): void { + this.host.streamingUI.flushNow(); + this.host.streamingUI.resetToolUi(); + this.host.streamingUI.finalizeLiveTextBuffers('idle'); + if (event.code === OAUTH_LOGIN_REQUIRED_CODE) { + this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); + return; + } + this.host.showError(`[${event.code}] ${event.message}`); + const sessionId = this.host.state.appState.sessionId; + if (sessionId.length > 0) { + this.host.showStatus(errorReportHintLine(sessionId)); + } + } + + private handleSessionWarning(event: WarningEvent): void { + this.host.showStatus(`Warning: ${event.message}`, this.host.state.theme.colors.warning); + } + + private renderMcpServerStatus(server: McpServerStatusSnapshot): void { + const { state } = this.host; + const key = mcpServerStatusKey(server); + if (state.renderedMcpServerStatusKeys.get(server.name) === key) return; + state.renderedMcpServerStatusKeys.set(server.name, key); + + const colors = state.theme.colors; + switch (server.status) { + case 'connected': { + const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; + const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; + this.finalizeMcpServerStatusRow(server.name, message, colors.success); + return; + } + case 'failed': { + const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; + this.finalizeMcpServerStatusRow(server.name, message, colors.error); + return; + } + case 'needs-auth': { + const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; + this.finalizeMcpServerStatusRow(server.name, message, colors.warning); + return; + } + case 'disabled': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" disabled`, + colors.textMuted, + ); + return; + case 'pending': + this.showMcpServerStatusSpinner(server.name); + return; + } + } + + private showMcpServerStatusSpinner(name: string): void { + const { state } = this.host; + const label = `MCP server "${name}" connecting…`; + const existing = state.mcpServerStatusSpinners.get(name); + if (existing !== undefined) { + existing.setLabel(label); + return; + } + const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); + const spinner = new MoonLoader(state.ui, 'braille', tint, label); + state.transcriptContainer.addChild(spinner); + state.mcpServerStatusSpinners.set(name, spinner); + state.ui.requestRender(); + } + + private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + const { state } = this.host; + const spinner = state.mcpServerStatusSpinners.get(name); + if (spinner === undefined) { + this.host.showStatus(message, color); + return; + } + spinner.stop(); + const status = new StatusMessageComponent(message, state.theme.colors, color); + const children = state.transcriptContainer.children; + const idx = children.indexOf(spinner); + if (idx >= 0) { + children[idx] = status; + state.transcriptContainer.invalidate(); + } else { + state.transcriptContainer.addChild(status); + } + state.mcpServerStatusSpinners.delete(name); + state.ui.requestRender(); + } + + private handleSkillActivated(event: SkillActivatedEvent): void { + const { state } = this.host; + if (state.renderedSkillActivationIds.has(event.activationId)) return; + state.renderedSkillActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'skill_activation', + turnId: undefined, + renderMode: 'plain', + content: `Activated skill: ${event.skillName}`, + skillActivationId: event.activationId, + skillName: event.skillName, + skillArgs: event.skillArgs, + }); + } + + private handleCompactionBegin(event: CompactionStartedEvent): void { + this.host.streamingUI.finalizeLiveTextBuffers('waiting'); + this.host.setAppState({ + isCompacting: true, + streamingPhase: 'waiting', + streamingStartTime: Date.now(), + }); + this.host.streamingUI.beginCompaction(event.instruction); + } + + private handleCompactionEnd( + event: CompactionCompletedEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.host.streamingUI.endCompaction(event.result.tokensBefore, event.result.tokensAfter); + this.finishCompaction(sendQueued); + } + + private handleCompactionCancel( + _event: CompactionCancelledEvent, + sendQueued: (item: QueuedMessage) => void, + ): void { + this.host.streamingUI.cancelCompaction(); + this.finishCompaction(sendQueued); + } + + private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { + const { state } = this.host; + if (!state.appState.isStreaming) { + this.host.setAppState({ + isCompacting: false, + streamingPhase: 'idle', + }); + this.host.resetLivePane(); + if (state.queuedMessages.length > 0) { + const [next, ...rest] = state.queuedMessages; + state.queuedMessages = rest; + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); + } + } + } else { + this.host.setAppState({ isCompacting: false }); + } + } + + private handleSubagentSpawned(event: SubagentSpawnedEvent): void { + const { state, streamingUI } = this.host; + state.subagentParentToolCallIds.set(event.subagentId, event.parentToolCallId); + state.subagentNames.set(event.subagentId, event.subagentName); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + state.backgroundAgentMetadata.set(event.subagentId, meta); + state.backgroundAgents.add(event.subagentId); + this.appendBackgroundAgentEntry('started', meta); + this.syncBackgroundAgentBadge(); + return; + } + + let tc = state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) { + const toolCall = state.activeToolCalls.get(event.parentToolCallId); + if (toolCall !== undefined) { + streamingUI.onToolCallStart(toolCall); + tc = state.pendingToolComponents.get(event.parentToolCallId); + } + } + tc ??= this.createStandaloneSubagentToolCall(event); + if (tc === undefined) return; + tc.onSubagentSpawned({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + } + + private handleSubagentCompleted(event: SubagentCompletedEvent): void { + const { state } = this.host; + const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); + if (state.backgroundAgents.delete(event.subagentId)) { + this.syncBackgroundAgentBadge(); + } + if (backgroundMeta !== undefined) { + state.backgroundAgentMetadata.delete(event.subagentId); + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + state.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + const tc = state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + if (!state.activeToolCalls.has(event.parentToolCallId)) { + state.pendingToolComponents.delete(event.parentToolCallId); + } + } + + private handleSubagentFailed(event: SubagentFailedEvent): void { + const { state } = this.host; + const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); + if (state.backgroundAgents.delete(event.subagentId)) { + this.syncBackgroundAgentBadge(); + } + if (backgroundMeta !== undefined) { + state.backgroundAgentMetadata.delete(event.subagentId); + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + state.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + const tc = state.pendingToolComponents.get(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + if (!state.activeToolCalls.has(event.parentToolCallId)) { + state.pendingToolComponents.delete(event.parentToolCallId); + } + } + + private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent) { + const { state, streamingUI } = this.host; + const description = event.description ?? `Run ${event.subagentName} agent`; + const toolCall: ToolCallBlockData = { + id: event.parentToolCallId, + name: 'Agent', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step: state.currentStep, + turnId: state.currentTurnId, + }; + streamingUI.onToolCallStart(toolCall); + return state.pendingToolComponents.get(event.parentToolCallId); + } + + private findAgentTaskId(subagentId: string): string | undefined { + const { state } = this.host; + const meta = state.backgroundAgentMetadata.get(subagentId); + const description = meta?.description ?? meta?.agentName; + if (description === undefined) return undefined; + let match: string | undefined; + for (const info of state.backgroundTasks.values()) { + if (!info.taskId.startsWith('agent-')) continue; + if (info.description !== description) continue; + if (match !== undefined) return undefined; + match = info.taskId; + } + return match; + } + + private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { + const parent = this.host.state.activeToolCalls.get(event.parentToolCallId); + const description = parent?.args['description'] ?? event.description; + return { + agentId: event.subagentId, + parentToolCallId: event.parentToolCallId, + agentName: event.subagentName, + description: typeof description === 'string' ? description : undefined, + }; + } + + private appendBackgroundAgentEntry( + phase: 'started' | 'completed' | 'failed', + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, + ): void { + const status = formatBackgroundAgentTranscript(phase, meta, extras); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.state.currentTurnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private syncBackgroundAgentBadge(): void { + this.syncBackgroundTaskBadge(); + } + + // --------------------------------------------------------------------------- + // Background task lifecycle + // --------------------------------------------------------------------------- + + private handleBackgroundTaskEvent( + event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent, + ): void { + const { state } = this.host; + const { info } = event; + const previous = state.backgroundTasks.get(info.taskId); + state.backgroundTasks.set(info.taskId, info); + + const viewer = state.tasksBrowser?.viewer; + if (viewer !== undefined && viewer.taskId === info.taskId) { + void this.host.refreshTaskOutputViewer({ silent: true }); + } + + const isTerminal = + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost'; + + if (event.type === 'background.task.started') { + if (info.taskId.startsWith('agent-')) { + this.syncBackgroundTaskBadge(); + this.host.repaintTasksBrowser(); + return; + } + this.appendBackgroundTaskEntry(info); + this.syncBackgroundTaskBadge(); + this.host.repaintTasksBrowser(); + return; + } + + if (event.type === 'background.task.terminated' && isTerminal) { + if (!state.backgroundTaskTranscriptedTerminal.has(info.taskId)) { + if (info.taskId.startsWith('bash-')) { + this.appendBackgroundTaskEntry(info); + } + state.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + this.syncBackgroundTaskBadge(); + this.host.repaintTasksBrowser(); + return; + } + + if (previous?.status !== info.status) { + this.syncBackgroundTaskBadge(); + } + this.host.repaintTasksBrowser(); + } + + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + const status = formatBackgroundTaskTranscript(info); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.state.currentTurnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private syncBackgroundTaskBadge(): void { + const { state } = this.host; + let bashTasks = 0; + let agentTasks = 0; + for (const info of state.backgroundTasks.values()) { + if ( + info.status === 'completed' || + info.status === 'failed' || + info.status === 'killed' || + info.status === 'lost' + ) { + continue; + } + if (info.taskId.startsWith('agent-')) { + agentTasks += 1; + } else { + bashTasks += 1; + } + } + state.footer.setBackgroundCounts({ bashTasks, agentTasks }); + state.ui.requestRender(); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index eb33d8f119..cc1b5bc73e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -147,6 +147,7 @@ import { PlatformSelectorComponent } from './components/dialogs/platform-selecto import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { SessionEventHandler } from './controllers/session-event-handler'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; import { @@ -560,12 +561,12 @@ export class KimiTUI { private readonly imageStore = new ImageAttachmentStore(); private readonly fdPath: string | null = detectFdPath(); private readonly gitLsFilesCache: GitLsFilesCache; - private sessionEventUnsubscribe: (() => void) | undefined; + sessionEventUnsubscribe: (() => void) | undefined; private pendingExit: PendingExit | null = null; private cancelInFlight: (() => void) | undefined; // Queues editor messages instead of sending or steering them. Used by /init. deferUserMessages = false; - private aborted = false; + aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; // Cleanup callbacks for SIGHUP/SIGTERM listeners and stdout/stderr 'error' @@ -580,6 +581,7 @@ export class KimiTUI { // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; readonly streamingUI: StreamingUIController; + private readonly sessionEventHandler: SessionEventHandler; private readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -630,6 +632,7 @@ export class KimiTUI { }), ); this.streamingUI = new StreamingUIController(this); + this.sessionEventHandler = new SessionEventHandler(this); this.tasksBrowserController = new TasksBrowserController(this); this.setupEditorHandlers(); this.buildLayout(); @@ -1685,7 +1688,7 @@ export class KimiTUI { } // Sends a queued message after restoring the agent target captured at enqueue time. - private sendQueuedMessage(session: Session, item: QueuedMessage): void { + sendQueuedMessage(session: Session, item: QueuedMessage): void { this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; this.sendMessageInternal(session, item.text, { parts: item.parts, @@ -2495,970 +2498,15 @@ export class KimiTUI { } // ========================================================================= - // Session Events + // Session Events — delegated to SessionEventHandler // ========================================================================= - private startSessionEventSubscription(): void { - const session = this.requireSession(); - const sendQueued = (item: QueuedMessage): void => { - this.sendQueuedMessage(session, item); - }; - this.sessionEventUnsubscribe?.(); - const mcpOAuthOpener = new McpOAuthAuthorizationUrlOpener(openUrl); - const { sessionId } = this.state.appState; - this.sessionEventUnsubscribe = session.onEvent((event) => { - if (this.aborted) return; - if (event.sessionId !== sessionId) return; - if (event.type === 'tool.progress') { - mcpOAuthOpener.handleToolProgress(event); - } - this.handleEvent(event, sendQueued); - }); - void this.syncMcpServerStatusSnapshot(session); - } - - private async syncMcpServerStatusSnapshot(session: Session): Promise { - let servers: readonly McpServerStatusSnapshot[]; - try { - servers = await session.listMcpServers(); - } catch (error) { - if (this.session !== session || this.aborted) return; - const message = error instanceof Error ? error.message : String(error); - this.showError(`Failed to sync MCP server status: ${message}`); - return; - } - if (this.session !== session || this.state.appState.sessionId !== session.id) return; + private startSessionEventSubscription(): void { this.sessionEventHandler.startSubscription(); } - const visible = selectMcpStartupStatusRows(servers); - const visibleNames = new Set(visible.map((server) => server.name)); - for (const server of visible) { - if (this.state.renderedMcpServerStatusKeys.has(server.name)) continue; - this.renderMcpServerStatus(server); - } - - const hidden: McpServerStatusSnapshot[] = []; - for (const server of servers) { - if (visibleNames.has(server.name)) continue; - if (this.state.renderedMcpServerStatusKeys.has(server.name)) continue; - this.state.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); - hidden.push(server); - } - if (hidden.length > 0) { - this.showStatus( - formatMcpStartupStatusSummary(hidden, visible.length), - this.state.theme.colors.textMuted, - ); - } - } - - // Routes an SDK event to the matching TUI state transition. private handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { - if (this.routeSubagentEvent(event)) { - return; - } - - if ('turnId' in event && event.turnId !== undefined) { - this.state.currentTurnId = String(event.turnId); - } - - switch (event.type) { - case 'turn.started': - this.handleTurnBegin(event); - break; - case 'turn.ended': - this.handleTurnEnd(event, sendQueued); - break; - case 'turn.step.started': - this.handleStepBegin(event); - break; - case 'turn.step.interrupted': - this.handleStepInterrupted(event); - break; - case 'turn.step.completed': - this.handleStepCompleted(event); - break; - case 'turn.step.retrying': - break; - case 'tool.progress': - this.handleToolProgress(event); - break; - case 'assistant.delta': - this.handleAssistantDelta(event); - break; - case 'hook.result': - this.handleHookResult(event); - break; - case 'thinking.delta': - this.handleThinkingDelta(event); - break; - case 'tool.call.started': - this.handleToolCall(event); - break; - case 'tool.call.delta': - this.handleToolCallDelta(event); - break; - case 'tool.result': - this.handleToolResult(event); - break; - case 'agent.status.updated': - this.handleStatusUpdate(event); - break; - case 'session.meta.updated': - this.handleSessionMetaChanged(event); - break; - case 'skill.activated': - this.handleSkillActivated(event); - break; - case 'error': - this.handleSessionError(event); - break; - case 'warning': - this.handleSessionWarning(event); - break; - case 'compaction.started': - this.handleCompactionBegin(event); - break; - case 'compaction.completed': - this.handleCompactionEnd(event, sendQueued); - break; - case 'compaction.blocked': - break; - case 'compaction.cancelled': - this.handleCompactionCancel(event, sendQueued); - break; - case 'subagent.spawned': - this.handleSubagentSpawned(event); - break; - case 'subagent.completed': - this.handleSubagentCompleted(event); - break; - case 'subagent.failed': - this.handleSubagentFailed(event); - break; - case 'background.task.started': - case 'background.task.updated': - case 'background.task.terminated': - this.handleBackgroundTaskEvent(event); - break; - case 'mcp.server.status': - this.renderMcpServerStatus(event.server); - break; - case 'tool.list.updated': - break; - default: - break; - } - } - - // Routes child-agent events into their parent tool-call component. - private routeSubagentEvent(event: Event): boolean { - const subagentId = event.agentId; - if (subagentId === MAIN_AGENT_ID) return false; - - const parentToolCallId = this.state.subagentParentToolCallIds.get(subagentId); - if (parentToolCallId === undefined || parentToolCallId.length === 0) return true; - const sourceName = this.state.subagentNames.get(subagentId); - const toolCall = this.state.pendingToolComponents.get(parentToolCallId); - if (toolCall === undefined) return true; - toolCall.setSubagentMeta(subagentId, sourceName); - - switch (event.type) { - case 'hook.result': - toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); - return true; - case 'assistant.delta': - toolCall.appendSubagentText(event.delta, 'text'); - return true; - case 'thinking.delta': - toolCall.appendSubagentText(event.delta, 'thinking'); - return true; - case 'tool.call.started': - toolCall.appendSubToolCall({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - args: argsRecord(event.args), - }); - return true; - case 'tool.call.delta': - toolCall.appendSubToolCallDelta({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - argumentsPart: event.argumentsPart ?? null, - }); - return true; - case 'tool.result': - toolCall.finishSubToolCall({ - tool_call_id: `${subagentId}:${event.toolCallId}`, - output: serializeToolResultOutput(event.output), - is_error: event.isError, - }); - return true; - case 'agent.status.updated': { - const usageObj = event.usage; - const totalUsage = usageObj?.total ?? usageObj?.currentTurn; - toolCall.updateSubagentMetrics({ - contextTokens: event.contextTokens, - usage: totalUsage, - }); - return true; - } - case 'background.task.started': - case 'background.task.updated': - case 'background.task.terminated': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - case 'compaction.started': - case 'error': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'tool.progress': - case 'tool.list.updated': - case 'mcp.server.status': - case 'turn.ended': - case 'turn.started': - case 'turn.step.completed': - case 'turn.step.interrupted': - case 'turn.step.retrying': - case 'turn.step.started': - return true; - default: - return true; - } - } - - // Initializes turn-scoped buffers when the SDK starts a turn. - private handleTurnBegin(_event: TurnStartedEvent): void { - void _event; - this.resetLiveToolUiState(); - this.state.currentStep = 0; - this.patchLivePane({ - mode: 'waiting', - pendingApproval: null, - pendingQuestion: null, - }); - this.setAppState({ - isStreaming: true, - streamingPhase: 'waiting', - streamingStartTime: Date.now(), - }); - } - - // Finalizes turn-scoped state when the SDK completes a turn. - private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { - void _event; - this.flushStreamingUiUpdatesNow(); - const todos = this.state.todoPanel.getTodos(); - if (todos.length > 0 && todos.every((t) => t.status === 'done')) { - this.setTodoList([]); - } - this.resetLiveToolUiState(); - this.finalizeTurn(sendQueued); - } - - // Resets live render state for a new turn step. - private handleStepBegin(event: TurnStepStartedEvent): void { - this.flushStreamingUiUpdatesNow(); - this.state.currentStep = event.step; - this.resetLiveToolUiState(); - this.finalizeLiveTextBuffers('waiting'); - this.patchLivePane({ - mode: 'waiting', - pendingApproval: null, - pendingQuestion: null, - }); - this.setAppState({ - streamingPhase: 'waiting', - streamingStartTime: Date.now(), - }); - } - - // Surfaces step-level outcomes the user needs to act on. The common - // case (finishReason === 'tool_use' or 'end_turn') is silent — those - // already render via tool.call.started/tool.result and assistant.delta. - // The interesting case is max_tokens: the model started a tool_use but - // ran out of budget before finalizing it, so the partial tool call is - // still pinned in 'Preparing' state with no signal that anything went - // wrong. Flip those into a visible 'Truncated' state and append a - // notice pointing at the config knob. - private handleStepCompleted(event: TurnStepCompletedEvent): void { - this.flushStreamingUiUpdatesNow(); - if (event.finishReason !== 'max_tokens') return; - - // Scope the truncation marking to tool calls that belong to the - // step that just completed. Without this guard, stale entries from - // earlier retry attempts (or unrelated still-tracked calls) would - // get relabeled and counted, producing misleading "tool call was - // truncated" notices for the wrong step. - const eventTurnId = String(event.turnId); - let truncatedCount = 0; - for (const toolCall of this.state.activeToolCalls.values()) { - if (toolCall.result !== undefined) continue; - if (toolCall.streamingArguments === undefined) continue; - if (toolCall.turnId !== eventTurnId) continue; - if (toolCall.step !== event.step) continue; - toolCall.truncated = true; - const component = this.state.pendingToolComponents.get(toolCall.id); - if (component !== undefined) { - component.updateToolCall(toolCall); - } - truncatedCount += 1; - } - this.state.streamingToolCallArguments.clear(); - - const title = - truncatedCount > 0 - ? 'Model hit max_tokens — tool call was truncated before it could run.' - : 'Model hit max_tokens — no tool call was emitted.'; - // The `max_output_size` knob is only wired through to provider - // requests for the Anthropic provider (see toKosongProviderConfig). - // For OpenAI / Kimi / Google sessions the advice would be a - // dead end, so skip the second line on those providers. - const detail = this.isAnthropicSessionActive() - ? 'If this limit is wrong for your model, set `max_output_size` on the model alias in your kimi-code config.' - : undefined; - this.showNotice(title, detail); - } - - private isAnthropicSessionActive(): boolean { - const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; - if (providerKey === undefined) return false; - return this.state.appState.availableProviders[providerKey]?.type === 'anthropic'; - } - - // Renders user-facing status for an interrupted turn step. - private handleStepInterrupted(event: TurnStepInterruptedEvent): void { - this.flushStreamingUiUpdatesNow(); - this.resetLiveToolUiState(); - this.finalizeLiveTextBuffers('idle'); - const reason = event.reason; - if (reason === 'error') return; - if (reason === 'aborted' || reason === undefined || reason === '') { - this.showStatus('Interrupted by user', this.state.theme.colors.error); - return; - } - this.showError( - reason === 'max_steps' - ? 'reached per-turn step limit (max_steps)' - : `step interrupted (${reason})`, - ); - } - - // Appends a thinking delta to the live thinking block. - private handleThinkingDelta(event: ThinkingDeltaEvent): void { - this.state.thinkingDraft += event.delta; - this.streamingUI.markThinkingDirty(); - this.patchLivePane({ mode: 'idle' }); - if (this.state.appState.streamingPhase !== 'thinking') { - this.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); - } - this.scheduleStreamingUiFlush(); - } - - // Appends an assistant text delta to the live assistant block. - private handleAssistantDelta(event: AssistantDeltaEvent): void { - if (this.state.thinkingDraft.length > 0) { - this.flushThinkingToTranscript('idle'); - } - - if (!this.state.assistantStreamActive) { - this.state.assistantStreamActive = true; - this.onStreamingTextStart(); - } - - this.state.assistantDraft += event.delta; - this.streamingUI.markAssistantDirty(); - - this.patchLivePane({ - mode: 'idle', - pendingApproval: null, - pendingQuestion: null, - }); - if (this.state.appState.streamingPhase !== 'composing') { - this.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); - } - this.scheduleStreamingUiFlush(); - } - - private handleHookResult(event: HookResultEvent): void { - this.flushStreamingUiUpdatesNow(); - if (this.state.thinkingDraft.length > 0) { - this.flushThinkingToTranscript('idle'); - } - this.finalizeAssistantStream(); - this.appendTranscriptEntry({ - id: nextTranscriptId(), - kind: 'assistant', - turnId: String(event.turnId), - renderMode: 'markdown', - content: formatHookResultMarkdown(event), - }); - this.patchLivePane({ - mode: 'idle', - pendingApproval: null, - pendingQuestion: null, - }); - } - - // Starts or updates a rendered tool call from a tool-call start event. - private handleToolCall(event: ToolCallStartedEvent): void { - this.flushStreamingUiUpdatesNow(); - const toolCall: ToolCallBlockData = { - id: event.toolCallId, - name: event.name, - args: argsRecord(event.args), - description: event.description, - display: event.display, - step: this.state.currentStep, - turnId: this.state.currentTurnId, - }; - const existing = this.state.activeToolCalls.get(event.toolCallId); - this.state.activeToolCalls.set(event.toolCallId, toolCall); - this.streamingUI.pendingToolCallFlushIds.delete(event.toolCallId); - this.state.streamingToolCallArguments.delete(event.toolCallId); - const existingComponent = this.state.pendingToolComponents.get(event.toolCallId); - if (existingComponent !== undefined) { - existingComponent.updateToolCall(toolCall); - } else if (existing === undefined) { - this.finalizeLiveTextBuffers('tool'); - if (event.name !== 'Agent') { - this.onToolCallStart(toolCall); - } - } - this.patchLivePane({ - mode: 'tool', - pendingApproval: null, - pendingQuestion: null, - }); - } - - // Accumulates streaming tool-call arguments and updates the rendered call. - private handleToolCallDelta(event: ToolCallDeltaEvent): void { - if (event.toolCallId.length === 0) return; - const id = event.toolCallId; - const existing = this.state.streamingToolCallArguments.get(id); - const argumentsText = appendStreamingArgsPreview( - existing?.argumentsText, - event.argumentsPart, - ); - const name = event.name ?? existing?.name ?? this.state.activeToolCalls.get(id)?.name ?? 'Tool'; - const startedAtMs = existing?.startedAtMs ?? Date.now(); - this.state.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); - this.streamingUI.pendingToolCallFlushIds.add(id); - - this.patchLivePane({ - mode: 'tool', - pendingApproval: null, - pendingQuestion: null, - }); - if (this.state.appState.streamingPhase !== 'composing') { - this.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); - } - this.scheduleStreamingUiFlush(); - } - - // Streams a `{kind:'status'}` progress text into the live tool box so - // long-blocking tools (e.g. the MCP synthetic `authenticate` tool whose - // 15-minute browser wait would otherwise show only a spinner) can surface - // their authorization URL. Non-status update kinds stay out of the terminal - // transcript because only status text needs persistent display. - private handleToolProgress(event: ToolProgressEvent): void { - if (event.update.kind !== 'status') return; - const text = event.update.text; - if (text === undefined || text.length === 0) return; - const tc = this.state.pendingToolComponents.get(event.toolCallId); - if (tc === undefined) return; - tc.appendProgress(text); - } - - // Completes a tool call and applies any tool-specific UI side effects. - private handleToolResult(event: ToolResultEvent): void { - this.flushStreamingUiUpdatesNow(); - const matchedCall = this.state.activeToolCalls.get(event.toolCallId); - const resultData: ToolResultBlockData = { - tool_call_id: event.toolCallId, - output: serializeToolResultOutput(event.output), - is_error: event.isError, - synthetic: event.synthetic, - }; - if (matchedCall !== undefined) { - this.onToolCallEnd(event.toolCallId, resultData); - if (matchedCall.name === 'TodoList' && !event.isError) { - const rawTodos = (matchedCall.args as { todos?: unknown }).todos; - if (Array.isArray(rawTodos)) { - const sanitized = rawTodos - .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => - isTodoItemShape(todo), - ) - .map((t) => ({ title: t.title, status: t.status })); - this.setTodoList(sanitized); - } - } - } - this.state.activeToolCalls.delete(event.toolCallId); - this.state.streamingToolCallArguments.delete(event.toolCallId); - this.patchLivePane({ mode: 'waiting' }); - } - - // Applies agent status updates to app state. - private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { - const patch: Partial = {}; - if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; - if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; - if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; - if (event.planMode !== undefined) patch.planMode = event.planMode; - if (event.permission !== undefined) { - patch.permissionMode = event.permission; - patch.yolo = event.permission === 'yolo'; - } - if (event.model !== undefined) patch.model = event.model; - if (Object.keys(patch).length > 0) this.setAppState(patch); - } - - // Applies session metadata changes to the UI and process title. - private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { - const title = event.title ?? stringValue(event.patch?.['title']); - if (title !== undefined) { - this.setAppState({ sessionTitle: title }); - setProcessTitle(title, this.state.appState.sessionId); - } - } - - // Finalizes live buffers and renders a session error. - private handleSessionError(event: ErrorEvent): void { - this.flushStreamingUiUpdatesNow(); - this.resetLiveToolUiState(); - this.finalizeLiveTextBuffers('idle'); - if (event.code === OAUTH_LOGIN_REQUIRED_CODE) { - this.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); - return; - } - this.showError(`[${event.code}] ${event.message}`); - const sessionId = this.state.appState.sessionId; - if (sessionId.length > 0) { - this.showStatus(errorReportHintLine(sessionId)); - } - } - - private handleSessionWarning(event: WarningEvent): void { - this.showStatus(`Warning: ${event.message}`, this.state.theme.colors.warning); - } - - private renderMcpServerStatus(server: McpServerStatusSnapshot): void { - const key = mcpServerStatusKey(server); - if (this.state.renderedMcpServerStatusKeys.get(server.name) === key) return; - this.state.renderedMcpServerStatusKeys.set(server.name, key); - - const colors = this.state.theme.colors; - switch (server.status) { - case 'connected': { - const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; - const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, colors.success); - return; - } - case 'failed': { - const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.error); - return; - } - case 'needs-auth': { - const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, colors.warning); - return; - } - case 'disabled': - this.finalizeMcpServerStatusRow( - server.name, - `MCP server "${server.name}" disabled`, - colors.textMuted, - ); - return; - case 'pending': - this.showMcpServerStatusSpinner(server.name); - return; - } - } - - private showMcpServerStatusSpinner(name: string): void { - const label = `MCP server "${name}" connecting…`; - const existing = this.state.mcpServerStatusSpinners.get(name); - if (existing !== undefined) { - existing.setLabel(label); - return; - } - const tint = (s: string): string => chalk.hex(this.state.theme.colors.textMuted)(s); - const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); - this.state.transcriptContainer.addChild(spinner); - this.state.mcpServerStatusSpinners.set(name, spinner); - this.state.ui.requestRender(); - } - - private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { - const spinner = this.state.mcpServerStatusSpinners.get(name); - if (spinner === undefined) { - this.showStatus(message, color); - return; - } - spinner.stop(); - const status = new StatusMessageComponent(message, this.state.theme.colors, color); - const children = this.state.transcriptContainer.children; - const idx = children.indexOf(spinner); - if (idx >= 0) { - children[idx] = status; - this.state.transcriptContainer.invalidate(); - } else { - this.state.transcriptContainer.addChild(status); - } - this.state.mcpServerStatusSpinners.delete(name); - this.state.ui.requestRender(); - } - - private stopAllMcpServerStatusSpinners(): void { - for (const spinner of this.state.mcpServerStatusSpinners.values()) { - spinner.stop(); - } - this.state.mcpServerStatusSpinners.clear(); - } - - // Adds a skill activation entry to the transcript once. - private handleSkillActivated(event: SkillActivatedEvent): void { - if (this.state.renderedSkillActivationIds.has(event.activationId)) return; - this.state.renderedSkillActivationIds.add(event.activationId); - this.appendTranscriptEntry({ - id: nextTranscriptId(), - kind: 'skill_activation', - turnId: undefined, - renderMode: 'plain', - content: `Activated skill: ${event.skillName}`, - skillActivationId: event.activationId, - skillName: event.skillName, - skillArgs: event.skillArgs, - }); - } - - // Starts the compaction UI block and marks the app as compacting. - private handleCompactionBegin(event: CompactionStartedEvent): void { - this.finalizeLiveTextBuffers('waiting'); - this.setAppState({ - isCompacting: true, - streamingPhase: 'waiting', - streamingStartTime: Date.now(), - }); - this.beginCompaction(event.instruction); - } - - // Finishes compaction and resumes queued work when possible. - private handleCompactionEnd( - event: CompactionCompletedEvent, - sendQueued: (item: QueuedMessage) => void, - ): void { - this.endCompaction(event.result.tokensBefore, event.result.tokensAfter); - this.finishCompaction(sendQueued); - } - - private handleCompactionCancel( - _event: CompactionCancelledEvent, - sendQueued: (item: QueuedMessage) => void, - ): void { - this.cancelCompactionBlock(); - this.finishCompaction(sendQueued); - } - - private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { - if (!this.state.appState.isStreaming) { - this.setAppState({ - isCompacting: false, - streamingPhase: 'idle', - }); - this.resetLivePane(); - if (this.state.queuedMessages.length > 0) { - const [next, ...rest] = this.state.queuedMessages; - this.state.queuedMessages = rest; - if (next !== undefined) { - setTimeout(() => { - sendQueued(next); - }, 0); - } - } - } else { - this.setAppState({ isCompacting: false }); - } - } - - // Registers a spawned subagent and renders foreground or background status. - private handleSubagentSpawned(event: SubagentSpawnedEvent): void { - this.state.subagentParentToolCallIds.set(event.subagentId, event.parentToolCallId); - this.state.subagentNames.set(event.subagentId, event.subagentName); - - if (event.runInBackground) { - const meta = this.buildBackgroundAgentMetadata(event); - this.state.backgroundAgentMetadata.set(event.subagentId, meta); - this.state.backgroundAgents.add(event.subagentId); - this.appendBackgroundAgentEntry('started', meta); - this.syncBackgroundAgentBadge(); - return; - } - - let tc = this.state.pendingToolComponents.get(event.parentToolCallId); - if (tc === undefined) { - const toolCall = this.state.activeToolCalls.get(event.parentToolCallId); - if (toolCall !== undefined) { - this.onToolCallStart(toolCall); - tc = this.state.pendingToolComponents.get(event.parentToolCallId); - } - } - tc ??= this.createStandaloneSubagentToolCall(event); - if (tc === undefined) return; - tc.onSubagentSpawned({ - agentId: event.subagentId, - agentName: event.subagentName, - runInBackground: event.runInBackground, - }); - } - - // Completes a subagent in its parent tool call or background transcript entry. - private handleSubagentCompleted(event: SubagentCompletedEvent): void { - const backgroundMeta = this.state.backgroundAgentMetadata.get(event.subagentId); - if (this.state.backgroundAgents.delete(event.subagentId)) { - this.syncBackgroundAgentBadge(); - } - if (backgroundMeta !== undefined) { - this.state.backgroundAgentMetadata.delete(event.subagentId); - // Dedupe: if the BPM `background.task.terminated` for the - // matching agent task already pushed a terminal card, skip. - // Otherwise mark the subagent id so a later BPM event skips. - const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && this.state.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.state.backgroundTaskTranscriptedTerminal.add(taskId); - } - const extras = - event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; - this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); - return; - } - const tc = this.state.pendingToolComponents.get(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentCompleted({ - contextTokens: event.contextTokens, - usage: event.usage, - resultSummary: event.resultSummary, - }); - if (!this.state.activeToolCalls.has(event.parentToolCallId)) { - this.state.pendingToolComponents.delete(event.parentToolCallId); - } - } - - // Marks a subagent failure in its parent tool call or background transcript entry. - private handleSubagentFailed(event: SubagentFailedEvent): void { - const backgroundMeta = this.state.backgroundAgentMetadata.get(event.subagentId); - if (this.state.backgroundAgents.delete(event.subagentId)) { - this.syncBackgroundAgentBadge(); - } - if (backgroundMeta !== undefined) { - this.state.backgroundAgentMetadata.delete(event.subagentId); - const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && this.state.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.state.backgroundTaskTranscriptedTerminal.add(taskId); - } - this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); - return; - } - const tc = this.state.pendingToolComponents.get(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentFailed({ error: event.error }); - if (!this.state.activeToolCalls.has(event.parentToolCallId)) { - this.state.pendingToolComponents.delete(event.parentToolCallId); - } - } - - // Mounts subagents launched by session-level commands that do not originate - // from a model-issued Agent tool call. - private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent): ToolCallComponent | undefined { - const description = event.description ?? `Run ${event.subagentName} agent`; - const toolCall: ToolCallBlockData = { - id: event.parentToolCallId, - name: 'Agent', - args: { - description, - subagent_type: event.subagentName, - }, - description, - step: this.state.currentStep, - turnId: this.state.currentTurnId, - }; - this.onToolCallStart(toolCall); - return this.state.pendingToolComponents.get(event.parentToolCallId); - } - - /** - * Locate the BPM `agent-*` task id whose `description` matches the - * spawned subagent's recorded description. Used only for dedupe - * between the BPM and subagent flows — best-effort: if there is no - * unique match (e.g. multiple agent tasks with the same description) - * the caller treats the dedupe as a miss, which is safe. - */ - private findAgentTaskId(subagentId: string): string | undefined { - const meta = this.state.backgroundAgentMetadata.get(subagentId); - const description = meta?.description ?? meta?.agentName; - if (description === undefined) return undefined; - let match: string | undefined; - for (const info of this.state.backgroundTasks.values()) { - if (!info.taskId.startsWith('agent-')) continue; - if (info.description !== description) continue; - if (match !== undefined) return undefined; // ambiguous - match = info.taskId; - } - return match; - } - - // Builds transcript metadata for a background subagent. - private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { - const parent = this.state.activeToolCalls.get(event.parentToolCallId); - const description = parent?.args['description'] ?? event.description; - return { - agentId: event.subagentId, - parentToolCallId: event.parentToolCallId, - agentName: event.subagentName, - description: typeof description === 'string' ? description : undefined, - }; - } - - // Appends a background-agent status row to the transcript. - private appendBackgroundAgentEntry( - phase: 'started' | 'completed' | 'failed', - meta: BackgroundAgentMetadata, - extras: { resultSummary?: string; error?: string } | undefined = undefined, - ): void { - const status = formatBackgroundAgentTranscript(phase, meta, extras); - const entry: TranscriptEntry = { - id: nextTranscriptId(), - kind: 'status', - turnId: this.state.currentTurnId, - renderMode: 'plain', - content: status.headline, - detail: status.detail, - backgroundAgentStatus: status, - }; - this.appendTranscriptEntry(entry); - } - - // Updates the footer badge for active background agents. - private syncBackgroundAgentBadge(): void { - this.syncBackgroundTaskBadge(); - } - - // ========================================================================= - // Background task lifecycle (BPM-derived, covers both bash + agent tasks) - // ========================================================================= - - private handleBackgroundTaskEvent( - event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent, - ): void { - const { info } = event; - const previous = this.state.backgroundTasks.get(info.taskId); - this.state.backgroundTasks.set(info.taskId, info); - - // If the user is currently viewing this task's output, nudge a - // refresh immediately so they see new content without waiting for - // the 1s poll. Same dedupe-by-output-equality applies inside. - const viewer = this.state.tasksBrowser?.viewer; - if (viewer !== undefined && viewer.taskId === info.taskId) { - void this.refreshTaskOutputViewer({ silent: true }); - } - - const isTerminal = - info.status === 'completed' || - info.status === 'failed' || - info.status === 'killed' || - info.status === 'lost'; - - if (event.type === 'background.task.started') { - // For agent-* tasks, the legacy subagent.spawned flow already - // pushed a 'started' transcript card; skip to avoid duplicates. - if (info.taskId.startsWith('agent-')) { - this.syncBackgroundTaskBadge(); - this.repaintTasksBrowser(); - return; - } - this.appendBackgroundTaskEntry(info); - this.syncBackgroundTaskBadge(); - this.repaintTasksBrowser(); - return; - } - - if (event.type === 'background.task.terminated' && isTerminal) { - if (!this.state.backgroundTaskTranscriptedTerminal.has(info.taskId)) { - // For agent-* tasks, the older subagent.completed/failed flow - // may also produce a terminal card; whoever wins records the - // dedupe marker first. See handleSubagentCompleted/Failed. - if (info.taskId.startsWith('bash-')) { - this.appendBackgroundTaskEntry(info); - } - this.state.backgroundTaskTranscriptedTerminal.add(info.taskId); - } - this.syncBackgroundTaskBadge(); - this.repaintTasksBrowser(); - return; - } - - // updated: status flipped between running and awaiting_approval. - // No transcript card — just sync the badge if the active count - // changed (awaiting_approval still counts as active). - if (previous?.status !== info.status) { - this.syncBackgroundTaskBadge(); - } - this.repaintTasksBrowser(); - } - - private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { - const status = formatBackgroundTaskTranscript(info); - const entry: TranscriptEntry = { - id: nextTranscriptId(), - kind: 'status', - turnId: this.state.currentTurnId, - renderMode: 'plain', - content: status.headline, - detail: status.detail, - backgroundAgentStatus: status, - }; - this.appendTranscriptEntry(entry); - } - - // Footer counts are BPM-derived: every task that is not terminal, - // split by id prefix so bash and agent badges render independently. - // awaiting_approval still counts as active; lost/killed/completed/ - // failed do not. - private syncBackgroundTaskBadge(): void { - let bashTasks = 0; - let agentTasks = 0; - for (const info of this.state.backgroundTasks.values()) { - if ( - info.status === 'completed' || - info.status === 'failed' || - info.status === 'killed' || - info.status === 'lost' - ) { - continue; - } - if (info.taskId.startsWith('agent-')) { - agentTasks += 1; - } else { - bashTasks += 1; - } - } - this.state.footer.setBackgroundCounts({ bashTasks, agentTasks }); - this.state.ui.requestRender(); + this.sessionEventHandler.handleEvent(event, sendQueued); } + private stopAllMcpServerStatusSpinners(): void { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); } // ========================================================================= // Live Render Hooks — delegated to StreamingUIController @@ -3560,7 +2608,7 @@ export class KimiTUI { } // Stores a transcript entry and mounts its component if renderable. - private appendTranscriptEntry(entry: TranscriptEntry): void { + appendTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); const component = this.createTranscriptComponent(entry); if (component) { @@ -3648,7 +2696,7 @@ export class KimiTUI { } // Appends a status message to the transcript. - private showStatus(message: string, color?: string): void { + showStatus(message: string, color?: string): void { this.state.transcriptContainer.addChild( new StatusMessageComponent(message, this.state.theme.colors, color), ); @@ -3656,7 +2704,7 @@ export class KimiTUI { } // Appends a notice message to the transcript. - private showNotice(title: string, detail?: string): void { + showNotice(title: string, detail?: string): void { this.state.transcriptContainer.addChild( new NoticeMessageComponent(title, detail, this.state.theme.colors), ); @@ -4058,8 +3106,8 @@ export class KimiTUI { private showTasksBrowser(): Promise { return this.tasksBrowserController.show(); } private closeTasksBrowser(): void { this.tasksBrowserController.close(); } - private repaintTasksBrowser(): void { this.tasksBrowserController.repaint(); } - private refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise { + repaintTasksBrowser(): void { this.tasksBrowserController.repaint(); } + refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise { return this.tasksBrowserController.refreshOutputViewer(opts); } From d3541edc9a2d59ac4b6dbed549f27e7c66ccfea6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 14:48:15 +0800 Subject: [PATCH 04/28] refactor(tui): extract SessionReplayRenderer from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Session Replay 的 21 个方法(~410 行)从 KimiTUI 提取到 SessionReplayRenderer。包含会话历史回放水合(snapshot hydration)、 replay record 渲染、thinking/assistant/tool call 回放、skill 激活、 权限变更、审批结果、background task 通知等逻辑。 kimi-tui.ts: 4316 → 3911 行 (-405) --- .../src/tui/controllers/session-replay.ts | 481 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 417 +-------------- 2 files changed, 487 insertions(+), 411 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/session-replay.ts diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts new file mode 100644 index 0000000000..c263b5e23b --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -0,0 +1,481 @@ +import type { + AgentReplayRecord, + BackgroundTaskInfo, + ContextMessage, + PermissionMode, + PromptOrigin, + ResumedAgentState, + Session, + ToolCall, +} from '@moonshot-ai/kimi-code-sdk'; + +import { ToolCallComponent } from '../components/messages/tool-call'; +import type { TodoItem } from '../components/chrome/todo-panel'; +import type { + AppState, + BackgroundAgentMetadata, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { + appStateFromResumeAgent, + backgroundOrigin, + collectReplayMessageContent, + contentPartsToText, + countActiveBackgroundTasks, + createReplayRenderContext, + formatHookResultMessageForTranscript, + isTerminalBackgroundTask, + limitReplayRecordsByTurn, + REPLAY_TURN_LIMIT, + replayBackgroundProjection, + replayEntry, + skillActivationFromOrigin, + toolCallFromReplayMessage, + toolResultOutput, + type ReplayRenderContext, + type SkillActivationProjection, +} from '../utils/message-replay'; +import type { StreamingUIController } from './streaming-ui'; +import type { TUIState } from '../kimi-tui'; + +export interface SessionReplayHost { + state: TUIState; + readonly streamingUI: StreamingUIController; + setAppState(patch: Partial): void; + showError(msg: string): void; + appendTranscriptEntry(entry: TranscriptEntry): void; +} + +export class SessionReplayRenderer { + constructor(private readonly host: SessionReplayHost) {} + + async hydrateFromReplay(session: Session): Promise { + this.host.setAppState({ isReplaying: true }); + try { + const main = session.getResumeState()?.agents['main']; + if (main === undefined) { + this.host.showError('Session history is unavailable for this session.'); + return false; + } + + this.hydrateSnapshot(main); + this.renderRecords(main); + return true; + } catch (error) { + const message = formatErrorMessage(error); + this.host.showError(`Failed to replay session history: ${message}`); + return false; + } finally { + this.host.setAppState({ isReplaying: false }); + } + } + + // --------------------------------------------------------------------------- + // Snapshot hydration + // --------------------------------------------------------------------------- + + private hydrateSnapshot(agent: ResumedAgentState): void { + this.host.setAppState(appStateFromResumeAgent(agent)); + this.hydrateTodoPanel(agent); + this.hydrateBackgroundState(agent); + } + + private hydrateTodoPanel(agent: ResumedAgentState): void { + const rawTodos = agent.toolStore?.['todo']; + if (!Array.isArray(rawTodos)) { + this.host.streamingUI.setTodoList([]); + return; + } + + const todos = rawTodos + .filter((todo): todo is TodoItem => isTodoItemShape(todo)) + .map((todo) => ({ title: todo.title, status: todo.status })); + if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { + this.host.streamingUI.setTodoList([]); + return; + } + + this.host.streamingUI.setTodoList(todos); + } + + private hydrateBackgroundState(agent: ResumedAgentState): void { + const { state } = this.host; + const projection = replayBackgroundProjection(agent.background); + state.backgroundAgents = new Set(projection.backgroundAgents); + state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); + state.backgroundTasks = new Map( + agent.background.map((info) => [info.taskId, info]), + ); + state.backgroundTaskTranscriptedTerminal.clear(); + for (const info of agent.background) { + if (isTerminalBackgroundTask(info)) { + state.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + } + state.footer.setBackgroundCounts(countActiveBackgroundTasks(state.backgroundTasks)); + state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Record rendering + // --------------------------------------------------------------------------- + + private renderRecords(agent: ResumedAgentState): void { + const context = createReplayRenderContext(); + for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) { + this.renderRecord(context, record); + } + this.flushAssistant(context); + this.cleanupRuntime(context); + } + + private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { + switch (record.type) { + case 'message': + this.renderMessage(context, record.message); + return; + case 'plan_updated': + this.flushAssistant(context); + if (!record.enabled && context.suppressNextPlanModeOffNotice) { + context.suppressNextPlanModeOffNotice = false; + return; + } + context.suppressNextPlanModeOffNotice = false; + this.host.appendTranscriptEntry( + replayEntry(context, 'status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice'), + ); + return; + case 'permission_updated': + this.flushAssistant(context); + this.renderPermissionUpdate(context, record.mode); + return; + case 'approval_result': + this.flushAssistant(context); + this.renderApprovalResult(context, record.record); + return; + case 'config_updated': + return; + } + } + + private renderMessage(context: ReplayRenderContext, message: ContextMessage): void { + switch (message.role) { + case 'user': + this.renderUserMessage(context, message); + return; + case 'assistant': + if (message.origin?.kind === 'hook_result') { + this.renderHookResult(context, message); + this.renderToolCalls(context, message.toolCalls); + return; + } + collectReplayMessageContent(context.assistant, message.content); + this.flushAssistant(context); + this.renderToolCalls(context, message.toolCalls); + return; + case 'tool': + this.flushAssistant(context); + this.renderToolResult(context, message); + return; + case 'system': + return; + default: + return; + } + } + + private renderUserMessage(context: ReplayRenderContext, message: ContextMessage): void { + const origin = backgroundOrigin(message); + if (origin !== undefined) { + this.flushAssistant(context); + this.renderBackgroundTaskNotification(context, origin); + return; + } + if (message.origin?.kind === 'hook_result') { + this.renderHookResult(context, message); + return; + } + if (message.origin?.kind === 'injection') { + return; + } + + this.flushAssistant(context); + const skill = skillActivationFromOrigin(message.origin); + if (skill !== undefined) { + this.renderSkillActivation(context, skill); + if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } + + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), + ); + } + + private renderToolCalls(context: ReplayRenderContext, toolCalls: readonly ToolCall[]): void { + if (toolCalls.length === 0) return; + const { state, streamingUI } = this.host; + context.stepIndex += 1; + this.applyStepContext(context); + for (const rawToolCall of toolCalls) { + const toolCall = toolCallFromReplayMessage(rawToolCall, context); + if (toolCall === undefined) continue; + context.toolCalls.set(toolCall.id, toolCall); + state.activeToolCalls.set(toolCall.id, toolCall); + streamingUI.onToolCallStart(toolCall); + } + } + + private renderToolResult(context: ReplayRenderContext, message: ContextMessage): void { + const toolCallId = message.toolCallId; + if (toolCallId === undefined) return; + const call = context.toolCalls.get(toolCallId); + if (call === undefined) return; + + const result: ToolResultBlockData = { + tool_call_id: toolCallId, + output: toolResultOutput(message.content), + is_error: message.isError, + }; + call.result = result; + this.applyStepContext(context); + this.host.streamingUI.onToolCallEnd(toolCallId, result); + this.host.state.activeToolCalls.delete(toolCallId); + context.completedToolCallIds.add(toolCallId); + } + + private advanceTurn(context: ReplayRenderContext): void { + context.turnIndex += 1; + context.stepIndex = 0; + context.currentTurnId = `replay:${String(context.turnIndex)}`; + this.applyStepContext(context); + } + + private applyStepContext(context: ReplayRenderContext): void { + this.host.state.currentTurnId = context.currentTurnId; + this.host.state.currentStep = context.stepIndex; + } + + private flushAssistant(context: ReplayRenderContext): void { + const { state, streamingUI } = this.host; + const thinking = context.assistant.thinking.join(''); + const text = context.assistant.text.join(''); + context.assistant = { thinking: [], text: [] }; + this.applyStepContext(context); + + if (thinking.length > 0) { + streamingUI.onThinkingUpdate(thinking); + streamingUI.onThinkingEnd(); + } + if (text.length > 0) { + state.assistantStreamActive = true; + streamingUI.onStreamingTextStart(); + streamingUI.onStreamingTextUpdate(text); + streamingUI.onStreamingTextEnd(); + state.assistantStreamActive = false; + state.assistantDraft = ''; + } + } + + private cleanupRuntime(context: ReplayRenderContext): void { + const { state, streamingUI } = this.host; + this.flushAssistant(context); + state.activeToolCalls.clear(); + for (const toolCallId of context.completedToolCallIds) { + state.pendingToolComponents.delete(toolCallId); + } + state.pendingAgentGroup = null; + state.pendingReadGroup = null; + state.currentTurnId = undefined; + state.currentStep = 0; + state.streamingToolCallArguments.clear(); + streamingUI.pendingToolCallFlushIds.clear(); + state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Special content renderers + // --------------------------------------------------------------------------- + + private renderSkillActivation( + context: ReplayRenderContext, + skill: SkillActivationProjection, + ): void { + const { state } = this.host; + if (context.skillActivationIds.has(skill.activationId)) return; + if (state.renderedSkillActivationIds.has(skill.activationId)) return; + context.skillActivationIds.add(skill.activationId); + state.renderedSkillActivationIds.add(skill.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), + skillActivationId: skill.activationId, + skillName: skill.skillName, + skillArgs: skill.skillArgs, + }); + } + + private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { + if (message.origin?.kind !== 'hook_result') return; + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry( + context, + 'assistant', + formatHookResultMessageForTranscript( + contentPartsToText(message.content), + message.origin.event, + message.origin.blocked === true, + ), + 'markdown', + ), + ); + } + + private renderPermissionUpdate(context: ReplayRenderContext, mode: PermissionMode): void { + if (mode === 'yolo') { + this.host.appendTranscriptEntry( + replayEntry(context, 'status', 'YOLO mode: ON', 'notice', { + detail: 'All actions will be approved automatically. Use with caution.', + }), + ); + return; + } + this.host.appendTranscriptEntry( + replayEntry( + context, + 'status', + mode === 'manual' ? 'YOLO mode: OFF' : `Permission mode: ${mode}`, + 'notice', + ), + ); + } + + private renderApprovalResult( + context: ReplayRenderContext, + record: Extract['record'], + ): void { + if (record.toolName === 'ExitPlanMode') { + this.renderPlanReviewResult(context, record); + return; + } + + const { result } = record; + const parts: string[] = []; + switch (result.decision) { + case 'approved': + parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); + break; + case 'rejected': + parts.push('Rejected'); + break; + case 'cancelled': + parts.push('Cancelled'); + break; + } + parts.push(`: ${record.action}`); + if (result.feedback !== undefined && result.feedback.length > 0) { + parts.push(` — "${result.feedback}"`); + } + this.host.appendTranscriptEntry(replayEntry(context, 'status', parts.join(''), 'notice')); + } + + private renderPlanReviewResult( + context: ReplayRenderContext, + record: Extract['record'], + ): void { + const { result } = record; + if (result.decision === 'approved') { + context.suppressNextPlanModeOffNotice = true; + return; + } + this.removeToolCall(record.toolCallId); + + let content: string; + switch (result.decision) { + case 'rejected': + content = + result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; + break; + case 'cancelled': + content = 'Plan review cancelled'; + break; + } + const detail = + result.feedback !== undefined && result.feedback.length > 0 + ? `Feedback: ${result.feedback}` + : undefined; + this.host.appendTranscriptEntry(replayEntry(context, 'status', content, 'notice', { detail })); + } + + private removeToolCall(toolCallId: string): void { + const { state } = this.host; + state.activeToolCalls.delete(toolCallId); + state.pendingToolComponents.delete(toolCallId); + const index = state.transcriptEntries.findIndex( + (entry) => entry.toolCallData?.id === toolCallId, + ); + if (index >= 0) state.transcriptEntries.splice(index, 1); + const children = state.transcriptContainer.children; + const childIndex = children.findIndex( + (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, + ); + if (childIndex >= 0) { + children.splice(childIndex, 1); + state.transcriptContainer.invalidate(); + } + } + + private renderBackgroundTaskNotification( + context: ReplayRenderContext, + origin: Extract, + ): void { + const { state } = this.host; + const task = state.backgroundTasks.get(origin.taskId); + if (task !== undefined && task.taskId.startsWith('bash-')) { + const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + state.backgroundTaskTranscriptedTerminal.add(origin.taskId); + return; + } + + const meta: BackgroundAgentMetadata = { + agentId: origin.taskId, + parentToolCallId: origin.taskId, + description: task?.description, + }; + let status = formatBackgroundAgentTranscript( + origin.status === 'completed' ? 'completed' : 'failed', + meta, + ); + if (origin.status === 'lost') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' lost in background'), + }; + } else if (origin.status === 'killed') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' stopped'), + }; + } + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + state.backgroundAgents.delete(meta.agentId); + state.backgroundAgentMetadata.delete(meta.agentId); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cc1b5bc73e..c54144f170 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -148,6 +148,7 @@ import { PermissionSelectorComponent } from './components/dialogs/permission-sel import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { SessionEventHandler } from './controllers/session-event-handler'; +import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; import { @@ -582,6 +583,7 @@ export class KimiTUI { private readonly migrateOnly: boolean; readonly streamingUI: StreamingUIController; private readonly sessionEventHandler: SessionEventHandler; + private readonly sessionReplay: SessionReplayRenderer; private readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -633,6 +635,7 @@ export class KimiTUI { ); this.streamingUI = new StreamingUIController(this); this.sessionEventHandler = new SessionEventHandler(this); + this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); this.setupEditorHandlers(); this.buildLayout(); @@ -2082,419 +2085,11 @@ export class KimiTUI { } // ========================================================================= - // Session Replay + // Session Replay — delegated to SessionReplayRenderer // ========================================================================= - private async hydrateTranscriptFromReplay(session: Session): Promise { - this.setAppState({ isReplaying: true }); - try { - const main = session.getResumeState()?.agents['main']; - if (main === undefined) { - this.showError('Session history is unavailable for this session.'); - return false; - } - - this.hydrateReplaySnapshot(main); - this.renderReplayRecords(main); - return true; - } catch (error) { - const message = formatErrorMessage(error); - this.showError(`Failed to replay session history: ${message}`); - return false; - } finally { - this.setAppState({ isReplaying: false }); - } - } - - private hydrateReplaySnapshot(agent: ResumedAgentState): void { - this.setAppState(appStateFromResumeAgent(agent)); - this.hydrateTodoPanelFromResume(agent); - this.hydrateBackgroundStateFromResume(agent); - } - - private hydrateTodoPanelFromResume(agent: ResumedAgentState): void { - const rawTodos = agent.toolStore?.['todo']; - if (!Array.isArray(rawTodos)) { - this.setTodoList([]); - return; - } - - const todos = rawTodos - .filter((todo): todo is TodoItem => isTodoItemShape(todo)) - .map((todo) => ({ title: todo.title, status: todo.status })); - if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { - this.setTodoList([]); - return; - } - - this.setTodoList(todos); - } - - private hydrateBackgroundStateFromResume(agent: ResumedAgentState): void { - const projection = replayBackgroundProjection(agent.background); - this.state.backgroundAgents = new Set(projection.backgroundAgents); - this.state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); - this.state.backgroundTasks = new Map( - agent.background.map((info) => [info.taskId, info]), - ); - this.state.backgroundTaskTranscriptedTerminal.clear(); - for (const info of agent.background) { - if (isTerminalBackgroundTask(info)) { - this.state.backgroundTaskTranscriptedTerminal.add(info.taskId); - } - } - this.state.footer.setBackgroundCounts(countActiveBackgroundTasks(this.state.backgroundTasks)); - this.state.ui.requestRender(); - } - - private renderReplayRecords(agent: ResumedAgentState): void { - const context = createReplayRenderContext(); - for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) { - this.renderReplayRecord(context, record); - } - this.flushReplayAssistant(context); - this.cleanupReplayRuntime(context); - } - - private renderReplayRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { - switch (record.type) { - case 'message': - this.renderReplayMessage(context, record.message); - return; - case 'plan_updated': - this.flushReplayAssistant(context); - if (!record.enabled && context.suppressNextPlanModeOffNotice) { - context.suppressNextPlanModeOffNotice = false; - return; - } - context.suppressNextPlanModeOffNotice = false; - this.appendTranscriptEntry( - replayEntry(context, 'status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice'), - ); - return; - case 'permission_updated': - this.flushReplayAssistant(context); - this.renderReplayPermissionUpdate(context, record.mode); - return; - case 'approval_result': - this.flushReplayAssistant(context); - this.renderReplayApprovalResult(context, record.record); - return; - case 'config_updated': - return; - } - } - - private renderReplayMessage(context: ReplayRenderContext, message: ContextMessage): void { - switch (message.role) { - case 'user': - this.renderReplayUserMessage(context, message); - return; - case 'assistant': - if (message.origin?.kind === 'hook_result') { - this.renderReplayHookResult(context, message); - this.renderReplayToolCalls(context, message.toolCalls); - return; - } - collectReplayMessageContent(context.assistant, message.content); - this.flushReplayAssistant(context); - this.renderReplayToolCalls(context, message.toolCalls); - return; - case 'tool': - this.flushReplayAssistant(context); - this.renderReplayToolResult(context, message); - return; - case 'system': - return; - default: - return; - } - } - - private renderReplayUserMessage(context: ReplayRenderContext, message: ContextMessage): void { - const origin = backgroundOrigin(message); - if (origin !== undefined) { - this.flushReplayAssistant(context); - this.renderReplayBackgroundTaskNotification(context, origin); - return; - } - if (message.origin?.kind === 'hook_result') { - this.renderReplayHookResult(context, message); - return; - } - if (message.origin?.kind === 'injection') { - return; - } - - this.flushReplayAssistant(context); - const skill = skillActivationFromOrigin(message.origin); - if (skill !== undefined) { - this.renderReplaySkillActivation(context, skill); - if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') { - this.advanceReplayTurn(context); - } - return; - } - - this.advanceReplayTurn(context); - this.appendTranscriptEntry( - replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), - ); - } - - private renderReplayToolCalls( - context: ReplayRenderContext, - toolCalls: readonly ToolCall[], - ): void { - if (toolCalls.length === 0) return; - context.stepIndex += 1; - this.applyReplayStepContext(context); - for (const rawToolCall of toolCalls) { - const toolCall = toolCallFromReplayMessage(rawToolCall, context); - if (toolCall === undefined) continue; - context.toolCalls.set(toolCall.id, toolCall); - this.state.activeToolCalls.set(toolCall.id, toolCall); - this.onToolCallStart(toolCall); - } - } - - private renderReplayToolResult(context: ReplayRenderContext, message: ContextMessage): void { - const toolCallId = message.toolCallId; - if (toolCallId === undefined) return; - const call = context.toolCalls.get(toolCallId); - if (call === undefined) return; - - const result: ToolResultBlockData = { - tool_call_id: toolCallId, - output: toolResultOutput(message.content), - is_error: message.isError, - }; - call.result = result; - this.applyReplayStepContext(context); - this.onToolCallEnd(toolCallId, result); - this.state.activeToolCalls.delete(toolCallId); - context.completedToolCallIds.add(toolCallId); - } - - private advanceReplayTurn(context: ReplayRenderContext): void { - context.turnIndex += 1; - context.stepIndex = 0; - context.currentTurnId = `replay:${String(context.turnIndex)}`; - this.applyReplayStepContext(context); - } - - private applyReplayStepContext(context: ReplayRenderContext): void { - this.state.currentTurnId = context.currentTurnId; - this.state.currentStep = context.stepIndex; - } - - private flushReplayAssistant(context: ReplayRenderContext): void { - const thinking = context.assistant.thinking.join(''); - const text = context.assistant.text.join(''); - context.assistant = { thinking: [], text: [] }; - this.applyReplayStepContext(context); - - if (thinking.length > 0) { - this.onThinkingUpdate(thinking); - this.onThinkingEnd(); - } - if (text.length > 0) { - this.state.assistantStreamActive = true; - this.onStreamingTextStart(); - this.onStreamingTextUpdate(text); - this.onStreamingTextEnd(); - this.state.assistantStreamActive = false; - this.state.assistantDraft = ''; - } - } - - private cleanupReplayRuntime(context: ReplayRenderContext): void { - this.flushReplayAssistant(context); - this.state.activeToolCalls.clear(); - for (const toolCallId of context.completedToolCallIds) { - this.state.pendingToolComponents.delete(toolCallId); - } - this.state.pendingAgentGroup = null; - this.state.pendingReadGroup = null; - this.state.currentTurnId = undefined; - this.state.currentStep = 0; - this.state.streamingToolCallArguments.clear(); - this.streamingUI.pendingToolCallFlushIds.clear(); - this.state.ui.requestRender(); - } - - private renderReplaySkillActivation( - context: ReplayRenderContext, - skill: SkillActivationProjection, - ): void { - if (context.skillActivationIds.has(skill.activationId)) return; - if (this.state.renderedSkillActivationIds.has(skill.activationId)) return; - context.skillActivationIds.add(skill.activationId); - this.state.renderedSkillActivationIds.add(skill.activationId); - this.appendTranscriptEntry({ - ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), - skillActivationId: skill.activationId, - skillName: skill.skillName, - skillArgs: skill.skillArgs, - }); - } - - private renderReplayHookResult(context: ReplayRenderContext, message: ContextMessage): void { - if (message.origin?.kind !== 'hook_result') return; - this.flushReplayAssistant(context); - this.appendTranscriptEntry( - replayEntry( - context, - 'assistant', - formatHookResultMessageForTranscript( - contentPartsToText(message.content), - message.origin.event, - message.origin.blocked === true, - ), - 'markdown', - ), - ); - } - - private renderReplayPermissionUpdate( - context: ReplayRenderContext, - mode: PermissionMode, - ): void { - if (mode === 'yolo') { - this.appendTranscriptEntry( - replayEntry(context, 'status', 'YOLO mode: ON', 'notice', { - detail: 'All actions will be approved automatically. Use with caution.', - }), - ); - return; - } - this.appendTranscriptEntry( - replayEntry( - context, - 'status', - mode === 'manual' ? 'YOLO mode: OFF' : `Permission mode: ${mode}`, - 'notice', - ), - ); - } - - private renderReplayApprovalResult( - context: ReplayRenderContext, - record: Extract['record'], - ): void { - if (record.toolName === 'ExitPlanMode') { - this.renderReplayPlanReviewResult(context, record); - return; - } - - const { result } = record; - const parts: string[] = []; - switch (result.decision) { - case 'approved': - parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved'); - break; - case 'rejected': - parts.push('Rejected'); - break; - case 'cancelled': - parts.push('Cancelled'); - break; - } - parts.push(`: ${record.action}`); - if (result.feedback !== undefined && result.feedback.length > 0) { - parts.push(` — "${result.feedback}"`); - } - this.appendTranscriptEntry(replayEntry(context, 'status', parts.join(''), 'notice')); - } - - private renderReplayPlanReviewResult( - context: ReplayRenderContext, - record: Extract['record'], - ): void { - const { result } = record; - if (result.decision === 'approved') { - context.suppressNextPlanModeOffNotice = true; - return; - } - this.removeReplayToolCall(record.toolCallId); - - let content: string; - switch (result.decision) { - case 'rejected': - content = - result.selectedLabel === 'Revise' ? 'Plan sent back for revision' : 'Plan review rejected'; - break; - case 'cancelled': - content = 'Plan review cancelled'; - break; - } - const detail = - result.feedback !== undefined && result.feedback.length > 0 - ? `Feedback: ${result.feedback}` - : undefined; - this.appendTranscriptEntry(replayEntry(context, 'status', content, 'notice', { detail })); - } - - private removeReplayToolCall(toolCallId: string): void { - this.state.activeToolCalls.delete(toolCallId); - this.state.pendingToolComponents.delete(toolCallId); - const index = this.state.transcriptEntries.findIndex( - (entry) => entry.toolCallData?.id === toolCallId, - ); - if (index >= 0) this.state.transcriptEntries.splice(index, 1); - const children = this.state.transcriptContainer.children; - const childIndex = children.findIndex( - (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, - ); - if (childIndex >= 0) { - children.splice(childIndex, 1); - this.state.transcriptContainer.invalidate(); - } - } - - private renderReplayBackgroundTaskNotification( - context: ReplayRenderContext, - origin: Extract, - ): void { - const task = this.state.backgroundTasks.get(origin.taskId); - if (task !== undefined && task.taskId.startsWith('bash-')) { - const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); - this.appendTranscriptEntry({ - ...replayEntry(context, 'status', status.headline, 'plain'), - detail: status.detail, - backgroundAgentStatus: status, - }); - this.state.backgroundTaskTranscriptedTerminal.add(origin.taskId); - return; - } - - const meta: BackgroundAgentMetadata = { - agentId: origin.taskId, - parentToolCallId: origin.taskId, - description: task?.description, - }; - let status = formatBackgroundAgentTranscript( - origin.status === 'completed' ? 'completed' : 'failed', - meta, - ); - if (origin.status === 'lost') { - status = { - ...status, - headline: status.headline.replace(' failed in background', ' lost in background'), - }; - } else if (origin.status === 'killed') { - status = { - ...status, - headline: status.headline.replace(' failed in background', ' stopped'), - }; - } - this.appendTranscriptEntry({ - ...replayEntry(context, 'status', status.headline, 'plain'), - detail: status.detail, - backgroundAgentStatus: status, - }); - this.state.backgroundAgents.delete(meta.agentId); - this.state.backgroundAgentMetadata.delete(meta.agentId); + private hydrateTranscriptFromReplay(session: Session): Promise { + return this.sessionReplay.hydrateFromReplay(session); } // ========================================================================= From 37bac0c7cd49f52723d24e48ded76d947d914d78 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 15:19:21 +0800 Subject: [PATCH 05/28] refactor(tui): extract slash command handlers from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Slash Command Handlers 区域(~765 行)从 KimiTUI 提取到两个文件: - slash-commands.ts (684 行):命令逻辑(plan/yolo/compact/editor/theme/ model/title/fork/init/login/connect/logout/feedback) - slash-command-prompts.ts (183 行):UI prompt 函数(platform selection、 logout provider、feedback input、API key、model selector 等) prompt 函数拆为独立模块避免 ESM 同模块内部调用 mock 不生效的问题。 测试改为 vi.mock 对应的 prompt 模块。 kimi-tui.ts: 3911 → 3163 行 (-748) --- .../tui/controllers/slash-command-prompts.ts | 183 ++++ .../src/tui/controllers/slash-commands.ts | 687 ++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 836 +----------------- .../test/tui/kimi-tui-message-flow.test.ts | 18 +- .../test/tui/kimi-tui-startup.test.ts | 25 +- 5 files changed, 944 insertions(+), 805 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/slash-command-prompts.ts create mode 100644 apps/kimi-code/src/tui/controllers/slash-commands.ts diff --git a/apps/kimi-code/src/tui/controllers/slash-command-prompts.ts b/apps/kimi-code/src/tui/controllers/slash-command-prompts.ts new file mode 100644 index 0000000000..563f90ba1a --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/slash-command-prompts.ts @@ -0,0 +1,183 @@ +import { + catalogModelToAlias, + inferWireType, + type Catalog, + type CatalogModel, + type ModelAlias, +} from '@moonshot-ai/kimi-code-sdk'; +import { capabilitiesForModel } from '@moonshot-ai/kimi-code-oauth'; +import type { + ManagedKimiCodeModelInfo, + OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; + +import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog'; +import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; +import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; +import { ModelSelectorComponent } from '../components/dialogs/model-selector'; +import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; +import type { SlashCommandHost } from './slash-commands'; + +export function promptPlatformSelection(host: SlashCommandHost): Promise { + return new Promise((resolve) => { + const selector = new PlatformSelectorComponent({ + colors: host.state.theme.colors, + onSelect: (platformId) => { + host.restoreEditor(); + resolve(platformId); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); +} + +export function promptLogoutProviderSelection( + host: SlashCommandHost, + options: readonly ChoiceOption[], + currentValue: string | undefined, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Select a provider to log out', + options, + currentValue, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise { + return new Promise((resolve) => { + const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, host.state.theme.colors); + host.mountEditorReplacement(dialog); + }); +} + +export function promptApiKey(host: SlashCommandHost, platformName: string): Promise { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + host.state.theme.colors, + ); + host.mountEditorReplacement(dialog); + }); +} + +export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise { + return new Promise((resolve) => { + const options: ChoiceOption[] = Object.entries(catalog) + .filter(([, entry]) => inferWireType(entry) !== undefined) + .map(([id, entry]) => ({ + value: id, + label: entry.name ?? id, + description: + typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined, + })) + .toSorted((a, b) => a.label.localeCompare(b.label)); + + if (options.length === 0) { + host.showError('Catalog has no providers with supported wire types.'); + resolve(undefined); + return; + } + + const picker = new ChoicePickerComponent({ + title: 'Select a provider', + options, + colors: host.state.theme.colors, + searchable: true, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +export async function promptModelSelectionForOpenPlatform( + host: SlashCommandHost, + models: ManagedKimiCodeModelInfo[], + platform: OpenPlatformDefinition, +): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { + const modelDict: Record = {}; + for (const m of models) { + modelDict[`${platform.id}/${m.id}`] = { + provider: platform.id, + model: m.id, + maxContextSize: m.contextLength, + capabilities: capabilitiesForModel(m), + displayName: m.displayName, + }; + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) return undefined; + const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias); + return model ? { model, thinking: selection.thinking } : undefined; +} + +export async function promptModelSelectionForCatalog( + host: SlashCommandHost, + providerId: string, + models: CatalogModel[], +): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { + const modelDict: Record = {}; + for (const m of models) { + modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) return undefined; + const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); + return model ? { model, thinking: selection.thinking } : undefined; +} + +export function runModelSelector( + host: SlashCommandHost, + modelDict: Record, +): Promise<{ alias: string; thinking: boolean } | undefined> { + return new Promise((resolve) => { + const firstAlias = Object.keys(modelDict)[0] ?? ''; + const caps = modelDict[firstAlias]?.capabilities ?? []; + const initialThinking = caps.includes('always_thinking') || caps.includes('thinking'); + const selector = new ModelSelectorComponent({ + models: modelDict, + currentValue: firstAlias, + currentThinking: initialThinking, + colors: host.state.theme.colors, + searchable: true, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + resolve({ alias, thinking }); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(selector); + }); +} diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts new file mode 100644 index 0000000000..bf87b58181 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -0,0 +1,687 @@ +import { release as osRelease, type as osType } from 'node:os'; + +import { + applyOpenPlatformConfig, + capabilitiesForModel, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + OpenPlatformApiError, + type DeviceAuthorization, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, + type OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogBaseUrl, + catalogModelToAlias, + catalogProviderModels, + CatalogFetchError, + fetchCatalog, + inferWireType, + loadBuiltInCatalog, + log, + type Catalog, + type CatalogModel, + type KimiHarness, + type ModelAlias, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import type { Component, Focusable } from '@earendil-works/pi-tui'; + +import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; +import type { ChoiceOption } from '../components/dialogs/choice-picker'; +import type { Theme } from '../theme'; +import { + promptApiKey, + promptCatalogProviderSelection, + promptFeedbackInput, + promptLogoutProviderSelection, + promptModelSelectionForCatalog, + promptModelSelectionForOpenPlatform, + promptPlatformSelection, + runModelSelector, +} from './slash-command-prompts'; +import { + DEFAULT_OAUTH_PROVIDER_NAME, + LLM_NOT_SET_MESSAGE, + NO_ACTIVE_SESSION_MESSAGE, + PRODUCT_NAME, +} from '../constant/kimi-tui'; +import { + FEEDBACK_ISSUE_URL, + FEEDBACK_STATUS_CANCELLED, + FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NOT_SIGNED_IN, + FEEDBACK_STATUS_SUBMITTING, + FEEDBACK_STATUS_SUCCESS, + FEEDBACK_TELEMETRY_EVENT, + feedbackSessionLine, + withFeedbackVersionPrefix, +} from '../constant/feedback'; +import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { isTheme } from '../theme/index'; +import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; +import { isAbortError } from '../utils/errors'; +import { formatErrorMessage } from '../utils/event-payload'; +import { openUrl } from '../utils/open-url'; +import type { AppState, QueuedMessage } from '../types'; +import type { TUIState, LoginProgressSpinnerHandle } from '../kimi-tui'; + +export interface SlashCommandHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + cancelInFlight: (() => void) | undefined; + deferUserMessages: boolean; + + setAppState(patch: Partial): void; + resetLivePane(): void; + showError(msg: string): void; + showStatus(msg: string, color?: string): void; + showNotice(title: string, detail?: string): void; + track(event: string, props?: Record): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + + // Session + requireSession(): Session; + switchToSession(session: Session, message: string): Promise; + beginSessionRequest(): void; + failSessionRequest(message: string): void; + finalizeTurn(sendQueued: (item: QueuedMessage) => void): void; + sendQueuedMessage(session: Session, item: QueuedMessage): void; + + // Pickers (remain on KimiTUI) + showEditorPicker(): void; + showModelPicker(selectedValue?: string): void; + showThemePicker(): void; + showPermissionPicker(): void; + showSettingsSelector(): void; + showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; + + // Config (remain on KimiTUI) + applyEditorChoice(value: string): Promise; + applyThemeChoice(theme: Theme): Promise; + refreshConfigAfterLogin(): Promise; + refreshConfigAfterLogout(): Promise; + clearActiveSessionAfterLogout(): Promise; + +} + +// --------------------------------------------------------------------------- +// Plan / Config commands +// --------------------------------------------------------------------------- + +export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const subcmd = args.trim().toLowerCase(); + if (subcmd === 'clear') { + await session.clearPlan(); + host.showNotice('Plan cleared'); + return; + } + + let enabled: boolean; + if (subcmd.length === 0) enabled = !host.state.appState.planMode; + else if (subcmd === 'on') enabled = true; + else if (subcmd === 'off') enabled = false; + else { + host.showError(`Unknown plan subcommand: ${subcmd}`); + return; + } + + await applyPlanMode(host, session, enabled); +} + +async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise { + try { + await session.setPlanMode(enabled); + host.setAppState({ planMode: enabled }); + if (enabled) { + const plan = await session.getPlan().catch(() => null); + host.showNotice( + 'Plan mode: ON', + plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, + ); + return; + } + host.showNotice('Plan mode: OFF'); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set plan mode: ${msg}`); + } +} + +export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let enabled: boolean; + if (args === 'on') enabled = true; + else if (args === 'off') enabled = false; + else enabled = !host.state.appState.yolo; + + await session.setPermission(enabled ? 'yolo' : 'manual'); + host.setAppState({ yolo: enabled, permissionMode: enabled ? 'yolo' : 'manual' }); + if (enabled) { + host.showNotice( + 'YOLO mode: ON', + 'All actions will be approved automatically. Use with caution.', + ); + return; + } + host.showNotice('YOLO mode: OFF'); +} + +export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const customInstruction = args.trim() || undefined; + await session.compact({ instruction: customInstruction }); +} + +export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise { + const command = args.trim(); + if (command.length === 0) { + host.showEditorPicker(); + return; + } + await host.applyEditorChoice(command); +} + +export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise { + const theme = args.trim(); + if (theme.length === 0) { + host.showThemePicker(); + return; + } + if (!isTheme(theme)) { + host.showError(`Unknown theme: ${theme}`); + return; + } + await host.applyThemeChoice(theme); +} + +export function handleModelCommand(host: SlashCommandHost, args: string): void { + const alias = args.trim(); + if (alias.length === 0) { + host.showModelPicker(); + return; + } + if (host.state.appState.availableModels[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + host.showModelPicker(alias); +} + +// --------------------------------------------------------------------------- +// Session commands +// --------------------------------------------------------------------------- + +export async function handleTitleCommand(host: SlashCommandHost, args: string): Promise { + const title = args.trim(); + if (title.length === 0) { + const current = host.state.appState.sessionTitle; + host.showStatus( + current !== null && current.length > 0 + ? `Session title: ${current}` + : `Session title: (not set) — id: ${host.state.appState.sessionId}`, + ); + return; + } + + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const newTitle = title.slice(0, 200); + try { + await host.harness.renameSession({ id: session.id, title: newTitle }); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set title: ${msg}`); + return; + } + host.showStatus(`Session title set to: ${newTitle}`); +} + +export async function handleForkCommand(host: SlashCommandHost, args: string): Promise { + void args; + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const sourceTitle = forkSourceTitle(host, session); + let forked: Session; + try { + forked = await host.harness.forkSession({ + id: session.id, + title: `Fork: ${sourceTitle}`, + }); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to fork session: ${msg}`); + return; + } + + try { + await host.switchToSession(forked, `Session forked (${forked.id}).`); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch to forked session: ${msg}`); + } +} + +function forkSourceTitle(host: SlashCommandHost, session: Session): string { + const currentTitle = host.state.appState.sessionTitle?.trim(); + if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; + + const summaryTitle = + typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; + return summaryTitle.length > 0 ? summaryTitle : session.id; +} + +export async function handleInitCommand(host: SlashCommandHost): Promise { + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + host.deferUserMessages = true; + host.beginSessionRequest(); + try { + await session.init(); + host.track('init_complete'); + host.finalizeTurn((item) => { + host.sendQueuedMessage(session, item); + }); + } catch (error) { + if (isAbortError(error)) { + host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + host.resetLivePane(); + return; + } + const msg = error instanceof Error ? error.message : String(error); + host.failSessionRequest(`Init failed: ${msg}`); + } finally { + host.deferUserMessages = false; + } +} + +// --------------------------------------------------------------------------- +// Feedback +// --------------------------------------------------------------------------- + +export async function handleFeedbackCommand(host: SlashCommandHost): Promise { + const fallback = (reason: string): void => { + host.showStatus(reason); + host.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); + }; + + const providerKey = host.state.appState.availableModels[host.state.appState.model]?.provider; + if (!isManagedUsageProvider(providerKey)) { + fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); + return; + } + + const content = await promptFeedbackInput(host); + if (content === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + const res = await host.harness.auth.submitFeedback({ + content, + sessionId: host.state.appState.sessionId, + version: withFeedbackVersionPrefix(host.state.appState.version), + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind === 'ok') { + spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + return; + } + + spinner.stop({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); +} + + +// --------------------------------------------------------------------------- +// Auth: login / logout / connect +// --------------------------------------------------------------------------- + +export async function handleLoginCommand(host: SlashCommandHost): Promise { + const platformId = await promptPlatformSelection(host); + if (platformId === undefined) return; + + if (platformId === 'kimi-code') { + await handleKimiCodeOAuthLogin(host); + return; + } + + const platform = getOpenPlatformById(platformId); + if (platform === undefined) return; + await handleOpenPlatformLogin(host, platform); +} + +async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { + const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const alreadyLoggedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + + let spinner: LoginProgressSpinnerHandle | undefined; + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + try { + await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { + signal: controller.signal, + onDeviceCode: (data) => { + spinner = host.showLoginAuthorizationPrompt(data); + }, + }); + spinner?.stop({ ok: true, label: 'Logged in.' }); + spinner = undefined; + try { + await host.refreshConfigAfterLogin(); + } catch (refreshError) { + const message = formatErrorMessage(refreshError); + host.showError(`Authentication successful, but failed to refresh config: ${message}`); + return; + } + host.track('login', { + provider: DEFAULT_OAUTH_PROVIDER_NAME, + already_logged_in: alreadyLoggedIn, + }); + if (alreadyLoggedIn) { + host.showStatus('Already logged in. Model configuration refreshed.'); + } + } catch (error) { + const cancelled = controller.signal.aborted; + spinner?.stop({ + ok: false, + label: cancelled ? 'Login cancelled.' : 'Login failed.', + }); + spinner = undefined; + if (cancelled) return; + log.warn('login failed', { + providerName: DEFAULT_OAUTH_PROVIDER_NAME, + alreadyLoggedIn, + sessionId: host.session?.id, + error, + }); + const message = formatErrorMessage(error); + host.showError(`Login failed: ${message}`); + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } +} + +async function handleOpenPlatformLogin( + host: SlashCommandHost, + platform: OpenPlatformDefinition, +): Promise { + const apiKey = await promptApiKey(host, platform.name); + if (apiKey === undefined) return; + + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + + let models: ManagedKimiCodeModelInfo[]; + try { + models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = filterModelsByPrefix(models, platform); + } catch (error) { + if (controller.signal.aborted) return; + const msg = formatErrorMessage(error); + host.showError(`Failed to verify API key: ${msg}`); + if ( + error instanceof OpenPlatformApiError && + error.status === 401 + ) { + host.showStatus( + 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', + ); + } + return; + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } + + if (models.length === 0) { + host.showError('No models available for this platform.'); + return; + } + + const selection = await promptModelSelectionForOpenPlatform(host, models, platform); + if (selection === undefined) return; + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[platform.id] !== undefined) { + await host.harness.removeProvider(platform.id); + } + + const config = await host.harness.getConfig(); + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + platform, + models, + selectedModel: selection.model, + thinking: selection.thinking, + apiKey, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + await host.refreshConfigAfterLogin(); + host.track('login', { provider: platform.id, method: 'api_key' }); + host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); +} + +export async function handleConnectCommand(host: SlashCommandHost, args: string): Promise { + const resolution = resolveConnectCatalogRequest(args); + if (resolution.kind === 'error') { + host.showError(resolution.message); + return; + } + const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request; + + let catalog: Catalog | undefined; + + if (preferBuiltIn) { + const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); + if (builtIn !== undefined) { + host.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.'); + catalog = builtIn; + } + } + + if (catalog === undefined) { + const controller = new AbortController(); + const cancel = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancel; + + const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${url}`); + try { + catalog = await fetchCatalog(url, controller.signal); + spinner.stop({ ok: true, label: 'Catalog loaded.' }); + } catch (error) { + if (controller.signal.aborted) { + spinner.stop({ ok: false, label: 'Aborted.' }); + } else { + const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; + if (!allowBuiltInFallback) { + spinner.stop({ ok: false, label: 'Failed to load catalog.' }); + host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); + } else { + const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); + if (fallback !== undefined) { + spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' }); + catalog = fallback; + } else { + spinner.stop({ ok: false, label: 'Failed to load catalog.' }); + host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); + } + } + } + } finally { + if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; + } + } + + if (catalog === undefined) return; + + const providerId = await promptCatalogProviderSelection(host, catalog); + if (providerId === undefined) return; + const entry = catalog[providerId]; + if (entry === undefined) return; + + const models = catalogProviderModels(entry); + if (models.length === 0) { + host.showError(`Provider "${providerId}" has no usable models in this catalog.`); + return; + } + + const selection = await promptModelSelectionForCatalog(host, providerId, models); + if (selection === undefined) return; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; + + const wire = inferWireType(entry); + if (wire === undefined) return; + const baseUrl = catalogBaseUrl(entry, wire); + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[providerId] !== undefined) { + await host.harness.removeProvider(providerId); + } + + const config = await host.harness.getConfig(); + applyCatalogProvider(config, { + providerId, + wire, + baseUrl, + apiKey, + models, + selectedModelId: selection.model.id, + thinking: selection.thinking, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + await host.refreshConfigAfterLogin(); + host.track('connect', { provider: providerId, model: selection.model.id }); + host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); +} + +export async function handleLogoutCommand(host: SlashCommandHost): Promise { + const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const hasOAuthToken = oauthStatus.providers.some( + (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, + ); + const config = await host.harness.getConfig(); + const hasManagedRemnant = + hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; + const apiKeyProviderIds = Object.keys(config.providers ?? {}) + .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) + .toSorted(); + + const options: ChoiceOption[] = []; + if (hasManagedRemnant) { + options.push({ + value: DEFAULT_OAUTH_PROVIDER_NAME, + label: PRODUCT_NAME, + description: 'OAuth login', + }); + } + for (const id of apiKeyProviderIds) { + const baseUrl = config.providers[id]?.baseUrl; + options.push({ + value: id, + label: id, + description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined, + }); + } + + if (options.length === 0) { + host.showStatus('Nothing to logout.'); + return; + } + + const currentModel = host.state.appState.model.trim(); + const currentProvider = host.state.appState.availableModels[currentModel]?.provider; + + const target = await promptLogoutProviderSelection(host, options, currentProvider); + if (target === undefined) return; + + if (target === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + } else { + await host.harness.removeProvider(target); + } + + if (target === currentProvider) { + await host.refreshConfigAfterLogout(); + await host.clearActiveSessionAfterLogout(); + } else { + const updated = await host.harness.getConfig({ reload: true }); + host.setAppState({ + availableModels: updated.models ?? {}, + availableProviders: updated.providers ?? {}, + }); + } + + host.track('logout', { provider: target }); + const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; + host.showStatus(`Logged out from ${label}.`); +} + diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c54144f170..6b43faafd8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -148,6 +148,7 @@ import { PermissionSelectorComponent } from './components/dialogs/permission-sel import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { SessionEventHandler } from './controllers/session-event-handler'; +import * as slashCommands from './controllers/slash-commands'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; @@ -544,13 +545,13 @@ interface SendMessageOptions { readonly hasMedia?: boolean; } -interface LoginProgressSpinnerHandle { +export interface LoginProgressSpinnerHandle { /** Stops the login progress row and replaces it with a final status. */ stop(opts: { ok: boolean; label: string }): void; } export class KimiTUI { - private readonly harness: KimiHarness; + readonly harness: KimiHarness; private readonly options: KimiTUIOptions; session: Session | undefined; state: TUIState; @@ -564,7 +565,7 @@ export class KimiTUI { private readonly gitLsFilesCache: GitLsFilesCache; sessionEventUnsubscribe: (() => void) | undefined; private pendingExit: PendingExit | null = null; - private cancelInFlight: (() => void) | undefined; + cancelInFlight: (() => void) | undefined; // Queues editor messages instead of sending or steering them. Used by /init. deferUserMessages = false; aborted = false; @@ -588,7 +589,7 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; - private track( + track( event: string, properties?: Parameters[1], ): void { @@ -1106,7 +1107,7 @@ export class KimiTUI { } // Clears the active session and runtime UI after logout. - private async clearActiveSessionAfterLogout(): Promise { + async clearActiveSessionAfterLogout(): Promise { await this.closeSession('logged out'); this.resetSessionRuntime(); this.setAppState({ @@ -1118,7 +1119,7 @@ export class KimiTUI { } // Reloads config after login and selects the configured default model. - private async refreshConfigAfterLogin(): Promise { + async refreshConfigAfterLogin(): Promise { const config = await this.harness.getConfig({ reload: true }); const availableModels = config.models ?? {}; const availableProviders = config.providers ?? {}; @@ -1144,7 +1145,7 @@ export class KimiTUI { } // Reloads config after logout and clears model-dependent state. - private async refreshConfigAfterLogout(): Promise { + async refreshConfigAfterLogout(): Promise { const config = await this.harness.getConfig({ reload: true }); const availableModels = config.models ?? {}; const availableProviders = config.providers ?? {}; @@ -1511,7 +1512,7 @@ export class KimiTUI { void this.showMcpServers(); return; case 'editor': - await this.handleEditorCommand(args, {}); + await this.handleEditorCommand(args); return; case 'theme': await this.handleThemeCommand(args); @@ -1665,7 +1666,7 @@ export class KimiTUI { } // Resets request-scoped state before submitting work to the active session. - private beginSessionRequest(): void { + beginSessionRequest(): void { this.state.currentTurnId = undefined; this.resetLiveTextRuntime(); this.resetLiveToolUiState(); @@ -1684,7 +1685,7 @@ export class KimiTUI { } // Ends a failed session request and renders the failure to the transcript. - private failSessionRequest(message: string): void { + failSessionRequest(message: string): void { this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); this.resetLivePane(); this.showError(message); @@ -1809,7 +1810,7 @@ export class KimiTUI { private finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { this.streamingUI.finalizeLiveTextBuffers(nextMode); } - private finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { + finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { this.streamingUI.finalizeTurn(sendQueued); } @@ -2020,7 +2021,7 @@ export class KimiTUI { } // Switches to a provided session and replays its transcript. - private async switchToSession(session: Session, statusMessage: string): Promise { + async switchToSession(session: Session, statusMessage: string): Promise { this.resetSessionRuntime(); await this.setSession(session); await this.syncRuntimeState(session); @@ -2312,7 +2313,7 @@ export class KimiTUI { } // Adds an animated login progress row to the transcript. - private showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { + showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); this.state.transcriptContainer.addChild(new Spacer(1)); @@ -2330,7 +2331,7 @@ export class KimiTUI { } // Opens the device-code URL and renders the login authorization prompt. - private showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { openUrl(auth.verificationUriComplete); this.state.transcriptContainer.addChild( new DeviceCodeBoxComponent({ @@ -2577,7 +2578,7 @@ export class KimiTUI { // ========================================================================= // Replaces the editor with a focusable dialog or selector panel. - private mountEditorReplacement(panel: Component & Focusable): void { + mountEditorReplacement(panel: Component & Focusable): void { this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); @@ -2585,7 +2586,7 @@ export class KimiTUI { } // Restores the main editor after a dialog or selector closes. - private restoreEditor(): void { + restoreEditor(): void { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); @@ -2707,7 +2708,7 @@ export class KimiTUI { } // Shows the editor command selector. - private showEditorPicker(): void { + showEditorPicker(): void { const currentValue = this.state.appState.editorCommand ?? ''; this.mountEditorReplacement( new EditorSelectorComponent({ @@ -2725,7 +2726,7 @@ export class KimiTUI { } // Persists and applies the selected external editor command. - private async applyEditorChoice(value: string): Promise { + async applyEditorChoice(value: string): Promise { const previous = this.state.appState.editorCommand ?? ''; if (value === previous && value.length > 0) { this.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); @@ -2756,7 +2757,7 @@ export class KimiTUI { } // Shows the model selector when models are available. - private showModelPicker(selectedValue: string = this.state.appState.model): void { + showModelPicker(selectedValue: string = this.state.appState.model): void { const entries = Object.entries(this.state.appState.availableModels); if (entries.length === 0) { this.showNotice( @@ -2785,7 +2786,7 @@ export class KimiTUI { } // Applies model and thinking changes to the active or newly created session. - private async performModelSwitch(alias: string, thinking: boolean): Promise { + async performModelSwitch(alias: string, thinking: boolean): Promise { if (this.state.appState.isStreaming) { this.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; @@ -2842,7 +2843,7 @@ export class KimiTUI { } // Persists the selected model and thinking state as the startup defaults. - private async persistModelSelection(alias: string, thinking: boolean): Promise { + async persistModelSelection(alias: string, thinking: boolean): Promise { const config = await this.harness.getConfig({ reload: true }); if (config.defaultModel === alias && config.defaultThinking === thinking) { return false; @@ -2855,7 +2856,7 @@ export class KimiTUI { } // Shows the theme selector. - private showThemePicker(): void { + showThemePicker(): void { this.mountEditorReplacement( new ThemeSelectorComponent({ currentValue: this.state.appState.theme, @@ -2872,7 +2873,7 @@ export class KimiTUI { } // Shows the permission mode selector. - private showPermissionPicker(): void { + showPermissionPicker(): void { this.mountEditorReplacement( new PermissionSelectorComponent({ currentValue: this.state.appState.permissionMode, @@ -2889,7 +2890,7 @@ export class KimiTUI { } // Shows the settings selector entry point. - private showSettingsSelector(): void { + showSettingsSelector(): void { this.mountEditorReplacement( new SettingsSelectorComponent({ colors: this.state.theme.colors, @@ -2904,7 +2905,7 @@ export class KimiTUI { } // Routes a settings selection to the matching selector or panel. - private handleSettingsSelection(value: SettingsSelection): void { + handleSettingsSelection(value: SettingsSelection): void { this.restoreEditor(); switch (value) { case 'model': @@ -2926,7 +2927,7 @@ export class KimiTUI { } // Applies a permission mode choice to the active session and app state. - private async applyPermissionChoice(mode: PermissionMode): Promise { + async applyPermissionChoice(mode: PermissionMode): Promise { if (mode === this.state.appState.permissionMode) { this.showStatus(`Permission mode unchanged: ${mode}.`); return; @@ -2945,7 +2946,7 @@ export class KimiTUI { } // Persists and applies a theme choice. - private async applyThemeChoice(theme: Theme): Promise { + async applyThemeChoice(theme: Theme): Promise { if (theme === this.state.appState.theme) { if (theme === 'auto') this.refreshTerminalThemeTracking(); this.showStatus(`Theme unchanged: "${theme}".`); @@ -3140,772 +3141,23 @@ export class KimiTUI { } // ========================================================================= - // Slash Command Handlers + // Slash Command Handlers — delegated to controllers/slash-commands.ts // ========================================================================= - // Applies plan mode through the session and mirrors it into UI state. private async applyPlanMode(session: Session, enabled: boolean): Promise { - try { - await session.setPlanMode(enabled); - this.setAppState({ planMode: enabled }); - if (enabled) { - const plan = await session.getPlan().catch(() => null); - this.showNotice( - 'Plan mode: ON', - plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, - ); - return; - } - this.showNotice('Plan mode: OFF'); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to set plan mode: ${msg}`); - } - } - - // Handles the /editor command. - private async handleEditorCommand(args: string, _eCtx: {}): Promise { - const command = args.trim(); - if (command.length === 0) { - this.showEditorPicker(); - return; - } - await this.applyEditorChoice(command); - } - - // Handles the /theme command. - private async handleThemeCommand(args: string): Promise { - const theme = args.trim(); - if (theme.length === 0) { - this.showThemePicker(); - return; - } - if (!isTheme(theme)) { - this.showError(`Unknown theme: ${theme}`); - return; - } - await this.applyThemeChoice(theme); - } - - // Handles the /model command. - private handleModelCommand(args: string): void { - const alias = args.trim(); - if (alias.length === 0) { - this.showModelPicker(); - return; - } - if (this.state.appState.availableModels[alias] === undefined) { - this.showError(`Unknown model alias: ${alias}`); - return; - } - this.showModelPicker(alias); - } - - // Handles the /title command. - private async handleTitleCommand(args: string): Promise { - const title = args.trim(); - if (title.length === 0) { - const current = this.state.appState.sessionTitle; - this.showStatus( - current !== null && current.length > 0 - ? `Session title: ${current}` - : `Session title: (not set) — id: ${this.state.appState.sessionId}`, - ); - return; - } - - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const newTitle = title.slice(0, 200); - try { - await this.harness.renameSession({ id: session.id, title: newTitle }); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to set title: ${msg}`); - return; - } - this.showStatus(`Session title set to: ${newTitle}`); - } - - // Handles the /fork command. - private async handleForkCommand(args: string): Promise { - void args; - - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const sourceTitle = this.forkSourceTitle(session); - let forked: Session; - try { - forked = await this.harness.forkSession({ - id: session.id, - title: `Fork: ${sourceTitle}`, - }); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to fork session: ${msg}`); - return; - } - - try { - await this.switchToSession(forked, `Session forked (${forked.id}).`); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to switch to forked session: ${msg}`); - } - } - - private forkSourceTitle(session: Session): string { - const currentTitle = this.state.appState.sessionTitle?.trim(); - if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; - - const summaryTitle = - typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; - return summaryTitle.length > 0 ? summaryTitle : session.id; - } - - // Handles the /yolo command. - private async handleYoloCommand(args: string): Promise { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - let enabled: boolean; - if (args === 'on') enabled = true; - else if (args === 'off') enabled = false; - else enabled = !this.state.appState.yolo; - - await session.setPermission(enabled ? 'yolo' : 'manual'); - this.setAppState({ yolo: enabled, permissionMode: enabled ? 'yolo' : 'manual' }); - if (enabled) { - this.showNotice( - 'YOLO mode: ON', - 'All actions will be approved automatically. Use with caution.', - ); - return; - } - this.showNotice('YOLO mode: OFF'); - } - - // Handles the /plan command. - private async handlePlanCommand(args: string): Promise { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const subcmd = args.trim().toLowerCase(); - if (subcmd === 'clear') { - await session.clearPlan(); - this.showNotice('Plan cleared'); - return; - } - - let enabled: boolean; - if (subcmd.length === 0) enabled = !this.state.appState.planMode; - else if (subcmd === 'on') enabled = true; - else if (subcmd === 'off') enabled = false; - else { - this.showError(`Unknown plan subcommand: ${subcmd}`); - return; - } - - await this.applyPlanMode(session, enabled); - } - - // Handles the /compact command. - private async handleCompactCommand(args: string): Promise { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const customInstruction = args.trim() || undefined; - await session.compact({ instruction: customInstruction }); - } - - // Handles the /init command. - private async handleInitCommand(): Promise { - const session = this.session; - if (this.state.appState.model.trim().length === 0 || session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - return; - } - - this.deferUserMessages = true; - this.beginSessionRequest(); - try { - await session.init(); - this.track('init_complete'); - this.finalizeTurn((item) => { - this.sendQueuedMessage(session, item); - }); - } catch (error) { - if (isAbortError(error)) { - this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); - this.resetLivePane(); - return; - } - const msg = error instanceof Error ? error.message : String(error); - this.failSessionRequest(`Init failed: ${msg}`); - } finally { - this.deferUserMessages = false; - } - } - - // Handles the /login command. - private async handleLoginCommand(): Promise { - const platformId = await this.promptPlatformSelection(); - if (platformId === undefined) return; - - if (platformId === 'kimi-code') { - await this.handleKimiCodeOAuthLogin(); - return; - } - - const platform = getOpenPlatformById(platformId); - if (platform === undefined) return; - await this.handleOpenPlatformLogin(platform); - } - - // Kimi Code OAuth login flow. - private async handleKimiCodeOAuthLogin(): Promise { - const status = await this.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const alreadyLoggedIn = status.providers.some( - (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, - ); - - let spinner: LoginProgressSpinnerHandle | undefined; - const controller = new AbortController(); - const cancelLogin = (): void => { - controller.abort(); - }; - this.cancelInFlight = cancelLogin; - try { - await this.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { - signal: controller.signal, - onDeviceCode: (data) => { - spinner = this.showLoginAuthorizationPrompt(data); - }, - }); - spinner?.stop({ ok: true, label: 'Logged in.' }); - spinner = undefined; - try { - await this.refreshConfigAfterLogin(); - } catch (refreshError) { - const message = formatErrorMessage(refreshError); - this.showError(`Authentication successful, but failed to refresh config: ${message}`); - return; - } - this.track('login', { - provider: DEFAULT_OAUTH_PROVIDER_NAME, - already_logged_in: alreadyLoggedIn, - }); - if (alreadyLoggedIn) { - this.showStatus('Already logged in. Model configuration refreshed.'); - } - } catch (error) { - const cancelled = controller.signal.aborted; - spinner?.stop({ - ok: false, - label: cancelled ? 'Login cancelled.' : 'Login failed.', - }); - spinner = undefined; - if (cancelled) return; - log.warn('login failed', { - providerName: DEFAULT_OAUTH_PROVIDER_NAME, - alreadyLoggedIn, - sessionId: this.session?.id, - error, - }); - const message = formatErrorMessage(error); - this.showError(`Login failed: ${message}`); - } finally { - if (this.cancelInFlight === cancelLogin) { - this.cancelInFlight = undefined; - } - } - } - - // Open platform API key login flow. - private async handleOpenPlatformLogin( - platform: OpenPlatformDefinition, - ): Promise { - const apiKey = await this.promptApiKey(platform.name); - if (apiKey === undefined) return; - - const controller = new AbortController(); - const cancelLogin = (): void => { - controller.abort(); - }; - this.cancelInFlight = cancelLogin; - - let models: ManagedKimiCodeModelInfo[]; - try { - models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); - models = filterModelsByPrefix(models, platform); - } catch (error) { - if (controller.signal.aborted) return; - const msg = formatErrorMessage(error); - this.showError(`Failed to verify API key: ${msg}`); - if ( - error instanceof OpenPlatformApiError && - error.status === 401 - ) { - this.showStatus( - 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', - ); - } - return; - } finally { - if (this.cancelInFlight === cancelLogin) { - this.cancelInFlight = undefined; - } - } - - if (models.length === 0) { - this.showError('No models available for this platform.'); - return; - } - - const selection = await this.promptModelSelectionForOpenPlatform(models, platform); - if (selection === undefined) return; - - // Remove stale provider config first so old model aliases are fully - // cleared (setConfig patch merge cannot delete nested keys). - const existingConfig = await this.harness.getConfig(); - if (existingConfig.providers[platform.id] !== undefined) { - await this.harness.removeProvider(platform.id); - } - - const config = await this.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedKimiConfigShape, { - platform, - models, - selectedModel: selection.model, - thinking: selection.thinking, - apiKey, - }); - - await this.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - - await this.refreshConfigAfterLogin(); - this.track('login', { provider: platform.id, method: 'api_key' }); - this.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); - } - - // Handles the /connect command — fetches a model catalog (default - // models.dev), lets the user pick a provider + model, prompts for an API - // key, then writes the provider config + model aliases. Model metadata - // (context size, capabilities) comes from the catalog, so users do not - // hand-write it. - private async handleConnectCommand(args: string): Promise { - const resolution = resolveConnectCatalogRequest(args); - if (resolution.kind === 'error') { - this.showError(resolution.message); - return; - } - const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request; - - let catalog: Catalog | undefined; - - // Default path: serve the bundled catalog so /connect works without a - // live network and is not gated by models.dev availability. The source - // placeholder is undefined in dev builds, so dev falls through to fetch. - if (preferBuiltIn) { - const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (builtIn !== undefined) { - this.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.'); - catalog = builtIn; - } - } - - if (catalog === undefined) { - const controller = new AbortController(); - const cancel = (): void => { - controller.abort(); - }; - this.cancelInFlight = cancel; - - const spinner = this.showLoginProgressSpinner(`Fetching catalog from ${url}`); - try { - catalog = await fetchCatalog(url, controller.signal); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); - } catch (error) { - if (controller.signal.aborted) { - spinner.stop({ ok: false, label: 'Aborted.' }); - } else { - const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; - if (!allowBuiltInFallback) { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } else { - const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (fallback !== undefined) { - spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' }); - catalog = fallback; - } else { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } - } - } - } finally { - if (this.cancelInFlight === cancel) this.cancelInFlight = undefined; - } - } - - if (catalog === undefined) return; - - const providerId = await this.promptCatalogProviderSelection(catalog); - if (providerId === undefined) return; - const entry = catalog[providerId]; - if (entry === undefined) return; - - const models = catalogProviderModels(entry); - if (models.length === 0) { - this.showError(`Provider "${providerId}" has no usable models in this catalog.`); - return; - } - - const selection = await this.promptModelSelectionForCatalog(providerId, models); - if (selection === undefined) return; - - const apiKey = await this.promptApiKey(entry.name ?? providerId); - if (apiKey === undefined) return; - - const wire = inferWireType(entry); - if (wire === undefined) return; - const baseUrl = catalogBaseUrl(entry, wire); - - // Remove stale provider config first: setConfig is a deep-merge patch that - // cannot delete keys, and applyCatalogProvider's in-memory cleanup below - // does not survive that merge — removeProvider is the only step that - // actually drops old model aliases from disk. - const existingConfig = await this.harness.getConfig(); - if (existingConfig.providers[providerId] !== undefined) { - await this.harness.removeProvider(providerId); - } - - const config = await this.harness.getConfig(); - applyCatalogProvider(config, { - providerId, - wire, - baseUrl, - apiKey, - models, - selectedModelId: selection.model.id, - thinking: selection.thinking, - }); - - await this.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - - await this.refreshConfigAfterLogin(); - this.track('connect', { provider: providerId, model: selection.model.id }); - this.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); - } - - // Handles the /feedback command — opens an inline input dialog and POSTs - // the result to the managed Kimi Code platform. Falls back to the GitHub - // Issues page when the user is not signed in or the request fails. - private async handleFeedbackCommand(): Promise { - const fallback = (reason: string): void => { - this.showStatus(reason); - this.showStatus(FEEDBACK_ISSUE_URL); - openUrl(FEEDBACK_ISSUE_URL); - }; - - const providerKey = this.state.appState.availableModels[this.state.appState.model]?.provider; - if (!isManagedUsageProvider(providerKey)) { - fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); - return; - } - - const content = await this.promptFeedbackInput(); - if (content === undefined) { - this.showStatus(FEEDBACK_STATUS_CANCELLED); - return; - } - - const spinner = this.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); - const res = await this.harness.auth.submitFeedback({ - content, - sessionId: this.state.appState.sessionId, - version: withFeedbackVersionPrefix(this.state.appState.version), - os: `${osType()} ${osRelease()}`, - model: this.state.appState.model.length > 0 ? this.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - this.showStatus(feedbackSessionLine(this.state.appState.sessionId)); - this.track(FEEDBACK_TELEMETRY_EVENT); - return; - } - - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); - } - - // Mounts the feedback input dialog and resolves with the trimmed value - // when submitted, or undefined when the user cancels. - private promptFeedbackInput(): Promise { - return new Promise((resolve) => { - const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { - this.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); - }, this.state.theme.colors); - this.mountEditorReplacement(dialog); - }); - } - - // Handles the /logout command. Lists every credential currently held — the - // Kimi Code OAuth token (or a stale config entry for it) plus each configured - // API-key provider — and lets the user pick which one to drop. OAuth tokens - // go through auth.logout for proper revocation, everything else through - // removeProvider. - private async handleLogoutCommand(): Promise { - const oauthStatus = await this.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const hasOAuthToken = oauthStatus.providers.some( - (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, - ); - const config = await this.harness.getConfig(); - // Offer the managed provider whenever something points at it — either a - // live OAuth token or a stale providers[] entry left over from a previous - // login. auth.logout cleans the config regardless of whether the token - // is still present, so this avoids leaving residue with no way to reach it. - const hasManagedRemnant = - hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; - const apiKeyProviderIds = Object.keys(config.providers ?? {}) - .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) - .toSorted(); - - const options: ChoiceOption[] = []; - if (hasManagedRemnant) { - options.push({ - value: DEFAULT_OAUTH_PROVIDER_NAME, - label: PRODUCT_NAME, - description: 'OAuth login', - }); - } - for (const id of apiKeyProviderIds) { - const baseUrl = config.providers[id]?.baseUrl; - options.push({ - value: id, - label: id, - description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined, - }); - } - - if (options.length === 0) { - this.showStatus('Nothing to logout.'); - return; - } - - const currentModel = this.state.appState.model.trim(); - const currentProvider = this.state.appState.availableModels[currentModel]?.provider; - - const target = await this.promptLogoutProviderSelection(options, currentProvider); - if (target === undefined) return; - - if (target === DEFAULT_OAUTH_PROVIDER_NAME) { - await this.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); - } else { - await this.harness.removeProvider(target); - } - - if (target === currentProvider) { - // The active session is backed by the provider we just removed, so it - // can no longer make requests — tear it down along with the model state. - await this.refreshConfigAfterLogout(); - await this.clearActiveSessionAfterLogout(); - } else { - // Refresh provider/model listings so the picker reflects the change, - // but leave the user's current session running. - const updated = await this.harness.getConfig({ reload: true }); - this.setAppState({ - availableModels: updated.models ?? {}, - availableProviders: updated.providers ?? {}, - }); - } - - this.track('logout', { provider: target }); - const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; - this.showStatus(`Logged out from ${label}.`); - } - - private promptLogoutProviderSelection( - options: readonly ChoiceOption[], - currentValue: string | undefined, - ): Promise { - return new Promise((resolve) => { - const picker = new ChoicePickerComponent({ - title: 'Select a provider to log out', - options, - currentValue, - colors: this.state.theme.colors, - onSelect: (value) => { - this.restoreEditor(); - resolve(value); - }, - onCancel: () => { - this.restoreEditor(); - resolve(undefined); - }, - }); - this.mountEditorReplacement(picker); - }); - } - - // --------------------------------------------------------------------------- - // Login / setup prompts - // --------------------------------------------------------------------------- - - private promptPlatformSelection(): Promise { - return new Promise((resolve) => { - const selector = new PlatformSelectorComponent({ - colors: this.state.theme.colors, - onSelect: (platformId) => { - this.restoreEditor(); - resolve(platformId); - }, - onCancel: () => { - this.restoreEditor(); - resolve(undefined); - }, - }); - this.mountEditorReplacement(selector); - }); - } - - private promptCatalogProviderSelection(catalog: Catalog): Promise { - return new Promise((resolve) => { - const options: ChoiceOption[] = Object.entries(catalog) - .filter(([, entry]) => inferWireType(entry) !== undefined) - .map(([id, entry]) => ({ - value: id, - label: entry.name ?? id, - description: - typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined, - })) - .toSorted((a, b) => a.label.localeCompare(b.label)); - - if (options.length === 0) { - this.showError('Catalog has no providers with supported wire types.'); - resolve(undefined); - return; - } - - const picker = new ChoicePickerComponent({ - title: 'Select a provider', - options, - colors: this.state.theme.colors, - searchable: true, - onSelect: (value) => { - this.restoreEditor(); - resolve(value); - }, - onCancel: () => { - this.restoreEditor(); - resolve(undefined); - }, - }); - this.mountEditorReplacement(picker); - }); - } - - private promptApiKey(platformName: string): Promise { - return new Promise((resolve) => { - const dialog = new ApiKeyInputDialogComponent( - platformName, - (result: ApiKeyInputResult) => { - this.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); - }, - this.state.theme.colors, - ); - this.mountEditorReplacement(dialog); - }); - } - - private async promptModelSelectionForOpenPlatform( - models: ManagedKimiCodeModelInfo[], - platform: OpenPlatformDefinition, - ): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { - const modelDict: Record = {}; - for (const m of models) { - modelDict[`${platform.id}/${m.id}`] = { - provider: platform.id, - model: m.id, - maxContextSize: m.contextLength, - capabilities: capabilitiesForModel(m), - displayName: m.displayName, - }; - } - const selection = await this.runModelSelector(modelDict); - if (selection === undefined) return undefined; - const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias); - return model ? { model, thinking: selection.thinking } : undefined; - } - - private async promptModelSelectionForCatalog( - providerId: string, - models: CatalogModel[], - ): Promise<{ model: CatalogModel; thinking: boolean } | undefined> { - const modelDict: Record = {}; - for (const m of models) { - modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); - } - const selection = await this.runModelSelector(modelDict); - if (selection === undefined) return undefined; - const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); - return model ? { model, thinking: selection.thinking } : undefined; - } - - private runModelSelector( - modelDict: Record, - ): Promise<{ alias: string; thinking: boolean } | undefined> { - return new Promise((resolve) => { - const firstAlias = Object.keys(modelDict)[0] ?? ''; - const caps = modelDict[firstAlias]?.capabilities ?? []; - const initialThinking = caps.includes('always_thinking') || caps.includes('thinking'); - const selector = new ModelSelectorComponent({ - models: modelDict, - currentValue: firstAlias, - currentThinking: initialThinking, - colors: this.state.theme.colors, - searchable: true, - onSelect: ({ alias, thinking }) => { - this.restoreEditor(); - resolve({ alias, thinking }); - }, - onCancel: () => { - this.restoreEditor(); - resolve(undefined); - }, - }); - this.mountEditorReplacement(selector); - }); - } + await slashCommands.handlePlanCommand(this, enabled ? 'on' : 'off'); + } + private async handleEditorCommand(args: string): Promise { await slashCommands.handleEditorCommand(this, args); } + private async handleThemeCommand(args: string): Promise { await slashCommands.handleThemeCommand(this, args); } + private handleModelCommand(args: string): void { slashCommands.handleModelCommand(this, args); } + private async handleTitleCommand(args: string): Promise { await slashCommands.handleTitleCommand(this, args); } + private async handleForkCommand(args: string): Promise { await slashCommands.handleForkCommand(this, args); } + private async handleYoloCommand(args: string): Promise { await slashCommands.handleYoloCommand(this, args); } + private async handlePlanCommand(args: string): Promise { await slashCommands.handlePlanCommand(this, args); } + private async handleCompactCommand(args: string): Promise { await slashCommands.handleCompactCommand(this, args); } + private async handleInitCommand(): Promise { await slashCommands.handleInitCommand(this); } + private async handleLoginCommand(): Promise { await slashCommands.handleLoginCommand(this); } + private async handleConnectCommand(args: string): Promise { await slashCommands.handleConnectCommand(this, args); } + private async handleLogoutCommand(): Promise { await slashCommands.handleLogoutCommand(this); } + private async handleFeedbackCommand(): Promise { await slashCommands.handleFeedbackCommand(this); } } 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 7ba1a26a16..d7af115490 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 @@ -13,7 +13,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { + promptFeedbackInput, + runModelSelector, +} from '#/tui/controllers/slash-command-prompts'; import type { QueuedMessage } from '#/tui/types'; + +vi.mock('#/tui/controllers/slash-command-prompts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, promptFeedbackInput: vi.fn() }; +}); import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -285,7 +294,7 @@ describe('KimiTUI message flow', () => { }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - feedbackDriver.promptFeedbackInput = vi.fn(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); harness.track.mockClear(); @@ -318,7 +327,7 @@ describe('KimiTUI message flow', () => { }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - feedbackDriver.promptFeedbackInput = vi.fn(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, @@ -349,7 +358,7 @@ describe('KimiTUI message flow', () => { }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - feedbackDriver.promptFeedbackInput = vi.fn(async () => undefined); + vi.mocked(promptFeedbackInput).mockImplementation(async () => undefined); harness.track.mockClear(); await feedbackDriver.handleFeedbackCommand(); @@ -1399,8 +1408,7 @@ describe('KimiTUI message flow', () => { it('enables search in the shared model selector helper', async () => { const { driver } = await makeDriver(); - const selectorDriver = driver as unknown as ModelSelectorDriver; - const selection = selectorDriver.runModelSelector({ + const selection = runModelSelector(driver as any, { alpha: { provider: 'managed:kimi-code', model: 'kimi-alpha', diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index f8096d6ae7..6d77b83db6 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -4,6 +4,15 @@ import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log } from "@moonshot-ai/kimi-code-sdk"; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { + promptPlatformSelection, + promptLogoutProviderSelection, +} from "#/tui/controllers/slash-command-prompts"; + +vi.mock("#/tui/controllers/slash-command-prompts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; +}); import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -386,7 +395,7 @@ describe("KimiTUI startup", () => { planMode: true, }); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await driver.handleLoginCommand(); expect(createSession).toHaveBeenNthCalledWith(1, { @@ -439,7 +448,7 @@ describe("KimiTUI startup", () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await driver.handleLoginCommand(); expect(createSession).toHaveBeenNthCalledWith(2, { @@ -471,7 +480,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.appState.thinking).toBe(false); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await driver.handleLoginCommand(); expect(session.setModel).toHaveBeenCalledWith("k2"); @@ -504,7 +513,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await driver.handleLoginCommand(); expect(harness.auth.login).toHaveBeenCalledWith( @@ -539,7 +548,7 @@ describe("KimiTUI startup", () => { try { await expect(driver.init()).resolves.toBe(false); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); await driver.handleLoginCommand(); expect(harness.auth.login).toHaveBeenCalledWith( @@ -588,7 +597,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue( + vi.mocked(promptLogoutProviderSelection).mockResolvedValue( "managed:kimi-code", ); await driver.handleLogoutCommand(); @@ -631,7 +640,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue("openai"); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); await driver.handleLogoutCommand(); expect(removeProvider).toHaveBeenCalledWith("openai"); @@ -668,7 +677,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); - vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue( + vi.mocked(promptLogoutProviderSelection).mockResolvedValue( "managed:kimi-code", ); await driver.handleLogoutCommand(); From 0071dfb67c802526b0e5b444b3891f65e24dbea3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 15:27:22 +0800 Subject: [PATCH 06/28] refactor(tui): extract AuthFlowController from KimiTUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Auth / Model Bootstrap 的 6 个方法(~115 行)从 KimiTUI 提取到 AuthFlowController。包含 refreshAvailableModels、 enterLoginRequiredStartupState、activateModelAfterLogin、 clearActiveSessionAfterLogout、refreshConfigAfterLogin、 refreshConfigAfterLogout。 kimi-tui.ts: 3163 → 3063 行 (-100) --- .../src/tui/controllers/auth-flow.ts | 143 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 138 +++-------------- 2 files changed, 162 insertions(+), 119 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/auth-flow.ts diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts new file mode 100644 index 0000000000..f2512b4157 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -0,0 +1,143 @@ +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { SkillListSession } from '../commands'; + +import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import type { AppState } from '../types'; +import type { KimiTUIOptions, TUIState } from '../kimi-tui'; + +function combineStartupNotice( + existing: string | undefined, + next: string | undefined, +): string | undefined { + if (existing !== undefined && next !== undefined) { + return `${existing}\n${next}`; + } + return existing ?? next; +} + +export interface AuthFlowHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + readonly options: KimiTUIOptions; + + setAppState(patch: Partial): void; + resetSessionRuntime(): void; + setSession(session: Session): Promise; + syncRuntimeState(session?: Session): Promise; + closeSession(reason: string): Promise; + startSessionEventSubscription(): void; + fetchSessions(): Promise; + refreshSessionTitle(): void; + refreshSkillCommands(session?: SkillListSession): Promise; +} + +export class AuthFlowController { + constructor(private readonly host: AuthFlowHost) {} + + async refreshAvailableModels(): Promise { + const config = await this.host.harness.getConfig({ reload: true }); + this.host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + }); + } + + enterLoginRequiredStartupState(): void { + this.host.resetSessionRuntime(); + this.host.setAppState({ + sessionId: '', + model: '', + thinking: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + sessionTitle: null, + }); + this.host.state.startupNotice = combineStartupNotice( + this.host.state.startupNotice, + OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, + ); + this.host.state.startupState = 'ready'; + } + + async activateModelAfterLogin(model: string, thinking?: boolean): Promise { + const { host } = this; + const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; + if (host.session !== undefined) { + await host.session.setModel(model); + if (level !== undefined) { + await host.session.setThinking(level); + } + return; + } + + const session = await host.harness.createSession({ + workDir: host.state.appState.workDir, + model, + thinking: level, + permission: host.options.startup.yolo ? 'yolo' : undefined, + planMode: host.state.appState.planMode ? true : undefined, + }); + await host.setSession(session); + host.setAppState({ + sessionId: session.id, + sessionTitle: session.summary?.title ?? null, + }); + await host.syncRuntimeState(session); + host.startSessionEventSubscription(); + void host.fetchSessions(); + host.refreshSessionTitle(); + void host.refreshSkillCommands(host.session); + } + + async clearActiveSessionAfterLogout(): Promise { + await this.host.closeSession('logged out'); + this.host.resetSessionRuntime(); + this.host.setAppState({ + sessionId: '', + model: '', + sessionTitle: null, + }); + await this.host.refreshSkillCommands(); + } + + async refreshConfigAfterLogin(): Promise { + const { host } = this; + const config = await host.harness.getConfig({ reload: true }); + const availableModels = config.models ?? {}; + const availableProviders = config.providers ?? {}; + const defaultModel = host.options.startup.model ?? config.defaultModel; + const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; + + if (defaultModel === undefined || selected === undefined) { + host.setAppState({ availableModels, availableProviders }); + return; + } + + await this.activateModelAfterLogin(defaultModel, config.defaultThinking); + const appStatePatch: Partial = { + availableModels, + availableProviders, + model: defaultModel, + maxContextTokens: selected.maxContextSize, + }; + if (config.defaultThinking !== undefined) { + appStatePatch.thinking = config.defaultThinking; + } + host.setAppState(appStatePatch); + } + + async refreshConfigAfterLogout(): Promise { + const config = await this.host.harness.getConfig({ reload: true }); + this.host.setAppState({ + availableModels: config.models ?? {}, + availableProviders: config.providers ?? {}, + model: '', + thinking: false, + maxContextTokens: 0, + contextUsage: 0, + contextTokens: 0, + }); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 6b43faafd8..84da2b32b5 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -147,6 +147,7 @@ import { PlatformSelectorComponent } from './components/dialogs/platform-selecto import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { AuthFlowController } from './controllers/auth-flow'; import { SessionEventHandler } from './controllers/session-event-handler'; import * as slashCommands from './controllers/slash-commands'; import { SessionReplayRenderer } from './controllers/session-replay'; @@ -552,7 +553,7 @@ export interface LoginProgressSpinnerHandle { export class KimiTUI { readonly harness: KimiHarness; - private readonly options: KimiTUIOptions; + readonly options: KimiTUIOptions; session: Session | undefined; state: TUIState; private readonly approvalController = new ApprovalController(); @@ -583,6 +584,7 @@ export class KimiTUI { // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; readonly streamingUI: StreamingUIController; + private readonly authFlow: AuthFlowController; private readonly sessionEventHandler: SessionEventHandler; private readonly sessionReplay: SessionReplayRenderer; private readonly tasksBrowserController: TasksBrowserController; @@ -635,6 +637,7 @@ export class KimiTUI { }), ); this.streamingUI = new StreamingUIController(this); + this.authFlow = new AuthFlowController(this); this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); @@ -667,7 +670,7 @@ export class KimiTUI { } // Loads skill-backed slash commands from the active session. - private async refreshSkillCommands(session?: SkillListSession): Promise { + async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; this.skillCommandMap.clear(); @@ -1045,120 +1048,17 @@ export class KimiTUI { } // ========================================================================= - // Auth / Model Bootstrap + // Auth / Model Bootstrap — delegated to AuthFlowController // ========================================================================= - // Refreshes model metadata from the harness config. - private async refreshAvailableModels(): Promise { - const config = await this.harness.getConfig({ reload: true }); - this.setAppState({ - availableModels: config.models ?? {}, - availableProviders: config.providers ?? {}, - }); - } - - // Allows the shell to start even when the managed OAuth token needs login. - private enterLoginRequiredStartupState(): void { - this.resetSessionRuntime(); - this.setAppState({ - sessionId: '', - model: '', - thinking: false, - contextTokens: 0, - maxContextTokens: 0, - contextUsage: 0, - sessionTitle: null, - }); - this.state.startupNotice = combineStartupNotice( - this.state.startupNotice, - OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, - ); - this.state.startupState = 'ready'; - } - - // Ensures a usable session exists for the default model after login. + private async refreshAvailableModels(): Promise { await this.authFlow.refreshAvailableModels(); } + private enterLoginRequiredStartupState(): void { this.authFlow.enterLoginRequiredStartupState(); } private async activateModelAfterLogin(model: string, thinking?: boolean): Promise { - const level = thinking === undefined ? undefined : thinking ? 'on' : 'off'; - if (this.session !== undefined) { - await this.session.setModel(model); - if (level !== undefined) { - await this.session.setThinking(level); - } - return; - } - - const session = await this.harness.createSession({ - workDir: this.state.appState.workDir, - model, - thinking: level, - permission: this.options.startup.yolo ? 'yolo' : undefined, - planMode: this.state.appState.planMode ? true : undefined, - }); - await this.setSession(session); - this.setAppState({ - sessionId: session.id, - sessionTitle: session.summary?.title ?? null, - }); - await this.syncRuntimeState(session); - this.startSessionEventSubscription(); - void this.fetchSessions(); - this.refreshSessionTitle(); - void this.refreshSkillCommands(this.session); - } - - // Clears the active session and runtime UI after logout. - async clearActiveSessionAfterLogout(): Promise { - await this.closeSession('logged out'); - this.resetSessionRuntime(); - this.setAppState({ - sessionId: '', - model: '', - sessionTitle: null, - }); - await this.refreshSkillCommands(); - } - - // Reloads config after login and selects the configured default model. - async refreshConfigAfterLogin(): Promise { - const config = await this.harness.getConfig({ reload: true }); - const availableModels = config.models ?? {}; - const availableProviders = config.providers ?? {}; - const defaultModel = this.options.startup.model ?? config.defaultModel; - const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; - - if (defaultModel === undefined || selected === undefined) { - this.setAppState({ availableModels, availableProviders }); - return; - } - - await this.activateModelAfterLogin(defaultModel, config.defaultThinking); - const appStatePatch: Partial = { - availableModels, - availableProviders, - model: defaultModel, - maxContextTokens: selected.maxContextSize, - }; - if (config.defaultThinking !== undefined) { - appStatePatch.thinking = config.defaultThinking; - } - this.setAppState(appStatePatch); - } - - // Reloads config after logout and clears model-dependent state. - async refreshConfigAfterLogout(): Promise { - const config = await this.harness.getConfig({ reload: true }); - const availableModels = config.models ?? {}; - const availableProviders = config.providers ?? {}; - this.setAppState({ - availableModels, - availableProviders, - model: '', - thinking: false, - maxContextTokens: 0, - contextUsage: 0, - contextTokens: 0, - }); + await this.authFlow.activateModelAfterLogin(model, thinking); } + async clearActiveSessionAfterLogout(): Promise { await this.authFlow.clearActiveSessionAfterLogout(); } + async refreshConfigAfterLogin(): Promise { await this.authFlow.refreshConfigAfterLogin(); } + async refreshConfigAfterLogout(): Promise { await this.authFlow.refreshConfigAfterLogout(); } // ========================================================================= // Layout / Editor Setup @@ -1874,7 +1774,7 @@ export class KimiTUI { } // Replaces the active session and installs approval/question handlers. - private async setSession(session: Session): Promise { + async setSession(session: Session): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); this.session = session; @@ -1883,7 +1783,7 @@ export class KimiTUI { } // Pulls runtime session status into the app state. - private async syncRuntimeState(session: Session = this.requireSession()): Promise { + async syncRuntimeState(session: Session = this.requireSession()): Promise { const status = await session.getStatus(); this.setAppState({ sessionId: session.id, @@ -1908,7 +1808,7 @@ export class KimiTUI { } // Detaches and closes the current session. - private async closeSession(reason: string): Promise { + async closeSession(reason: string): Promise { const previous = this.unloadCurrentSession(reason); await previous?.close(); } @@ -1945,7 +1845,7 @@ export class KimiTUI { } // Loads session picker rows for the current working directory. - private async fetchSessions(): Promise { + async fetchSessions(): Promise { this.state.loadingSessions = true; try { const sessions = await this.harness.listSessions({ workDir: this.state.appState.workDir }); @@ -1962,12 +1862,12 @@ export class KimiTUI { } // Syncs the process title with the current session title and id. - private refreshSessionTitle(): void { + refreshSessionTitle(): void { setProcessTitle(this.state.appState.sessionTitle, this.state.appState.sessionId); } // Resets turn, tool, queue, and background-agent state for a session switch. - private resetSessionRuntime(): void { + resetSessionRuntime(): void { this.aborted = false; this.discardPendingStreamingUiUpdates(); this.state.queuedMessages = []; @@ -2097,7 +1997,7 @@ export class KimiTUI { // Session Events — delegated to SessionEventHandler // ========================================================================= - private startSessionEventSubscription(): void { this.sessionEventHandler.startSubscription(); } + startSessionEventSubscription(): void { this.sessionEventHandler.startSubscription(); } private handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { this.sessionEventHandler.handleEvent(event, sendQueued); From f1dad39ce41bc6884f48a8a34ef6efc0c0037632 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 15:30:36 +0800 Subject: [PATCH 07/28] refactor(tui): clean up dead imports after controller extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 kimi-tui.ts 和 controller 文件中因代码提取而残留的 55+ 个 无用 import(SDK event types、OAuth 工具函数、catalog 类型等)。 kimi-tui.ts: 3063 → 3005 行 (-58) --- .../src/tui/controllers/session-replay.ts | 1 - .../src/tui/controllers/slash-commands.ts | 5 -- apps/kimi-code/src/tui/kimi-tui.ts | 60 +------------------ 3 files changed, 1 insertion(+), 65 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index c263b5e23b..9d38c1d701 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -14,7 +14,6 @@ import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, BackgroundAgentMetadata, - ToolCallBlockData, ToolResultBlockData, TranscriptEntry, } from '../types'; diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index bf87b58181..6f357eb219 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -2,7 +2,6 @@ import { release as osRelease, type as osType } from 'node:os'; import { applyOpenPlatformConfig, - capabilitiesForModel, fetchOpenPlatformModels, filterModelsByPrefix, getOpenPlatformById, @@ -15,7 +14,6 @@ import { import { applyCatalogProvider, catalogBaseUrl, - catalogModelToAlias, catalogProviderModels, CatalogFetchError, fetchCatalog, @@ -23,9 +21,7 @@ import { loadBuiltInCatalog, log, type Catalog, - type CatalogModel, type KimiHarness, - type ModelAlias, type Session, } from '@moonshot-ai/kimi-code-sdk'; @@ -42,7 +38,6 @@ import { promptModelSelectionForCatalog, promptModelSelectionForOpenPlatform, promptPlatformSelection, - runModelSelector, } from './slash-command-prompts'; import { DEFAULT_OAUTH_PROVIDER_NAME, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 84da2b32b5..14e5e99340 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -8,7 +8,6 @@ */ import { writeFileSync } from 'node:fs'; -import { release as osRelease, type as osType } from 'node:os'; import { join } from 'node:path'; import { @@ -23,77 +22,20 @@ import { TUI, } from '@earendil-works/pi-tui'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; -import { - applyOpenPlatformConfig, - capabilitiesForModel, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - OpenPlatformApiError, - type DeviceAuthorization, - type ManagedKimiCodeModelInfo, - type ManagedKimiConfigShape, - type OpenPlatformDefinition, -} from '@moonshot-ai/kimi-code-oauth'; -import { - applyCatalogProvider, - catalogBaseUrl, - catalogModelToAlias, - catalogProviderModels, - CatalogFetchError, - fetchCatalog, - inferWireType, - loadBuiltInCatalog, - log, -} from '@moonshot-ai/kimi-code-sdk'; -import { BUILT_IN_CATALOG_JSON } from '../built-in-catalog'; +import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { - AgentReplayRecord, - AgentStatusUpdatedEvent, ApprovalRequest, ApprovalResponse, - AssistantDeltaEvent, BackgroundTaskInfo, - BackgroundTaskStartedEvent, - BackgroundTaskTerminatedEvent, - BackgroundTaskUpdatedEvent, - Catalog, - CatalogModel, - CompactionCancelledEvent, - CompactionCompletedEvent, - CompactionStartedEvent, - ContextMessage, CreateSessionOptions, - ErrorEvent, Event, - HookResultEvent, KimiHarness, - ModelAlias, McpServerInfo, PermissionMode, - PromptOrigin, PromptPart, - ResumedAgentState, Session, - SessionMetaUpdatedEvent, SessionStatus, SessionUsage, - SkillActivatedEvent, - SubagentCompletedEvent, - SubagentFailedEvent, - SubagentSpawnedEvent, - ThinkingDeltaEvent, - ToolCall, - ToolCallDeltaEvent, - ToolCallStartedEvent, - ToolProgressEvent, - ToolResultEvent, - TurnEndedEvent, - TurnStartedEvent, - TurnStepCompletedEvent, - TurnStepInterruptedEvent, - TurnStepStartedEvent, - WarningEvent, } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; From 513e82985c265a4fbbf83d84fe4c8dc0079dc65c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 15:56:16 +0800 Subject: [PATCH 08/28] refactor(tui): remove delegate methods and direct controller references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: 删除 14 个零调用者的纯委托方法 Phase B: 内联 8 个少量调用者委托(调用处直接引用 controller) Phase C: controller 之间通过 host 上的 controller 引用直接协作, 不再绕 kimi-tui 中转(SessionEventHandler → tasksBrowserController, SlashCommands → authFlow) kimi-tui.ts: 3005 → 2956 行 (-49) --- .../tui/controllers/session-event-handler.ts | 14 +-- .../src/tui/controllers/slash-commands.ts | 17 ++-- apps/kimi-code/src/tui/kimi-tui.ts | 87 ++++--------------- 3 files changed, 35 insertions(+), 83 deletions(-) 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 9498739af4..a33959b751 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -60,6 +60,7 @@ import { setProcessTitle } from '../utils/proctitle'; import { errorReportHintLine } from '../constant/feedback'; import { nextTranscriptId } from '../utils/transcript-id'; import type { StreamingUIController } from './streaming-ui'; +import type { TasksBrowserController } from './tasks-browser'; import type { AppState, BackgroundAgentMetadata, @@ -87,8 +88,7 @@ export interface SessionEventHost { showNotice(title: string, detail?: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; - repaintTasksBrowser(): void; - refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise; + readonly tasksBrowserController: TasksBrowserController; } export class SessionEventHandler { @@ -879,7 +879,7 @@ export class SessionEventHandler { const viewer = state.tasksBrowser?.viewer; if (viewer !== undefined && viewer.taskId === info.taskId) { - void this.host.refreshTaskOutputViewer({ silent: true }); + void this.host.tasksBrowserController.refreshOutputViewer({ silent: true }); } const isTerminal = @@ -891,12 +891,12 @@ export class SessionEventHandler { if (event.type === 'background.task.started') { if (info.taskId.startsWith('agent-')) { this.syncBackgroundTaskBadge(); - this.host.repaintTasksBrowser(); + this.host.tasksBrowserController.repaint(); return; } this.appendBackgroundTaskEntry(info); this.syncBackgroundTaskBadge(); - this.host.repaintTasksBrowser(); + this.host.tasksBrowserController.repaint(); return; } @@ -908,14 +908,14 @@ export class SessionEventHandler { state.backgroundTaskTranscriptedTerminal.add(info.taskId); } this.syncBackgroundTaskBadge(); - this.host.repaintTasksBrowser(); + this.host.tasksBrowserController.repaint(); return; } if (previous?.status !== info.status) { this.syncBackgroundTaskBadge(); } - this.host.repaintTasksBrowser(); + this.host.tasksBrowserController.repaint(); } private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index 6f357eb219..f5a74cce65 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -62,6 +62,7 @@ import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; import { isAbortError } from '../utils/errors'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '../utils/open-url'; +import type { AuthFlowController } from './auth-flow'; import type { AppState, QueuedMessage } from '../types'; import type { TUIState, LoginProgressSpinnerHandle } from '../kimi-tui'; @@ -101,9 +102,9 @@ export interface SlashCommandHost { // Config (remain on KimiTUI) applyEditorChoice(value: string): Promise; applyThemeChoice(theme: Theme): Promise; - refreshConfigAfterLogin(): Promise; - refreshConfigAfterLogout(): Promise; - clearActiveSessionAfterLogout(): Promise; + + // Controller refs + readonly authFlow: AuthFlowController; } @@ -408,7 +409,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { spinner?.stop({ ok: true, label: 'Logged in.' }); spinner = undefined; try { - await host.refreshConfigAfterLogin(); + await host.authFlow.refreshConfigAfterLogin(); } catch (refreshError) { const message = formatErrorMessage(refreshError); host.showError(`Authentication successful, but failed to refresh config: ${message}`); @@ -509,7 +510,7 @@ async function handleOpenPlatformLogin( defaultThinking: config.defaultThinking, }); - await host.refreshConfigAfterLogin(); + await host.authFlow.refreshConfigAfterLogin(); host.track('login', { provider: platform.id, method: 'api_key' }); host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); } @@ -613,7 +614,7 @@ export async function handleConnectCommand(host: SlashCommandHost, args: string) defaultThinking: config.defaultThinking, }); - await host.refreshConfigAfterLogin(); + await host.authFlow.refreshConfigAfterLogin(); host.track('connect', { provider: providerId, model: selection.model.id }); host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); } @@ -665,8 +666,8 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise } if (target === currentProvider) { - await host.refreshConfigAfterLogout(); - await host.clearActiveSessionAfterLogout(); + await host.authFlow.refreshConfigAfterLogout(); + await host.authFlow.clearActiveSessionAfterLogout(); } else { const updated = await host.harness.getConfig({ reload: true }); host.setAppState({ diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 14e5e99340..966287fc5b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -526,10 +526,10 @@ export class KimiTUI { // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; readonly streamingUI: StreamingUIController; - private readonly authFlow: AuthFlowController; + readonly authFlow: AuthFlowController; private readonly sessionEventHandler: SessionEventHandler; private readonly sessionReplay: SessionReplayRenderer; - private readonly tasksBrowserController: TasksBrowserController; + readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -856,7 +856,7 @@ export class KimiTUI { this.isShuttingDown = true; this.unregisterSignalHandlers(); this.aborted = true; - this.discardPendingStreamingUiUpdates(); + this.streamingUI.discardPending(); if (this.pendingExit) { clearTimeout(this.pendingExit.timer); this.pendingExit = null; @@ -868,7 +868,7 @@ export class KimiTUI { this.disposeTerminalTracking(); await this.closeSession('shutting down'); await this.harness.close(); - this.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.ui.stop(); if (this.onExit) { await this.onExit(exitCode); @@ -998,9 +998,6 @@ export class KimiTUI { private async activateModelAfterLogin(model: string, thinking?: boolean): Promise { await this.authFlow.activateModelAfterLogin(model, thinking); } - async clearActiveSessionAfterLogout(): Promise { await this.authFlow.clearActiveSessionAfterLogout(); } - async refreshConfigAfterLogin(): Promise { await this.authFlow.refreshConfigAfterLogin(); } - async refreshConfigAfterLogout(): Promise { await this.authFlow.refreshConfigAfterLogout(); } // ========================================================================= // Layout / Editor Setup @@ -1348,7 +1345,7 @@ export class KimiTUI { void this.showSessionPicker(); return; case 'tasks': - void this.showTasksBrowser(); + void this.tasksBrowserController.show(); return; case 'mcp': void this.showMcpServers(); @@ -1510,9 +1507,9 @@ export class KimiTUI { // Resets request-scoped state before submitting work to the active session. beginSessionRequest(): void { this.state.currentTurnId = undefined; - this.resetLiveTextRuntime(); - this.resetLiveToolUiState(); - this.resetToolCallState(); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.streamingUI.resetToolCallState(); this.patchLivePane({ mode: 'waiting', @@ -1635,23 +1632,6 @@ export class KimiTUI { }); } - // --------------------------------------------------------------------------- - // Streaming UI — delegated to StreamingUIController - // --------------------------------------------------------------------------- - - private scheduleStreamingUiFlush(): void { this.streamingUI.scheduleFlush(); } - private flushStreamingUiUpdatesNow(): void { this.streamingUI.flushNow(); } - private discardPendingStreamingUiUpdates(): void { this.streamingUI.discardPending(); } - private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { - this.streamingUI.flushThinkingToTranscript(nextMode); - } - private finalizeAssistantStream(): void { this.streamingUI.finalizeAssistantStream(); } - private resetLiveTextRuntime(): void { this.streamingUI.resetLiveText(); } - private resetLiveToolUiState(): void { this.streamingUI.resetToolUi(); } - private resetToolCallState(): void { this.streamingUI.resetToolCallState(); } - private finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { - this.streamingUI.finalizeLiveTextBuffers(nextMode); - } finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { this.streamingUI.finalizeTurn(sendQueued); } @@ -1811,26 +1791,26 @@ export class KimiTUI { // Resets turn, tool, queue, and background-agent state for a session switch. resetSessionRuntime(): void { this.aborted = false; - this.discardPendingStreamingUiUpdates(); + this.streamingUI.discardPending(); this.state.queuedMessages = []; this.harness.interactiveAgentId = MAIN_AGENT_ID; - this.resetToolCallState(); - this.resetLiveToolUiState(); + this.streamingUI.resetToolCallState(); + this.streamingUI.resetToolUi(); this.state.backgroundAgents.clear(); this.state.backgroundAgentMetadata.clear(); this.state.backgroundTasks.clear(); this.state.backgroundTaskTranscriptedTerminal.clear(); - this.closeTasksBrowser(); + this.tasksBrowserController.close(); this.state.subagentParentToolCallIds.clear(); this.state.subagentNames.clear(); this.state.renderedSkillActivationIds.clear(); this.state.renderedMcpServerStatusKeys.clear(); - this.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); - this.setTodoList([]); + this.streamingUI.setTodoList([]); this.state.currentTurnId = undefined; this.state.currentStep = 0; - this.resetLiveTextRuntime(); + this.streamingUI.resetLiveText(); this.updateQueueDisplay(); } @@ -1944,24 +1924,6 @@ export class KimiTUI { private handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { this.sessionEventHandler.handleEvent(event, sendQueued); } - private stopAllMcpServerStatusSpinners(): void { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); } - - // ========================================================================= - // Live Render Hooks — delegated to StreamingUIController - // ========================================================================= - - private onStreamingTextStart(): void { this.streamingUI.onStreamingTextStart(); } - private onStreamingTextUpdate(t: string): void { this.streamingUI.onStreamingTextUpdate(t); } - private onStreamingTextEnd(): void { this.streamingUI.onStreamingTextEnd(); } - private onThinkingUpdate(t: string): void { this.streamingUI.onThinkingUpdate(t); } - private onThinkingEnd(): void { this.streamingUI.onThinkingEnd(); } - private onToolCallStart(tc: ToolCallBlockData): void { this.streamingUI.onToolCallStart(tc); } - private onToolCallEnd(id: string, r: ToolResultBlockData): void { this.streamingUI.onToolCallEnd(id, r); } - private setTodoList(todos: readonly TodoItem[]): void { this.streamingUI.setTodoList(todos); } - private beginCompaction(instruction?: string): void { this.streamingUI.beginCompaction(instruction); } - private endCompaction(before?: number, after?: number): void { this.streamingUI.endCompaction(before, after); } - private cancelCompactionBlock(): void { this.streamingUI.cancelCompaction(); } - // ========================================================================= // Transcript Rendering // ========================================================================= @@ -2119,12 +2081,12 @@ export class KimiTUI { // Clears transcript-related state and redraws the welcome view. private clearTranscriptAndRedraw(): void { - this.discardPendingStreamingUiUpdates(); + this.streamingUI.discardPending(); this.state.transcriptEntries = []; this.disposeActiveCompactionBlock(); - this.resetLiveTextRuntime(); - this.resetLiveToolUiState(); - this.stopAllMcpServerStatusSpinners(); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); @@ -2538,17 +2500,6 @@ export class KimiTUI { ); } - // ========================================================================= - // Background tasks browser (`/tasks`) — delegated to TasksBrowserController - // ========================================================================= - - private showTasksBrowser(): Promise { return this.tasksBrowserController.show(); } - private closeTasksBrowser(): void { this.tasksBrowserController.close(); } - repaintTasksBrowser(): void { this.tasksBrowserController.repaint(); } - refreshTaskOutputViewer(opts?: { silent?: boolean }): Promise { - return this.tasksBrowserController.refreshOutputViewer(opts); - } - // Shows the editor command selector. showEditorPicker(): void { const currentValue = this.state.appState.editorCommand ?? ''; From fde7dcadaff02f17ec4db5e6b6caf880ab099252 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 16:06:53 +0800 Subject: [PATCH 09/28] refactor(tui): move pickers, config apply, info commands to slash-commands controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D: 将 pickers(editor/model/theme/permission/settings)、 config apply(applyEditorChoice/applyThemeChoice/applyPermissionChoice/ performModelSwitch/persistModelSelection)、info commands (showUsage/showStatusReport/showMcpServers + load* helpers)共 ~400 行 从 kimi-tui.ts 搬到 slash-commands controller。 Phase E: handleBuiltInSlashCommand 中所有 case 直接调用 slashCommands.*, 删除全部 13 个 slash command 委托方法。测试改为直接调用 controller 函数。 kimi-tui.ts: 2956 → 2566 行 (-390) --- .../src/tui/controllers/slash-commands.ts | 409 ++++++++++++++++- apps/kimi-code/src/tui/kimi-tui.ts | 432 +----------------- .../test/tui/kimi-tui-message-flow.test.ts | 7 +- .../test/tui/kimi-tui-startup.test.ts | 20 +- 4 files changed, 431 insertions(+), 437 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index f5a74cce65..b0b3f6ae42 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -22,14 +22,28 @@ import { log, type Catalog, type KimiHarness, + type McpServerInfo, + type PermissionMode, type Session, + type SessionStatus, + type SessionUsage, } from '@moonshot-ai/kimi-code-sdk'; import type { Component, Focusable } from '@earendil-works/pi-tui'; import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; +import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { ModelSelectorComponent } from '../components/dialogs/model-selector'; +import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; +import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; +import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; +import { buildStatusReportLines } from '../components/messages/status-panel'; +import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; +import { saveTuiConfig } from '../config'; import type { Theme } from '../theme'; +import type { ResolvedTheme } from '../theme/colors'; import { promptApiKey, promptCatalogProviderSelection, @@ -90,18 +104,13 @@ export interface SlashCommandHost { finalizeTurn(sendQueued: (item: QueuedMessage) => void): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; - // Pickers (remain on KimiTUI) - showEditorPicker(): void; - showModelPicker(selectedValue?: string): void; - showThemePicker(): void; - showPermissionPicker(): void; - showSettingsSelector(): void; + // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; - // Config (remain on KimiTUI) - applyEditorChoice(value: string): Promise; - applyThemeChoice(theme: Theme): Promise; + // Theme + applyTheme(theme: Theme, resolved?: ResolvedTheme): void; + refreshTerminalThemeTracking(): void; // Controller refs readonly authFlow: AuthFlowController; @@ -194,36 +203,36 @@ export async function handleCompactCommand(host: SlashCommandHost, args: string) export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise { const command = args.trim(); if (command.length === 0) { - host.showEditorPicker(); + showEditorPicker(host); return; } - await host.applyEditorChoice(command); + await applyEditorChoice(host, command); } export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise { const theme = args.trim(); if (theme.length === 0) { - host.showThemePicker(); + showThemePicker(host); return; } if (!isTheme(theme)) { host.showError(`Unknown theme: ${theme}`); return; } - await host.applyThemeChoice(theme); + await applyThemeChoice(host, theme); } export function handleModelCommand(host: SlashCommandHost, args: string): void { const alias = args.trim(); if (alias.length === 0) { - host.showModelPicker(); + showModelPicker(host); return; } if (host.state.appState.availableModels[alias] === undefined) { host.showError(`Unknown model alias: ${alias}`); return; } - host.showModelPicker(alias); + showModelPicker(host, alias); } // --------------------------------------------------------------------------- @@ -681,3 +690,373 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise host.showStatus(`Logged out from ${label}.`); } +// --------------------------------------------------------------------------- +// Pickers & config apply +// --------------------------------------------------------------------------- + +function showEditorPicker(host: SlashCommandHost): void { + const currentValue = host.state.appState.editorCommand ?? ''; + host.mountEditorReplacement( + new EditorSelectorComponent({ + currentValue, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyEditorChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { + const previous = host.state.appState.editorCommand ?? ''; + if (value === previous && value.length > 0) { + host.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); + return; + } + + const editorCommand = value.length > 0 ? value : null; + try { + await saveTuiConfig({ + theme: host.state.appState.theme, + editorCommand, + notifications: host.state.appState.notifications, + }); + } catch (error) { + host.showStatus( + `Failed to save editor: ${formatErrorMessage(error)}`, + host.state.theme.colors.error, + ); + return; + } + + host.setAppState({ editorCommand }); + host.showStatus( + value.length > 0 + ? `Editor set to "${value}".` + : 'Editor set to auto-detect ($VISUAL / $EDITOR).', + ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const entries = Object.entries(host.state.appState.availableModels); + if (entries.length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.', + ); + return; + } + host.mountEditorReplacement( + new ModelSelectorComponent({ + models: host.state.appState.availableModels, + currentValue: host.state.appState.model, + selectedValue, + currentThinking: host.state.appState.thinking, + colors: host.state.theme.colors, + searchable: true, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { + if (host.state.appState.isStreaming) { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + const level = thinking ? 'on' : 'off'; + const prevModel = host.state.appState.model; + const prevThinking = host.state.appState.thinking; + const runtimeChanged = alias !== prevModel || thinking !== prevThinking; + + const session = host.session; + try { + if (session === undefined && runtimeChanged) { + await host.authFlow.activateModelAfterLogin(alias, thinking); + } else if (session !== undefined) { + if (alias !== prevModel) { + await session.setModel(alias); + } + if (thinking !== prevThinking) { + await session.setThinking(level); + } + } + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch model: ${msg}`); + return; + } + + host.setAppState({ model: alias, thinking }); + if (session === undefined && runtimeChanged) { + if (alias !== prevModel) { + host.track('model_switch', { model: alias }); + } + if (thinking !== prevThinking) { + host.track('thinking_toggle', { enabled: thinking }); + } + } + + let persisted = false; + try { + persisted = await persistModelSelection(host, alias, thinking); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); + return; + } + + const status = runtimeChanged + ? `Switched to ${alias} with thinking ${level}.` + : persisted + ? `Saved ${alias} with thinking ${level} as default.` + : `Already using ${alias} with thinking ${level}.`; + host.showStatus(status, host.state.theme.colors.success); +} + +async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { + const config = await host.harness.getConfig({ reload: true }); + if (config.defaultModel === alias && config.defaultThinking === thinking) { + return false; + } + await host.harness.setConfig({ + defaultModel: alias, + defaultThinking: thinking, + }); + return true; +} + +function showThemePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new ThemeSelectorComponent({ + currentValue: host.state.appState.theme, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyThemeChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { + if (theme === host.state.appState.theme) { + if (theme === 'auto') host.refreshTerminalThemeTracking(); + host.showStatus(`Theme unchanged: "${theme}".`); + return; + } + + try { + await saveTuiConfig({ + theme, + editorCommand: host.state.appState.editorCommand, + notifications: host.state.appState.notifications, + }); + } catch (error) { + host.showStatus( + `Failed to save theme: ${formatErrorMessage(error)}`, + host.state.theme.colors.error, + ); + return; + } + + const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme; + host.applyTheme(theme, resolved); + host.refreshTerminalThemeTracking(); + host.track('theme_switch', { theme }); + const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; + host.showStatus(`Theme set to "${theme}"${detail}.`); +} + +export function showPermissionPicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new PermissionSelectorComponent({ + currentValue: host.state.appState.permissionMode, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyPermissionChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise { + if (mode === host.state.appState.permissionMode) { + host.showStatus(`Permission mode unchanged: ${mode}.`); + return; + } + + try { + await host.requireSession().setPermission(mode); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set permission mode: ${msg}`); + return; + } + + host.setAppState({ permissionMode: mode, yolo: mode === 'yolo' }); + host.showNotice(`Permission mode: ${mode}`); +} + +export function showSettingsSelector(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SettingsSelectorComponent({ + colors: host.state.theme.colors, + onSelect: (value) => { + handleSettingsSelection(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelection): void { + host.restoreEditor(); + switch (value) { + case 'model': showModelPicker(host); return; + case 'permission': showPermissionPicker(host); return; + case 'theme': showThemePicker(host); return; + case 'editor': showEditorPicker(host); return; + case 'usage': void showUsage(host); return; + } +} + +// --------------------------------------------------------------------------- +// Info commands +// --------------------------------------------------------------------------- + +interface SessionUsageResult { + readonly usage?: SessionUsage; + readonly error?: string; +} + +interface RuntimeStatusResult { + readonly status?: SessionStatus; + readonly error?: string; +} + +interface ManagedUsageResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; +} + +export async function showUsage(host: SlashCommandHost): Promise { + const sessionUsage = await loadSessionUsageReport(host); + const managedUsage = await loadManagedUsageReport(host); + const lines = buildUsageReportLines({ + colors: host.state.theme.colors, + sessionUsage: sessionUsage.usage, + sessionUsageError: sessionUsage.error, + contextUsage: host.state.appState.contextUsage, + contextTokens: host.state.appState.contextTokens, + maxContextTokens: host.state.appState.maxContextTokens, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showStatusReport(host: SlashCommandHost): Promise { + const [runtimeStatus, managedUsage] = await Promise.all([ + loadRuntimeStatusReport(host), + loadManagedUsageReport(host), + ]); + const appState = host.state.appState; + const lines = buildStatusReportLines({ + colors: host.state.theme.colors, + version: appState.version, + model: appState.model, + workDir: appState.workDir, + sessionId: appState.sessionId, + sessionTitle: appState.sessionTitle, + thinking: appState.thinking, + permissionMode: appState.permissionMode, + planMode: appState.planMode, + contextUsage: appState.contextUsage, + contextTokens: appState.contextTokens, + maxContextTokens: appState.maxContextTokens, + availableModels: appState.availableModels, + status: runtimeStatus.status, + statusError: runtimeStatus.error, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showMcpServers(host: SlashCommandHost): Promise { + let servers: readonly McpServerInfo[]; + try { + servers = await host.requireSession().listMcpServers(); + } catch (error) { + host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); + return; + } + + const lines = buildMcpStatusReportLines({ + colors: host.state.theme.colors, + servers, + }); + const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function loadSessionUsageReport(host: SlashCommandHost): Promise { + try { + return { usage: await host.requireSession().getUsage() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } +} + +async function loadRuntimeStatusReport(host: SlashCommandHost): Promise { + try { + return { status: await host.requireSession().getStatus() }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +async function loadManagedUsageReport(host: SlashCommandHost): Promise { + const alias = host.state.appState.model; + const providerKey = host.state.appState.availableModels[alias]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; + + let res; + try { + res = await host.harness.auth.getManagedUsage(providerKey); + } catch (error) { + return { error: formatErrorMessage(error) }; + } + if (res.kind === 'error') { + return { error: res.message }; + } + return { usage: { summary: res.summary, limits: res.limits } }; +} + diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 966287fc5b..3a0820cfde 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1098,7 +1098,7 @@ export class KimiTUI { const next = !this.state.appState.planMode; this.track('shortcut_plan_toggle', { enabled: next }); this.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - void this.applyPlanMode(session, next); + void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); }; editor.onOpenExternalEditor = () => { @@ -1348,58 +1348,58 @@ export class KimiTUI { void this.tasksBrowserController.show(); return; case 'mcp': - void this.showMcpServers(); + void slashCommands.showMcpServers(this); return; case 'editor': - await this.handleEditorCommand(args); + await slashCommands.handleEditorCommand(this, args); return; case 'theme': - await this.handleThemeCommand(args); + await slashCommands.handleThemeCommand(this, args); return; case 'model': - this.handleModelCommand(args); + slashCommands.handleModelCommand(this, args); return; case 'permission': - this.showPermissionPicker(); + slashCommands.showPermissionPicker(this); return; case 'settings': - this.showSettingsSelector(); + slashCommands.showSettingsSelector(this); return; case 'usage': - void this.showUsage(); + void slashCommands.showUsage(this); return; case 'status': - void this.showStatusReport(); + void slashCommands.showStatusReport(this); return; case 'feedback': - await this.handleFeedbackCommand(); + await slashCommands.handleFeedbackCommand(this); return; case 'title': - await this.handleTitleCommand(args); + await slashCommands.handleTitleCommand(this, args); return; case 'yolo': - await this.handleYoloCommand(args); + await slashCommands.handleYoloCommand(this, args); return; case 'plan': - await this.handlePlanCommand(args); + await slashCommands.handlePlanCommand(this, args); return; case 'compact': - await this.handleCompactCommand(args); + await slashCommands.handleCompactCommand(this, args); return; case 'init': - await this.handleInitCommand(); + await slashCommands.handleInitCommand(this); return; case 'fork': - await this.handleForkCommand(args); + await slashCommands.handleForkCommand(this, args); return; case 'login': - await this.handleLoginCommand(); + await slashCommands.handleLoginCommand(this); return; case 'connect': - await this.handleConnectCommand(args); + await slashCommands.handleConnectCommand(this, args); return; case 'logout': - await this.handleLogoutCommand(); + await slashCommands.handleLogoutCommand(this); return; default: this.showError(`Unknown slash command: /${String(name)}`); @@ -2293,7 +2293,7 @@ export class KimiTUI { } // Applies a theme bundle to all stateful UI theme references. - private applyTheme(theme: Theme, resolved?: ResolvedTheme): void { + applyTheme(theme: Theme, resolved?: ResolvedTheme): void { const nextTheme = createKimiTUIThemeBundle(theme, resolved); Object.assign(this.state.theme.colors, nextTheme.colors); this.state.theme.resolvedTheme = nextTheme.resolvedTheme; @@ -2305,7 +2305,7 @@ export class KimiTUI { } // Starts or stops terminal theme notifications according to the user preference. - private refreshTerminalThemeTracking(): void { + refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); if (this.state.appState.theme !== 'auto') return; @@ -2500,380 +2500,6 @@ export class KimiTUI { ); } - // Shows the editor command selector. - showEditorPicker(): void { - const currentValue = this.state.appState.editorCommand ?? ''; - this.mountEditorReplacement( - new EditorSelectorComponent({ - currentValue, - colors: this.state.theme.colors, - onSelect: (value) => { - this.restoreEditor(); - void this.applyEditorChoice(value); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Persists and applies the selected external editor command. - async applyEditorChoice(value: string): Promise { - const previous = this.state.appState.editorCommand ?? ''; - if (value === previous && value.length > 0) { - this.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); - return; - } - - const editorCommand = value.length > 0 ? value : null; - try { - await saveTuiConfig({ - theme: this.state.appState.theme, - editorCommand, - notifications: this.state.appState.notifications, - }); - } catch (error) { - this.showStatus( - `Failed to save editor: ${formatErrorMessage(error)}`, - this.state.theme.colors.error, - ); - return; - } - - this.setAppState({ editorCommand }); - this.showStatus( - value.length > 0 - ? `Editor set to "${value}".` - : 'Editor set to auto-detect ($VISUAL / $EDITOR).', - ); - } - - // Shows the model selector when models are available. - showModelPicker(selectedValue: string = this.state.appState.model): void { - const entries = Object.entries(this.state.appState.availableModels); - if (entries.length === 0) { - this.showNotice( - 'No models configured', - 'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.', - ); - return; - } - this.mountEditorReplacement( - new ModelSelectorComponent({ - models: this.state.appState.availableModels, - currentValue: this.state.appState.model, - selectedValue, - currentThinking: this.state.appState.thinking, - colors: this.state.theme.colors, - searchable: true, - onSelect: ({ alias, thinking }) => { - this.restoreEditor(); - void this.performModelSwitch(alias, thinking); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Applies model and thinking changes to the active or newly created session. - async performModelSwitch(alias: string, thinking: boolean): Promise { - if (this.state.appState.isStreaming) { - this.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); - return; - } - - const level = thinking ? 'on' : 'off'; - const prevModel = this.state.appState.model; - const prevThinking = this.state.appState.thinking; - const runtimeChanged = alias !== prevModel || thinking !== prevThinking; - - const session = this.session; - try { - if (session === undefined && runtimeChanged) { - await this.activateModelAfterLogin(alias, thinking); - } else if (session !== undefined) { - if (alias !== prevModel) { - await session.setModel(alias); - } - if (thinking !== prevThinking) { - await session.setThinking(level); - } - } - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to switch model: ${msg}`); - return; - } - - this.setAppState({ model: alias, thinking }); - if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - this.track('model_switch', { model: alias }); - } - if (thinking !== prevThinking) { - this.track('thinking_toggle', { enabled: thinking }); - } - } - - let persisted = false; - try { - persisted = await this.persistModelSelection(alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; - } - - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; - this.showStatus(status, this.state.theme.colors.success); - } - - // Persists the selected model and thinking state as the startup defaults. - async persistModelSelection(alias: string, thinking: boolean): Promise { - const config = await this.harness.getConfig({ reload: true }); - if (config.defaultModel === alias && config.defaultThinking === thinking) { - return false; - } - await this.harness.setConfig({ - defaultModel: alias, - defaultThinking: thinking, - }); - return true; - } - - // Shows the theme selector. - showThemePicker(): void { - this.mountEditorReplacement( - new ThemeSelectorComponent({ - currentValue: this.state.appState.theme, - colors: this.state.theme.colors, - onSelect: (value) => { - this.restoreEditor(); - void this.applyThemeChoice(value); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Shows the permission mode selector. - showPermissionPicker(): void { - this.mountEditorReplacement( - new PermissionSelectorComponent({ - currentValue: this.state.appState.permissionMode, - colors: this.state.theme.colors, - onSelect: (value) => { - this.restoreEditor(); - void this.applyPermissionChoice(value); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Shows the settings selector entry point. - showSettingsSelector(): void { - this.mountEditorReplacement( - new SettingsSelectorComponent({ - colors: this.state.theme.colors, - onSelect: (value) => { - this.handleSettingsSelection(value); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Routes a settings selection to the matching selector or panel. - handleSettingsSelection(value: SettingsSelection): void { - this.restoreEditor(); - switch (value) { - case 'model': - this.showModelPicker(); - return; - case 'permission': - this.showPermissionPicker(); - return; - case 'theme': - this.showThemePicker(); - return; - case 'editor': - this.showEditorPicker(); - return; - case 'usage': - void this.showUsage(); - return; - } - } - - // Applies a permission mode choice to the active session and app state. - async applyPermissionChoice(mode: PermissionMode): Promise { - if (mode === this.state.appState.permissionMode) { - this.showStatus(`Permission mode unchanged: ${mode}.`); - return; - } - - try { - await this.requireSession().setPermission(mode); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to set permission mode: ${msg}`); - return; - } - - this.setAppState({ permissionMode: mode, yolo: mode === 'yolo' }); - this.showNotice(`Permission mode: ${mode}`); - } - - // Persists and applies a theme choice. - async applyThemeChoice(theme: Theme): Promise { - if (theme === this.state.appState.theme) { - if (theme === 'auto') this.refreshTerminalThemeTracking(); - this.showStatus(`Theme unchanged: "${theme}".`); - return; - } - - try { - await saveTuiConfig({ - theme, - editorCommand: this.state.appState.editorCommand, - notifications: this.state.appState.notifications, - }); - } catch (error) { - this.showStatus( - `Failed to save theme: ${formatErrorMessage(error)}`, - this.state.theme.colors.error, - ); - return; - } - - const resolved = theme === 'auto' ? this.state.theme.resolvedTheme : theme; - this.applyTheme(theme, resolved); - this.refreshTerminalThemeTracking(); - this.track('theme_switch', { theme }); - const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; - this.showStatus(`Theme set to "${theme}"${detail}.`); - } - - // Loads and renders current usage information. - private async showUsage(): Promise { - const sessionUsage = await this.loadSessionUsageReport(); - const managedUsage = await this.loadManagedUsageReport(); - const lines = buildUsageReportLines({ - colors: this.state.theme.colors, - sessionUsage: sessionUsage.usage, - sessionUsageError: sessionUsage.error, - contextUsage: this.state.appState.contextUsage, - contextTokens: this.state.appState.contextTokens, - maxContextTokens: this.state.appState.maxContextTokens, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary); - this.state.transcriptContainer.addChild(panel); - this.state.ui.requestRender(); - } - - // Loads and renders current runtime status. - private async showStatusReport(): Promise { - const [runtimeStatus, managedUsage] = await Promise.all([ - this.loadRuntimeStatusReport(), - this.loadManagedUsageReport(), - ]); - const appState = this.state.appState; - const lines = buildStatusReportLines({ - colors: this.state.theme.colors, - version: appState.version, - model: appState.model, - workDir: appState.workDir, - sessionId: appState.sessionId, - sessionTitle: appState.sessionTitle, - thinking: appState.thinking, - permissionMode: appState.permissionMode, - planMode: appState.planMode, - contextUsage: appState.contextUsage, - contextTokens: appState.contextTokens, - maxContextTokens: appState.maxContextTokens, - availableModels: appState.availableModels, - status: runtimeStatus.status, - statusError: runtimeStatus.error, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, ' Status '); - this.state.transcriptContainer.addChild(panel); - this.state.ui.requestRender(); - } - - // Loads and renders current MCP server status. - private async showMcpServers(): Promise { - let servers: readonly McpServerInfo[]; - try { - servers = await this.requireSession().listMcpServers(); - } catch (error) { - this.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); - return; - } - - const lines = buildMcpStatusReportLines({ - colors: this.state.theme.colors, - servers, - }); - const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title); - this.state.transcriptContainer.addChild(panel); - this.state.ui.requestRender(); - } - - // Loads per-session usage and captures displayable errors. - private async loadSessionUsageReport(): Promise { - try { - return { usage: await this.requireSession().getUsage() }; - } catch (error) { - return { error: formatErrorMessage(error) }; - } - } - - // Loads per-session runtime status and captures displayable errors. - private async loadRuntimeStatusReport(): Promise { - try { - return { status: await this.requireSession().getStatus() }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - } - - // Loads managed-provider usage when the active model supports it. - private async loadManagedUsageReport(): Promise { - const alias = this.state.appState.model; - const providerKey = this.state.appState.availableModels[alias]?.provider; - if (!isManagedUsageProvider(providerKey)) return undefined; - - let res; - try { - res = await this.harness.auth.getManagedUsage(providerKey); - } catch (error) { - return { error: formatErrorMessage(error) }; - } - if (res.kind === 'error') { - return { error: res.message }; - } - return { usage: { summary: res.summary, limits: res.limits } }; - } - // Shows an approval panel and connects its response callback. private showApprovalPanel(payload: ApprovalPanelData): void { this.patchLivePane({ pendingApproval: { data: payload } }); @@ -2937,20 +2563,4 @@ export class KimiTUI { // Slash Command Handlers — delegated to controllers/slash-commands.ts // ========================================================================= - private async applyPlanMode(session: Session, enabled: boolean): Promise { - await slashCommands.handlePlanCommand(this, enabled ? 'on' : 'off'); - } - private async handleEditorCommand(args: string): Promise { await slashCommands.handleEditorCommand(this, args); } - private async handleThemeCommand(args: string): Promise { await slashCommands.handleThemeCommand(this, args); } - private handleModelCommand(args: string): void { slashCommands.handleModelCommand(this, args); } - private async handleTitleCommand(args: string): Promise { await slashCommands.handleTitleCommand(this, args); } - private async handleForkCommand(args: string): Promise { await slashCommands.handleForkCommand(this, args); } - private async handleYoloCommand(args: string): Promise { await slashCommands.handleYoloCommand(this, args); } - private async handlePlanCommand(args: string): Promise { await slashCommands.handlePlanCommand(this, args); } - private async handleCompactCommand(args: string): Promise { await slashCommands.handleCompactCommand(this, args); } - private async handleInitCommand(): Promise { await slashCommands.handleInitCommand(this); } - private async handleLoginCommand(): Promise { await slashCommands.handleLoginCommand(this); } - private async handleConnectCommand(args: string): Promise { await slashCommands.handleConnectCommand(this, args); } - private async handleLogoutCommand(): Promise { await slashCommands.handleLogoutCommand(this); } - private async handleFeedbackCommand(): Promise { await slashCommands.handleFeedbackCommand(this); } } 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 d7af115490..8a1f67a712 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 @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import { handleFeedbackCommand } from '#/tui/controllers/slash-commands'; import { promptFeedbackInput, runModelSelector, @@ -298,7 +299,7 @@ describe('KimiTUI message flow', () => { harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); harness.track.mockClear(); - await feedbackDriver.handleFeedbackCommand(); + await handleFeedbackCommand(feedbackDriver as any); expect(harness.auth.submitFeedback).toHaveBeenCalledWith( expect.objectContaining({ @@ -334,7 +335,7 @@ describe('KimiTUI message flow', () => { message: 'backend says no', }); - await feedbackDriver.handleFeedbackCommand(); + await handleFeedbackCommand(feedbackDriver as any); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('backend says no'); @@ -361,7 +362,7 @@ describe('KimiTUI message flow', () => { vi.mocked(promptFeedbackInput).mockImplementation(async () => undefined); harness.track.mockClear(); - await feedbackDriver.handleFeedbackCommand(); + await handleFeedbackCommand(feedbackDriver as any); expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); expect(harness.track).not.toHaveBeenCalledWith('feedback_submitted', undefined); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 6d77b83db6..d4ea31b135 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -4,6 +4,10 @@ import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log } from "@moonshot-ai/kimi-code-sdk"; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { + handleLoginCommand, + handleLogoutCommand, +} from "#/tui/controllers/slash-commands"; import { promptPlatformSelection, promptLogoutProviderSelection, @@ -396,7 +400,7 @@ describe("KimiTUI startup", () => { }); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { workDir: "/tmp/proj-a", @@ -449,7 +453,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { workDir: "/tmp/proj-a", @@ -481,7 +485,7 @@ describe("KimiTUI startup", () => { expect(driver.state.appState.thinking).toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + await handleLoginCommand(driver as any); expect(session.setModel).toHaveBeenCalledWith("k2"); expect(session.setThinking).toHaveBeenCalledWith("on"); @@ -514,7 +518,7 @@ describe("KimiTUI startup", () => { harness.track.mockClear(); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( "managed:kimi-code", @@ -549,7 +553,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( "managed:kimi-code", @@ -600,7 +604,7 @@ describe("KimiTUI startup", () => { vi.mocked(promptLogoutProviderSelection).mockResolvedValue( "managed:kimi-code", ); - await driver.handleLogoutCommand(); + await handleLogoutCommand(driver as any); expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); expect(session.close).toHaveBeenCalledOnce(); @@ -641,7 +645,7 @@ describe("KimiTUI startup", () => { harness.track.mockClear(); vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); - await driver.handleLogoutCommand(); + await handleLogoutCommand(driver as any); expect(removeProvider).toHaveBeenCalledWith("openai"); expect(harness.auth.logout).not.toHaveBeenCalled(); @@ -680,7 +684,7 @@ describe("KimiTUI startup", () => { vi.mocked(promptLogoutProviderSelection).mockResolvedValue( "managed:kimi-code", ); - await driver.handleLogoutCommand(); + await handleLogoutCommand(driver as any); expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); }); From 96b3c0e05a1814101ed2b9e73b365fa76ba3660e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 16:17:55 +0800 Subject: [PATCH 10/28] =?UTF-8?q?refactor(tui):=20final=20cleanup=20?= =?UTF-8?q?=E2=80=94=20remove=20last=20delegates=20and=20dead=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 finalizeTurn 和 activateModelAfterLogin 委托方法, slash-commands 直接通过 host.streamingUI / host.authFlow 调用。 清理 15 个因搬运产生的 dead import。 kimi-tui.ts: 2566 → 2545 行 (-21) --- .../src/tui/controllers/slash-commands.ts | 5 ++-- apps/kimi-code/src/tui/kimi-tui.ts | 25 ++----------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index b0b3f6ae42..df1a5fddda 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -77,6 +77,7 @@ import { isAbortError } from '../utils/errors'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '../utils/open-url'; import type { AuthFlowController } from './auth-flow'; +import type { StreamingUIController } from './streaming-ui'; import type { AppState, QueuedMessage } from '../types'; import type { TUIState, LoginProgressSpinnerHandle } from '../kimi-tui'; @@ -101,7 +102,6 @@ export interface SlashCommandHost { switchToSession(session: Session, message: string): Promise; beginSessionRequest(): void; failSessionRequest(message: string): void; - finalizeTurn(sendQueued: (item: QueuedMessage) => void): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; // UI @@ -113,6 +113,7 @@ export interface SlashCommandHost { refreshTerminalThemeTracking(): void; // Controller refs + readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; } @@ -318,7 +319,7 @@ export async function handleInitCommand(host: SlashCommandHost): Promise { try { await session.init(); host.track('init_complete'); - host.finalizeTurn((item) => { + host.streamingUI.finalizeTurn((item) => { host.sendQueuedMessage(session, item); }); } catch (error) { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3a0820cfde..52fe7326fb 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -30,7 +30,6 @@ import type { CreateSessionOptions, Event, KimiHarness, - McpServerInfo, PermissionMode, PromptPart, Session, @@ -66,7 +65,7 @@ import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; import { CHROME_GUTTER } from './constant/rendering'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; -import { TodoPanelComponent, type TodoItem } from './components/chrome/todo-panel'; +import { TodoPanelComponent } from './components/chrome/todo-panel'; import { WelcomeComponent } from './components/chrome/welcome'; import { ApprovalPanelComponent, @@ -77,16 +76,13 @@ import { type ApiKeyInputResult, } from './components/dialogs/api-key-input-dialog'; import { CompactionComponent } from './components/dialogs/compaction'; -import { EditorSelectorComponent } from './components/dialogs/editor-selector'; import { FeedbackInputDialogComponent, type FeedbackInputDialogResult, } from './components/dialogs/feedback-input-dialog'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { ChoicePickerComponent, type ChoiceOption } from './components/dialogs/choice-picker'; -import { ModelSelectorComponent } from './components/dialogs/model-selector'; import { PlatformSelectorComponent } from './components/dialogs/platform-selector'; -import { PermissionSelectorComponent } from './components/dialogs/permission-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; @@ -96,34 +92,27 @@ import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; import { - SettingsSelectorComponent, - type SettingsSelection, } from './components/dialogs/settings-selector'; -import { ThemeSelectorComponent } from './components/dialogs/theme-selector'; import { CustomEditor } from './components/editor/custom-editor'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AgentGroupComponent } from './components/messages/agent-group'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; -import { buildMcpStatusReportLines } from './components/messages/mcp-status-panel'; import { ReadGroupComponent } from './components/messages/read-group'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; -import { buildStatusReportLines } from './components/messages/status-panel'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { - buildUsageReportLines, - UsagePanelComponent, type ManagedUsageReport, } from './components/messages/usage-panel'; import { UserMessageComponent } from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; -import { saveTuiConfig, type TuiConfig } from './config'; +import type { TuiConfig } from './config'; import { FEEDBACK_ISSUE_URL, FEEDBACK_STATUS_CANCELLED, @@ -141,7 +130,6 @@ import { CTRL_D_HINT, DEFAULT_OAUTH_PROVIDER_NAME, EXIT_CONFIRM_WINDOW_MS, - isManagedUsageProvider, LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, @@ -167,7 +155,6 @@ import { type LivePaneState, type QueuedMessage, type ToolCallBlockData, - type ToolResultBlockData, type TranscriptEntry, } from './types'; import { formatBackgroundAgentTranscript } from './utils/background-agent-status'; @@ -179,7 +166,6 @@ import { appendStreamingArgsPreview, argsRecord, formatErrorMessage, - isTodoItemShape, parseStreamingArgs, serializeToolResultOutput, stringValue, @@ -995,9 +981,6 @@ export class KimiTUI { private async refreshAvailableModels(): Promise { await this.authFlow.refreshAvailableModels(); } private enterLoginRequiredStartupState(): void { this.authFlow.enterLoginRequiredStartupState(); } - private async activateModelAfterLogin(model: string, thinking?: boolean): Promise { - await this.authFlow.activateModelAfterLogin(model, thinking); - } // ========================================================================= // Layout / Editor Setup @@ -1632,10 +1615,6 @@ export class KimiTUI { }); } - finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { - this.streamingUI.finalizeTurn(sendQueued); - } - // ========================================================================= // State Helpers // ========================================================================= From f8944639d78e9849bb9d5249251b5ba79b57a586 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 16:22:27 +0800 Subject: [PATCH 11/28] refactor(tui): clean up dead imports and empty import blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理第二轮搬运后残留的 19 个 dead import 和 3 个空 import 块 (api-key-input-dialog、feedback-input-dialog、settings-selector、 feedback constants 等已搬到 slash-commands controller)。 kimi-tui.ts: 2545 → 2517 行 (-28) --- apps/kimi-code/src/tui/kimi-tui.ts | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 52fe7326fb..bd66cd5e6a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -71,18 +71,8 @@ import { ApprovalPanelComponent, type ApprovalPanelResponse, } from './components/dialogs/approval-panel'; -import { - ApiKeyInputDialogComponent, - type ApiKeyInputResult, -} from './components/dialogs/api-key-input-dialog'; import { CompactionComponent } from './components/dialogs/compaction'; -import { - FeedbackInputDialogComponent, - type FeedbackInputDialogResult, -} from './components/dialogs/feedback-input-dialog'; import { HelpPanelComponent } from './components/dialogs/help-panel'; -import { ChoicePickerComponent, type ChoiceOption } from './components/dialogs/choice-picker'; -import { PlatformSelectorComponent } from './components/dialogs/platform-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; @@ -91,8 +81,6 @@ import * as slashCommands from './controllers/slash-commands'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; -import { -} from './components/dialogs/settings-selector'; import { CustomEditor } from './components/editor/custom-editor'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AgentGroupComponent } from './components/messages/agent-group'; @@ -106,36 +94,20 @@ import { } from './components/messages/status-message'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import { - type ManagedUsageReport, -} from './components/messages/usage-panel'; +import type { ManagedUsageReport } from './components/messages/usage-panel'; import { UserMessageComponent } from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; -import { - FEEDBACK_ISSUE_URL, - FEEDBACK_STATUS_CANCELLED, - FEEDBACK_STATUS_FALLBACK, - FEEDBACK_STATUS_NOT_SIGNED_IN, - FEEDBACK_STATUS_SUBMITTING, - FEEDBACK_STATUS_SUCCESS, - FEEDBACK_TELEMETRY_EVENT, - errorReportHintLine, - feedbackSessionLine, - withFeedbackVersionPrefix, -} from './constant/feedback'; import { CTRL_C_HINT, CTRL_D_HINT, - DEFAULT_OAUTH_PROVIDER_NAME, EXIT_CONFIRM_WINDOW_MS, LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, - PRODUCT_NAME, } from './constant/kimi-tui'; import { STREAMING_UI_FLUSH_MS } from './constant/streaming'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; From e71fd77b1e1339483f7387311bfc06adfd659b3d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 16:55:58 +0800 Subject: [PATCH 12/28] refactor(tui): inline last auth delegate methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 refreshAvailableModels 和 enterLoginRequiredStartupState 的 调用方改为直接访问 this.authFlow.*,删除最后两个委托方法。 kimi-tui.ts: 2517 → 2510 行 (-7) --- apps/kimi-code/src/tui/kimi-tui.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index bd66cd5e6a..6a431d70e5 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -740,7 +740,7 @@ export class KimiTUI { // Creates or resumes the startup session and reports whether history should replay. private async init(): Promise { - await this.refreshAvailableModels(); + await this.authFlow.refreshAvailableModels(); const { startup } = this.options; const { workDir } = this.state.appState; @@ -791,7 +791,7 @@ export class KimiTUI { } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; - this.enterLoginRequiredStartupState(); + this.authFlow.enterLoginRequiredStartupState(); return false; } @@ -947,13 +947,6 @@ export class KimiTUI { } } - // ========================================================================= - // Auth / Model Bootstrap — delegated to AuthFlowController - // ========================================================================= - - private async refreshAvailableModels(): Promise { await this.authFlow.refreshAvailableModels(); } - private enterLoginRequiredStartupState(): void { this.authFlow.enterLoginRequiredStartupState(); } - // ========================================================================= // Layout / Editor Setup // ========================================================================= From 0cec67259d61184fb13c93cf9c59bdc9f00818fa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 17:00:33 +0800 Subject: [PATCH 13/28] refactor(tui): move slash command dispatch to slash-commands controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 executeSlashCommand(~45 行)和 handleBuiltInSlashCommand(~80 行) 从 kimi-tui.ts 搬到 slash-commands controller。kimi-tui.ts 的 handleUserInput 现在只做空检查、replay guard、历史持久化, 然后调 slashCommands.dispatchInput(this, text)。 kimi-tui.ts: 2510 → 2367 行 (-143) --- .../src/tui/controllers/slash-commands.ts | 160 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 157 +---------------- 2 files changed, 167 insertions(+), 150 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index df1a5fddda..04ed631276 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -76,8 +76,15 @@ import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; import { isAbortError } from '../utils/errors'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '../utils/open-url'; +import { + parseSlashInput, + resolveSlashCommandInput, + slashBusyMessage, + type BuiltinSlashCommandName, +} from '../commands'; import type { AuthFlowController } from './auth-flow'; import type { StreamingUIController } from './streaming-ui'; +import type { TasksBrowserController } from './tasks-browser'; import type { AppState, QueuedMessage } from '../types'; import type { TUIState, LoginProgressSpinnerHandle } from '../kimi-tui'; @@ -112,8 +119,18 @@ export interface SlashCommandHost { applyTheme(theme: Theme, resolved?: ResolvedTheme): void; refreshTerminalThemeTracking(): void; + // Dispatch + stop(exitCode?: number): Promise; + showHelpPanel(): void; + createNewSession(): Promise; + showSessionPicker(): Promise; + sendNormalUserInput(text: string): void; + sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + readonly skillCommandMap: Map; + // Controller refs readonly streamingUI: StreamingUIController; + readonly tasksBrowserController: TasksBrowserController; readonly authFlow: AuthFlowController; } @@ -1061,3 +1078,146 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise { + const parsedCommand = parseSlashInput(input); + const intent = resolveSlashCommandInput({ + input, + skillCommandMap: host.skillCommandMap, + isStreaming: host.state.appState.isStreaming, + isCompacting: host.state.appState.isCompacting, + }); + + switch (intent.kind) { + case 'not-command': + return; + case 'blocked': + host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); + host.showError(slashBusyMessage(intent.commandName, intent.reason)); + return; + case 'skill': { + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.track('input_command', { + command: intent.commandName, + skill_name: intent.skillName, + }); + host.sendSkillActivation(session, intent.skillName, intent.args); + return; + } + case 'message': + host.sendNormalUserInput(intent.input); + return; + case 'builtin': + host.track('input_command', { command: intent.name }); + if (intent.name === 'new' && parsedCommand?.name === 'clear') { + host.track('clear'); + } + try { + await handleBuiltInSlashCommand(host, intent.name, intent.args); + } catch (error) { + host.showError(formatErrorMessage(error)); + } + return; + } +} + +async function handleBuiltInSlashCommand( + host: SlashCommandHost, + name: BuiltinSlashCommandName, + args: string, +): Promise { + switch (name) { + case 'exit': + void host.stop(); + return; + case 'help': + host.showHelpPanel(); + return; + case 'version': + host.showStatus(`Kimi Code v${host.state.appState.version}`); + return; + case 'new': + await host.createNewSession(); + host.state.ui.requestRender(); + return; + case 'sessions': + void host.showSessionPicker(); + return; + case 'tasks': + void host.tasksBrowserController.show(); + return; + case 'mcp': + void showMcpServers(host); + return; + case 'editor': + await handleEditorCommand(host, args); + return; + case 'theme': + await handleThemeCommand(host, args); + return; + case 'model': + handleModelCommand(host, args); + return; + case 'permission': + showPermissionPicker(host); + return; + case 'settings': + showSettingsSelector(host); + return; + case 'usage': + void showUsage(host); + return; + case 'status': + void showStatusReport(host); + return; + case 'feedback': + await handleFeedbackCommand(host); + return; + case 'title': + await handleTitleCommand(host, args); + return; + case 'yolo': + await handleYoloCommand(host, args); + return; + case 'plan': + await handlePlanCommand(host, args); + return; + case 'compact': + await handleCompactCommand(host, args); + return; + case 'init': + await handleInitCommand(host); + return; + case 'fork': + await handleForkCommand(host, args); + return; + case 'login': + await handleLoginCommand(host); + return; + case 'connect': + await handleConnectCommand(host, args); + return; + case 'logout': + await handleLogoutCommand(host); + return; + default: + host.showError(`Unknown slash command: /${String(name)}`); + return; + } +} + diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 6a431d70e5..ed664bb819 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -52,11 +52,7 @@ import { detectFdPath } from '#/utils/process/fd-detect'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, - parseSlashInput, - resolveSlashCommandInput, - slashBusyMessage, sortSlashCommands, - type BuiltinSlashCommandName, type KimiSlashCommand, type SkillListSession, } from './commands'; @@ -460,7 +456,7 @@ export class KimiTUI { private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly KimiSlashCommand[] = []; - private readonly skillCommandMap = new Map(); + readonly skillCommandMap = new Map(); private readonly imageStore = new ImageAttachmentStore(); private readonly fdPath: string | null = detectFdPath(); private readonly gitLsFilesCache: GitLsFilesCache; @@ -1206,7 +1202,6 @@ export class KimiTUI { // Input Dispatch // ========================================================================= - // Routes submitted editor text to slash command handling or normal prompting. private handleUserInput(text: string): void { if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { @@ -1214,149 +1209,11 @@ export class KimiTUI { return; } void this.persistInputHistory(text); - if (parseSlashInput(text) !== null) { - void this.executeSlashCommand(text); - return; - } - - this.sendNormalUserInput(text); - } - - // Parses and executes a slash command intent. - private async executeSlashCommand(input: string): Promise { - const parsedCommand = parseSlashInput(input); - const intent = resolveSlashCommandInput({ - input, - skillCommandMap: this.skillCommandMap, - isStreaming: this.state.appState.isStreaming, - isCompacting: this.state.appState.isCompacting, - }); - - switch (intent.kind) { - case 'not-command': - return; - case 'blocked': - this.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); - this.showError(slashBusyMessage(intent.commandName, intent.reason)); - return; - case 'skill': { - const session = this.session; - if (this.state.appState.model.trim().length === 0 || session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - return; - } - this.track('input_command', { - command: intent.commandName, - skill_name: intent.skillName, - }); - this.sendSkillActivation(session, intent.skillName, intent.args); - return; - } - case 'message': { - this.sendNormalUserInput(intent.input); - return; - } - case 'builtin': - this.track('input_command', { command: intent.name }); - if (intent.name === 'new' && parsedCommand?.name === 'clear') { - this.track('clear'); - } - try { - await this.handleBuiltInSlashCommand(intent.name, intent.args); - } catch (error) { - this.showError(formatErrorMessage(error)); - } - return; - } - } - - // Dispatches a built-in slash command to its concrete handler. - private async handleBuiltInSlashCommand( - name: BuiltinSlashCommandName, - args: string, - ): Promise { - switch (name) { - case 'exit': - void this.stop(); - return; - case 'help': - this.showHelpPanel(); - return; - case 'version': - this.showStatus(`Kimi Code v${this.state.appState.version}`); - return; - case 'new': - await this.createNewSession(); - this.state.ui.requestRender(); - return; - case 'sessions': - void this.showSessionPicker(); - return; - case 'tasks': - void this.tasksBrowserController.show(); - return; - case 'mcp': - void slashCommands.showMcpServers(this); - return; - case 'editor': - await slashCommands.handleEditorCommand(this, args); - return; - case 'theme': - await slashCommands.handleThemeCommand(this, args); - return; - case 'model': - slashCommands.handleModelCommand(this, args); - return; - case 'permission': - slashCommands.showPermissionPicker(this); - return; - case 'settings': - slashCommands.showSettingsSelector(this); - return; - case 'usage': - void slashCommands.showUsage(this); - return; - case 'status': - void slashCommands.showStatusReport(this); - return; - case 'feedback': - await slashCommands.handleFeedbackCommand(this); - return; - case 'title': - await slashCommands.handleTitleCommand(this, args); - return; - case 'yolo': - await slashCommands.handleYoloCommand(this, args); - return; - case 'plan': - await slashCommands.handlePlanCommand(this, args); - return; - case 'compact': - await slashCommands.handleCompactCommand(this, args); - return; - case 'init': - await slashCommands.handleInitCommand(this); - return; - case 'fork': - await slashCommands.handleForkCommand(this, args); - return; - case 'login': - await slashCommands.handleLoginCommand(this); - return; - case 'connect': - await slashCommands.handleConnectCommand(this, args); - return; - case 'logout': - await slashCommands.handleLogoutCommand(this); - return; - default: - this.showError(`Unknown slash command: /${String(name)}`); - return; - } + slashCommands.dispatchInput(this, text); } // Sends regular user input after validating model and media support. - private sendNormalUserInput(text: string): void { + sendNormalUserInput(text: string): void { if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; @@ -1512,7 +1369,7 @@ export class KimiTUI { } // Starts a skill activation turn on the session. - private sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { this.beginSessionRequest(); void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { const message = formatErrorMessage(error); @@ -1814,7 +1671,7 @@ export class KimiTUI { } // Creates a fresh session from current UI settings and resets the transcript. - private async createNewSession(): Promise { + async createNewSession(): Promise { if (this.state.appState.isReplaying) { this.showError('Cannot start a new session while history is replaying.'); return; @@ -2381,7 +2238,7 @@ export class KimiTUI { } // Shows the help panel with the current slash command list. - private showHelpPanel(): void { + showHelpPanel(): void { this.state.showingHelpPanel = true; this.mountEditorReplacement( new HelpPanelComponent({ @@ -2401,7 +2258,7 @@ export class KimiTUI { } // Loads sessions and shows the session picker. - private async showSessionPicker(): Promise { + async showSessionPicker(): Promise { await this.fetchSessions(); this.mountSessionPicker(() => { this.hideSessionPicker(); From c00ae613d62d0dd6631989a993d1ca058ca729e7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 17:07:46 +0800 Subject: [PATCH 14/28] refactor(tui): clean up 38 dead imports and 3 dead interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除因前几轮搬运残留的 import(message-replay、mcp-server-status、 background-*-status、hook-result-format、event-payload utils、 streaming/theme constants 等)和 SessionUsageResult / RuntimeStatusResult / ManagedUsageResult 接口定义。 kimi-tui.ts: 2367 → 2311 行 (-56) --- apps/kimi-code/src/tui/kimi-tui.ts | 58 +----------------------------- 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ed664bb819..f4940f002c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -33,8 +33,6 @@ import type { PermissionMode, PromptPart, Session, - SessionStatus, - SessionUsage, } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; @@ -90,7 +88,6 @@ import { } from './components/messages/status-message'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import type { ManagedUsageReport } from './components/messages/usage-panel'; import { UserMessageComponent } from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; @@ -103,9 +100,7 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, OAUTH_LOGIN_REQUIRED_CODE, - OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from './constant/kimi-tui'; -import { STREAMING_UI_FLUSH_MS } from './constant/streaming'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -115,7 +110,7 @@ import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; import type { ResolvedTheme } from './theme/colors'; -import { isTheme, type Theme } from './theme/index'; +import type { Theme } from './theme/index'; import { INITIAL_LIVE_PANE, type AppState, @@ -125,49 +120,13 @@ import { type ToolCallBlockData, type TranscriptEntry, } from './types'; -import { formatBackgroundAgentTranscript } from './utils/background-agent-status'; -import { formatBackgroundTaskTranscript } from './utils/background-task-status'; import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities'; -import { resolveConnectCatalogRequest } from './utils/connect-catalog'; import { isDeadTerminalError } from './utils/dead-terminal'; import { - appendStreamingArgsPreview, - argsRecord, formatErrorMessage, - parseStreamingArgs, - serializeToolResultOutput, - stringValue, } from './utils/event-payload'; -import { isAbortError } from './utils/errors'; -import { formatHookResultMarkdown, formatHookResultPlain } from './utils/hook-result-format'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; -import { McpOAuthAuthorizationUrlOpener } from './utils/mcp-oauth'; -import { - appStateFromResumeAgent, - backgroundOrigin, - collectReplayMessageContent, - contentPartsToText, - countActiveBackgroundTasks, - createReplayRenderContext, - formatHookResultMessageForTranscript, - isTerminalBackgroundTask, - limitReplayRecordsByTurn, - REPLAY_TURN_LIMIT, - replayBackgroundProjection, - replayEntry, - skillActivationFromOrigin, - toolCallFromReplayMessage, - toolResultOutput, - type ReplayRenderContext, - type SkillActivationProjection, -} from './utils/message-replay'; -import { - formatMcpStartupStatusSummary, - mcpServerStatusKey, - type McpServerStatusSnapshot, - selectMcpStartupStatusRows, -} from './utils/mcp-server-status'; import { hasPatchChanges } from './utils/object-patch'; import { openUrl } from './utils/open-url'; import { setProcessTitle } from './utils/proctitle'; @@ -421,21 +380,6 @@ function isOAuthLoginRequiredError(error: unknown): boolean { return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; } -interface SessionUsageResult { - readonly usage?: SessionUsage; - readonly error?: string; -} - -interface ManagedUsageResult { - readonly usage?: ManagedUsageReport; - readonly error?: string; -} - -interface RuntimeStatusResult { - readonly status?: SessionStatus; - readonly error?: string; -} - interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; From 4b6214aa2cb3869808742bb5da4d098d6f3b06f3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 17:12:03 +0800 Subject: [PATCH 15/28] refactor(tui): deduplicate combineStartupNotice and isOAuthLoginRequiredError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 combineStartupNotice(kimi-tui.ts + auth-flow.ts 各一份)和 isOAuthLoginRequiredError 统一到 constant/kimi-tui.ts,两边改为 import。 kimi-tui.ts: 2311 → 2296 行 (-15) --- apps/kimi-code/src/tui/constant/kimi-tui.ts | 16 +++++++++++++++- .../src/tui/controllers/auth-flow.ts | 12 +----------- apps/kimi-code/src/tui/kimi-tui.ts | 19 ++----------------- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index ed05d93e10..c60b5d85c1 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -1,4 +1,4 @@ -import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; +import { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE } from '#/constant/app'; export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; @@ -10,6 +10,20 @@ export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; +export function combineStartupNotice( + existing: string | undefined, + next: string | undefined, +): string | undefined { + if (existing !== undefined && next !== undefined) { + return `${existing}\n${next}`; + } + return existing ?? next; +} + +export function isOAuthLoginRequiredError(error: unknown): boolean { + return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; +} + export function isManagedUsageProvider( providerKey: string | undefined, ): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index f2512b4157..3536a5dd9e 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,20 +1,10 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; -import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import { combineStartupNotice, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import type { AppState } from '../types'; import type { KimiTUIOptions, TUIState } from '../kimi-tui'; -function combineStartupNotice( - existing: string | undefined, - next: string | undefined, -): string | undefined { - if (existing !== undefined && next !== undefined) { - return `${existing}\n${next}`; - } - return existing ?? next; -} - export interface AuthFlowHost { state: TUIState; session: Session | undefined; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f4940f002c..dcc47f8a8e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -93,13 +93,14 @@ import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; import { + combineStartupNotice, CTRL_C_HINT, CTRL_D_HINT, EXIT_CONFIRM_WINDOW_MS, + isOAuthLoginRequiredError, LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, - OAUTH_LOGIN_REQUIRED_CODE, } from './constant/kimi-tui'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; @@ -364,22 +365,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState { }; } -// Merges startup notices while preserving their display order. -function combineStartupNotice( - existing: string | undefined, - next: string | undefined, -): string | undefined { - if (existing !== undefined && next !== undefined) { - return `${existing}\n${next}`; - } - return existing ?? next; -} - -function isOAuthLoginRequiredError(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false; - return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; -} - interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; From 8ac19c1b2c126821bb45ba90ad51325d91b95bb7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 17:14:52 +0800 Subject: [PATCH 16/28] refactor(tui): move startup utils out of constant/kimi-tui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit combineStartupNotice 和 isOAuthLoginRequiredError 是工具函数不是常量, 从 constant/kimi-tui.ts 移到 utils/startup.ts。 --- apps/kimi-code/src/tui/constant/kimi-tui.ts | 16 +--------------- apps/kimi-code/src/tui/controllers/auth-flow.ts | 3 ++- apps/kimi-code/src/tui/kimi-tui.ts | 3 +-- apps/kimi-code/src/tui/utils/startup.ts | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 apps/kimi-code/src/tui/utils/startup.ts diff --git a/apps/kimi-code/src/tui/constant/kimi-tui.ts b/apps/kimi-code/src/tui/constant/kimi-tui.ts index c60b5d85c1..ed05d93e10 100644 --- a/apps/kimi-code/src/tui/constant/kimi-tui.ts +++ b/apps/kimi-code/src/tui/constant/kimi-tui.ts @@ -1,4 +1,4 @@ -import { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE } from '#/constant/app'; +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; @@ -10,20 +10,6 @@ export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; -export function combineStartupNotice( - existing: string | undefined, - next: string | undefined, -): string | undefined { - if (existing !== undefined && next !== undefined) { - return `${existing}\n${next}`; - } - return existing ?? next; -} - -export function isOAuthLoginRequiredError(error: unknown): boolean { - return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; -} - export function isManagedUsageProvider( providerKey: string | undefined, ): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 3536a5dd9e..c65debb5c7 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,7 +1,8 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; -import { combineStartupNotice, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import { combineStartupNotice } from '../utils/startup'; import type { AppState } from '../types'; import type { KimiTUIOptions, TUIState } from '../kimi-tui'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dcc47f8a8e..815db343e9 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -93,15 +93,14 @@ import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; import { - combineStartupNotice, CTRL_C_HINT, CTRL_D_HINT, EXIT_CONFIRM_WINDOW_MS, - isOAuthLoginRequiredError, LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, } from './constant/kimi-tui'; +import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; diff --git a/apps/kimi-code/src/tui/utils/startup.ts b/apps/kimi-code/src/tui/utils/startup.ts new file mode 100644 index 0000000000..e29561e9dd --- /dev/null +++ b/apps/kimi-code/src/tui/utils/startup.ts @@ -0,0 +1,15 @@ +import { OAUTH_LOGIN_REQUIRED_CODE } from '../constant/kimi-tui'; + +export function combineStartupNotice( + existing: string | undefined, + next: string | undefined, +): string | undefined { + if (existing !== undefined && next !== undefined) { + return `${existing}\n${next}`; + } + return existing ?? next; +} + +export function isOAuthLoginRequiredError(error: unknown): boolean { + return (error as { readonly code?: unknown }).code === OAUTH_LOGIN_REQUIRED_CODE; +} From 7927e4bc1676c9cdbdd7834f4e728c626b501b56 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 17:18:35 +0800 Subject: [PATCH 17/28] refactor(tui): remove last session event/replay delegate methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 handleEvent、startSessionEventSubscription、 hydrateTranscriptFromReplay 三个委托方法。 kimi-tui 内部和 auth-flow controller 改为直接访问 sessionEventHandler.startSubscription() 和 sessionReplay.hydrateFromReplay()。 测试改为通过 driver.sessionEventHandler 直接调用。 kimi-tui.ts: 2296 → 2278 行 (-18) --- .../src/tui/controllers/auth-flow.ts | 5 +- apps/kimi-code/src/tui/kimi-tui.ts | 33 +++--------- .../test/tui/kimi-tui-message-flow.test.ts | 52 ++++++++++--------- 3 files changed, 38 insertions(+), 52 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index c65debb5c7..655ada1d60 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -2,6 +2,7 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; +import type { SessionEventHandler } from './session-event-handler'; import { combineStartupNotice } from '../utils/startup'; import type { AppState } from '../types'; import type { KimiTUIOptions, TUIState } from '../kimi-tui'; @@ -17,7 +18,7 @@ export interface AuthFlowHost { setSession(session: Session): Promise; syncRuntimeState(session?: Session): Promise; closeSession(reason: string): Promise; - startSessionEventSubscription(): void; + readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; refreshSessionTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise; @@ -76,7 +77,7 @@ export class AuthFlowController { sessionTitle: session.summary?.title ?? null, }); await host.syncRuntimeState(session); - host.startSessionEventSubscription(); + host.sessionEventHandler.startSubscription(); void host.fetchSessions(); host.refreshSessionTitle(); void host.refreshSkillCommands(host.session); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 815db343e9..6a1d42a2cf 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -409,8 +409,8 @@ export class KimiTUI { private readonly migrateOnly: boolean; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; - private readonly sessionEventHandler: SessionEventHandler; - private readonly sessionReplay: SessionReplayRenderer; + readonly sessionEventHandler: SessionEventHandler; + readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; public onExit?: (exitCode?: number) => Promise; @@ -639,14 +639,14 @@ export class KimiTUI { return; } if (shouldReplayHistory) { - await this.hydrateTranscriptFromReplay(this.requireSession()); + await this.sessionReplay.hydrateFromReplay(this.requireSession()); } const resumeState = this.session?.getResumeState(); if (resumeState?.warning !== undefined) { this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning); } if (this.session !== undefined) { - this.startSessionEventSubscription(); + this.sessionEventHandler.startSubscription(); } void this.fetchSessions(); if (this.session !== undefined) { @@ -1584,12 +1584,12 @@ export class KimiTUI { } this.clearTranscriptAndRedraw(); try { - await this.hydrateTranscriptFromReplay(session); + await this.sessionReplay.hydrateFromReplay(session); } catch (error) { const msg = formatErrorMessage(error); this.showError(`Failed to replay session history: ${msg}`); } finally { - this.startSessionEventSubscription(); + this.sessionEventHandler.startSubscription(); } const resumeState = session.getResumeState(); if (resumeState?.warning !== undefined) { @@ -1621,7 +1621,7 @@ export class KimiTUI { await this.activateRuntime(); await this.syncRuntimeState(session); } catch (error) { - this.startSessionEventSubscription(); + this.sessionEventHandler.startSubscription(); const msg = formatErrorMessage(error); this.showError(`Post-create setup failed: ${msg}`); return; @@ -1631,28 +1631,11 @@ export class KimiTUI { } catch { /* keep the new session usable even if dynamic skills fail */ } - this.startSessionEventSubscription(); + this.sessionEventHandler.startSubscription(); this.clearTranscriptAndRedraw(); this.showStatus(`Started a new session (${session.id}).`); } - // ========================================================================= - // Session Replay — delegated to SessionReplayRenderer - // ========================================================================= - - private hydrateTranscriptFromReplay(session: Session): Promise { - return this.sessionReplay.hydrateFromReplay(session); - } - - // ========================================================================= - // Session Events — delegated to SessionEventHandler - // ========================================================================= - - startSessionEventSubscription(): void { this.sessionEventHandler.startSubscription(); } - - private handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { - this.sessionEventHandler.handleEvent(event, sendQueued); - } // ========================================================================= // Transcript Rendering // ========================================================================= 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 8a1f67a712..3639b0723f 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 @@ -39,11 +39,13 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + sessionEventHandler: { + startSubscription(): void; + handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; + }; init(): Promise; handleUserInput(text: string): void; - handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; persistInputHistory(text: string): Promise; - startSessionEventSubscription(): void; getCurrentSessionId(): string; } @@ -498,7 +500,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); await Promise.resolve(); expect(session.onEvent).toHaveBeenCalledOnce(); @@ -532,7 +534,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); await Promise.resolve(); eventListeners[0]?.({ type: 'mcp.server.status', @@ -590,7 +592,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); eventListeners[0]?.({ type: 'mcp.server.status', agentId: 'main', @@ -693,7 +695,7 @@ describe('KimiTUI message flow', () => { driver.state.currentTurnId = '1'; driver.state.queuedMessages = [{ text: 'next' }]; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', agentId: 'main', @@ -719,7 +721,7 @@ describe('KimiTUI message flow', () => { const { driver } = await makeDriver(); vi.mocked(driver.state.ui.requestRender).mockClear(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -733,7 +735,7 @@ describe('KimiTUI message flow', () => { if (component === undefined) throw new Error('expected streaming component'); const updateSpy = vi.spyOn(component, 'updateContent'); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -743,7 +745,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -771,7 +773,7 @@ describe('KimiTUI message flow', () => { const sendQueued = vi.fn(); driver.state.appState.isStreaming = true; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -781,7 +783,7 @@ describe('KimiTUI message flow', () => { } as Event, sendQueued, ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', agentId: 'main', @@ -805,7 +807,7 @@ describe('KimiTUI message flow', () => { driver.state.currentTurnId = '1'; driver.state.currentStep = 1; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.delta', agentId: 'main', @@ -834,7 +836,7 @@ describe('KimiTUI message flow', () => { it('cancels manual compaction from the editor', async () => { const { driver, session } = await makeDriver(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'compaction.started', agentId: 'main', @@ -860,7 +862,7 @@ describe('KimiTUI message flow', () => { try { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'compaction.started', agentId: 'main', @@ -871,7 +873,7 @@ describe('KimiTUI message flow', () => { ); driver.state.queuedMessages = [{ text: 'next' }]; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'compaction.cancelled', agentId: 'main', @@ -1007,7 +1009,7 @@ describe('KimiTUI message flow', () => { it('shows the login prompt for auth.login_required session errors', async () => { const { driver } = await makeDriver(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'error', agentId: 'main', @@ -1028,7 +1030,7 @@ describe('KimiTUI message flow', () => { it('appends the kimi export hint beneath session error messages', async () => { const { driver } = await makeDriver(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'error', agentId: 'main', @@ -1050,7 +1052,7 @@ describe('KimiTUI message flow', () => { const { driver } = await makeDriver(); driver.state.appState.sessionId = ''; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'error', agentId: 'main', @@ -1078,7 +1080,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', agentId: 'main', @@ -1132,7 +1134,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', agentId: 'main', @@ -1173,7 +1175,7 @@ describe('KimiTUI message flow', () => { (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('2'); await expect(response).resolves.toMatchObject({ decision: 'rejected' }); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.result', agentId: 'main', @@ -1517,7 +1519,7 @@ describe('KimiTUI message flow', () => { driver.state.toolOutputExpanded = true; const longThinking = ['t1', 't2', 't3', 't4', 't5', 't6', 't7'].join('\n'); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', agentId: 'main', @@ -1526,7 +1528,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -1544,7 +1546,7 @@ describe('KimiTUI message flow', () => { it('renders hook results without XML tags', async () => { const { driver } = await makeDriver(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'hook.result', agentId: 'main', @@ -1565,7 +1567,7 @@ describe('KimiTUI message flow', () => { it('renders empty hook results as empty status text', async () => { const { driver } = await makeDriver(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'hook.result', agentId: 'main', From 8590bf1a097afc10c0ab3d9ff6aa158a93d31cbb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 18:03:29 +0800 Subject: [PATCH 18/28] refactor(tui): reduce TUIState fields by removing redundancy and merging pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete 4 redundant fields (AppState.yolo, AppState.isStreaming, TUIState.backgroundAgents, TUIState.assistantStreamActive) that were derivable from existing state. Merge 4 pairs of always-coupled fields into single objects (streamingBlock, subagentInfo, activitySpinner, activeDialog). Demote 3 fields only used by KimiTUI itself to private class fields. Net reduction: AppState 22→20, TUIState 55→48 fields. --- .../src/tui/controllers/auth-flow.ts | 7 +- .../tui/controllers/session-event-handler.ts | 30 ++-- .../src/tui/controllers/session-replay.ts | 4 - .../src/tui/controllers/slash-commands.ts | 12 +- .../src/tui/controllers/streaming-ui.ts | 33 ++-- apps/kimi-code/src/tui/kimi-tui.ts | 141 ++++++++---------- apps/kimi-code/src/tui/types.ts | 2 - .../kimi-code/src/tui/utils/message-replay.ts | 6 +- apps/kimi-code/test/tui/activity-pane.test.ts | 4 +- .../panels/footer-bg-agents.test.ts | 2 - .../components/panels/footer-context.test.ts | 2 - .../test/tui/create-tui-state.test.ts | 16 +- .../test/tui/kimi-tui-message-flow.test.ts | 23 ++- .../test/tui/kimi-tui-startup.test.ts | 6 +- 14 files changed, 115 insertions(+), 173 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 655ada1d60..3f38c56b25 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -3,7 +3,6 @@ import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import type { SessionEventHandler } from './session-event-handler'; -import { combineStartupNotice } from '../utils/startup'; import type { AppState } from '../types'; import type { KimiTUIOptions, TUIState } from '../kimi-tui'; @@ -18,6 +17,7 @@ export interface AuthFlowHost { setSession(session: Session): Promise; syncRuntimeState(session?: Session): Promise; closeSession(reason: string): Promise; + appendStartupNotice(extra: string): void; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise; refreshSessionTitle(): void; @@ -46,10 +46,7 @@ export class AuthFlowController { contextUsage: 0, sessionTitle: null, }); - this.host.state.startupNotice = combineStartupNotice( - this.host.state.startupNotice, - OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, - ); + this.host.appendStartupNotice(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); this.host.state.startupState = 'ready'; } 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 a33959b751..575960219a 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -208,9 +208,10 @@ export class SessionEventHandler { if (subagentId === MAIN_AGENT_ID) return false; const { state } = this.host; - const parentToolCallId = state.subagentParentToolCallIds.get(subagentId); - if (parentToolCallId === undefined || parentToolCallId.length === 0) return true; - const sourceName = state.subagentNames.get(subagentId); + const info = state.subagentInfo.get(subagentId); + if (info === undefined || info.parentToolCallId.length === 0) return true; + const { parentToolCallId } = info; + const sourceName = info.name; const toolCall = state.pendingToolComponents.get(parentToolCallId); if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); @@ -293,7 +294,6 @@ export class SessionEventHandler { pendingQuestion: null, }); this.host.setAppState({ - isStreaming: true, streamingPhase: 'waiting', streamingStartTime: Date.now(), }); @@ -398,8 +398,7 @@ export class SessionEventHandler { this.host.streamingUI.flushThinkingToTranscript('idle'); } - if (!state.assistantStreamActive) { - state.assistantStreamActive = true; + if (state.streamingBlock === null) { this.host.streamingUI.onStreamingTextStart(); } @@ -540,7 +539,6 @@ export class SessionEventHandler { if (event.planMode !== undefined) patch.planMode = event.planMode; if (event.permission !== undefined) { patch.permissionMode = event.permission; - patch.yolo = event.permission === 'yolo'; } if (event.model !== undefined) patch.model = event.model; if (Object.keys(patch).length > 0) this.host.setAppState(patch); @@ -690,7 +688,8 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const { state } = this.host; - if (!state.appState.isStreaming) { + const hasActiveTurn = state.currentTurnId !== undefined; + if (!hasActiveTurn) { this.host.setAppState({ isCompacting: false, streamingPhase: 'idle', @@ -712,13 +711,14 @@ export class SessionEventHandler { private handleSubagentSpawned(event: SubagentSpawnedEvent): void { const { state, streamingUI } = this.host; - state.subagentParentToolCallIds.set(event.subagentId, event.parentToolCallId); - state.subagentNames.set(event.subagentId, event.subagentName); + state.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + }); if (event.runInBackground) { const meta = this.buildBackgroundAgentMetadata(event); state.backgroundAgentMetadata.set(event.subagentId, meta); - state.backgroundAgents.add(event.subagentId); this.appendBackgroundAgentEntry('started', meta); this.syncBackgroundAgentBadge(); return; @@ -744,11 +744,9 @@ export class SessionEventHandler { private handleSubagentCompleted(event: SubagentCompletedEvent): void { const { state } = this.host; const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); - if (state.backgroundAgents.delete(event.subagentId)) { - this.syncBackgroundAgentBadge(); - } if (backgroundMeta !== undefined) { state.backgroundAgentMetadata.delete(event.subagentId); + this.syncBackgroundAgentBadge(); const taskId = this.findAgentTaskId(event.subagentId); if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { return; @@ -776,11 +774,9 @@ export class SessionEventHandler { private handleSubagentFailed(event: SubagentFailedEvent): void { const { state } = this.host; const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); - if (state.backgroundAgents.delete(event.subagentId)) { - this.syncBackgroundAgentBadge(); - } if (backgroundMeta !== undefined) { state.backgroundAgentMetadata.delete(event.subagentId); + this.syncBackgroundAgentBadge(); const taskId = this.findAgentTaskId(event.subagentId); if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { return; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 9d38c1d701..80aa341d72 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -105,7 +105,6 @@ export class SessionReplayRenderer { private hydrateBackgroundState(agent: ResumedAgentState): void { const { state } = this.host; const projection = replayBackgroundProjection(agent.background); - state.backgroundAgents = new Set(projection.backgroundAgents); state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); state.backgroundTasks = new Map( agent.background.map((info) => [info.taskId, info]), @@ -275,11 +274,9 @@ export class SessionReplayRenderer { streamingUI.onThinkingEnd(); } if (text.length > 0) { - state.assistantStreamActive = true; streamingUI.onStreamingTextStart(); streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); - state.assistantStreamActive = false; state.assistantDraft = ''; } } @@ -474,7 +471,6 @@ export class SessionReplayRenderer { detail: status.detail, backgroundAgentStatus: status, }); - state.backgroundAgents.delete(meta.agentId); state.backgroundAgentMetadata.delete(meta.agentId); } } diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index 04ed631276..e27f375873 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -194,10 +194,10 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P let enabled: boolean; if (args === 'on') enabled = true; else if (args === 'off') enabled = false; - else enabled = !host.state.appState.yolo; + else enabled = host.state.appState.permissionMode !== 'yolo'; await session.setPermission(enabled ? 'yolo' : 'manual'); - host.setAppState({ yolo: enabled, permissionMode: enabled ? 'yolo' : 'manual' }); + host.setAppState({ permissionMode: enabled ? 'yolo' : 'manual' }); if (enabled) { host.showNotice( 'YOLO mode: ON', @@ -341,7 +341,7 @@ export async function handleInitCommand(host: SlashCommandHost): Promise { }); } catch (error) { if (isAbortError(error)) { - host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + host.setAppState({ streamingPhase: 'idle' }); host.resetLivePane(); return; } @@ -788,7 +788,7 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = } async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { - if (host.state.appState.isStreaming) { + if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; } @@ -930,7 +930,7 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod return; } - host.setAppState({ permissionMode: mode, yolo: mode === 'yolo' }); + host.setAppState({ permissionMode: mode }); host.showNotice(`Permission mode: ${mode}`); } @@ -1095,7 +1095,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi const intent = resolveSlashCommandInput({ input, skillCommandMap: host.skillCommandMap, - isStreaming: host.state.appState.isStreaming, + isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, }); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index dd333a813b..8390a65cca 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -137,9 +137,8 @@ export class StreamingUIController { finalizeAssistantStream(): void { this.flushNow(); - if (this.host.state.assistantStreamActive) { + if (this.host.state.streamingBlock !== null) { this.onStreamingTextEnd(); - this.host.state.assistantStreamActive = false; } this.host.state.assistantDraft = ''; this.host.updateActivityPane(); @@ -151,9 +150,7 @@ export class StreamingUIController { this.pendingThinkingFlush = false; this.clearFlushTimerIfIdle(); this.host.state.assistantDraft = ''; - this.host.state.assistantStreamActive = false; - this.host.state.streamingComponent = undefined; - this.host.state.streamingTranscriptEntry = undefined; + this.host.state.streamingBlock = null; this.host.state.thinkingDraft = ''; this.host.disposeActiveThinkingComponent(); } @@ -178,7 +175,7 @@ export class StreamingUIController { finalizeTurn(sendQueued: (item: QueuedMessage) => void): void { const { state } = this.host; - if (!state.appState.isStreaming) return; + if (state.appState.streamingPhase === 'idle') return; this.host.deferUserMessages = false; const completedTurnKey = state.currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; @@ -189,7 +186,7 @@ export class StreamingUIController { if (state.queuedMessages.length > 0) { const [next, ...rest] = state.queuedMessages; state.queuedMessages = rest; - this.host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); if (next !== undefined) { setTimeout(() => { @@ -199,7 +196,7 @@ export class StreamingUIController { return; } - this.host.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); notifyTerminalOnce(state, `turn-complete:${completedTurnKey}`, { title: 'Kimi Code task complete', @@ -222,30 +219,28 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', }; - state.streamingComponent = new AssistantMessageComponent( + const component = new AssistantMessageComponent( state.theme.markdownTheme, state.theme.colors, ); - state.streamingTranscriptEntry = entry; + state.streamingBlock = { component, entry }; state.transcriptEntries.push(entry); - state.transcriptContainer.addChild(state.streamingComponent); + state.transcriptContainer.addChild(component); state.ui.requestRender(); } onStreamingTextUpdate(fullText: string): void { const { state } = this.host; - if (state.streamingTranscriptEntry !== undefined) { - state.streamingTranscriptEntry.content = fullText; - } - if (state.streamingComponent) { - state.streamingComponent.updateContent(fullText); + const block = state.streamingBlock; + if (block !== null) { + block.entry.content = fullText; + block.component.updateContent(fullText); state.ui.requestRender(); } } onStreamingTextEnd(): void { - this.host.state.streamingComponent = undefined; - this.host.state.streamingTranscriptEntry = undefined; + this.host.state.streamingBlock = null; } onThinkingUpdate(fullText: string): void { @@ -399,7 +394,7 @@ export class StreamingUIController { }; state.activeToolCalls.set(id, toolCall); - if (state.thinkingDraft.length > 0 || state.assistantStreamActive) { + if (state.thinkingDraft.length > 0 || state.streamingBlock !== null) { this.finalizeLiveTextBuffers('tool'); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 6a1d42a2cf..2d3f0de8cb 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -188,20 +188,15 @@ export interface TUIState { theme: KimiTUIThemeBundle; appState: AppState; startupState: TUIStartupState; - startupNotice: string | undefined; livePane: LivePaneState; transcriptEntries: TranscriptEntry[]; terminalState: TerminalState; - activitySpinner: MoonLoader | undefined; - activitySpinnerStyle: SpinnerStyle | undefined; + activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; activeThinkingComponent: ThinkingComponent | undefined; - streamingComponent: AssistantMessageComponent | undefined; - streamingTranscriptEntry: TranscriptEntry | undefined; + streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null; activeCompactionBlock: CompactionComponent | undefined; toolOutputExpanded: boolean; planExpanded: boolean; - lastActivityMode: string | undefined; - lastHistoryContent: string | undefined; pendingToolComponents: Map; pendingAgentGroup: { readonly turnId: string | undefined; @@ -215,7 +210,6 @@ export interface TUIState { solo?: ToolCallComponent; group?: ReadGroupComponent; } | null; - backgroundAgents: Set; backgroundAgentMetadata: Map; /** * Authoritative live mirror of the BPM. Keyed by `taskId`. Includes @@ -234,18 +228,15 @@ export interface TUIState { renderedSkillActivationIds: Set; renderedMcpServerStatusKeys: Map; mcpServerStatusSpinners: Map; - subagentParentToolCallIds: Map; - subagentNames: Map; + subagentInfo: Map; sessions: SessionRow[]; loadingSessions: boolean; - showingSessionPicker: boolean; - showingHelpPanel: boolean; + activeDialog: 'session-picker' | 'help' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; currentTurnId: string | undefined; currentStep: number; assistantDraft: string; - assistantStreamActive: boolean; thinkingDraft: string; activeToolCalls: Map; streamingToolCallArguments: Map< @@ -262,14 +253,12 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { model: '', workDir: input.workDir, sessionId: '', - yolo: input.cliOptions.yolo, permissionMode: startupPermission, planMode: input.cliOptions.plan, thinking: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, - isStreaming: false, isCompacting: false, isReplaying: false, streamingPhase: 'idle', @@ -321,42 +310,33 @@ export function createTUIState(options: KimiTUIOptions): TUIState { theme, appState: { ...initialAppState }, startupState: 'pending', - startupNotice: options.startup.startupNotice, livePane: { ...INITIAL_LIVE_PANE }, transcriptEntries: [], terminalState: createTerminalState(), - activitySpinner: undefined, - activitySpinnerStyle: undefined, + activitySpinner: null, activeThinkingComponent: undefined, - streamingComponent: undefined, - streamingTranscriptEntry: undefined, + streamingBlock: null, activeCompactionBlock: undefined, toolOutputExpanded: false, planExpanded: false, - lastActivityMode: undefined, - lastHistoryContent: undefined, pendingToolComponents: new Map(), pendingAgentGroup: null, pendingReadGroup: null, - backgroundAgents: new Set(), backgroundAgentMetadata: new Map(), backgroundTasks: new Map(), backgroundTaskTranscriptedTerminal: new Set(), renderedSkillActivationIds: new Set(), renderedMcpServerStatusKeys: new Map(), mcpServerStatusSpinners: new Map(), - subagentParentToolCallIds: new Map(), - subagentNames: new Map(), + subagentInfo: new Map(), sessions: [], loadingSessions: false, - showingSessionPicker: false, - showingHelpPanel: false, + activeDialog: null, tasksBrowser: undefined, externalEditorRunning: false, currentTurnId: undefined, currentStep: 0, assistantDraft: '', - assistantStreamActive: false, thinkingDraft: '', activeToolCalls: new Map(), streamingToolCallArguments: new Map(), @@ -407,6 +387,9 @@ export class KimiTUI { private readonly migrationPlan: MigrationPlan | null; // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; + private startupNotice: string | undefined; + private lastActivityMode: string | undefined; + private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly sessionEventHandler: SessionEventHandler; @@ -440,6 +423,7 @@ export class KimiTUI { this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; this.migrateOnly = startupInput.migrateOnly ?? false; + this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir); @@ -525,7 +509,7 @@ export class KimiTUI { for (const entry of entries) { this.state.editor.addToHistory(entry.content); } - this.state.lastHistoryContent = entries.at(-1)?.content; + this.lastHistoryContent = entries.at(-1)?.content; } catch { /* history is best-effort */ } @@ -627,9 +611,9 @@ export class KimiTUI { // Runs post-init startup tasks: startup notice, picker bootstrap, transcript // replay, and session event subscriptions. private async finishStartup(shouldReplayHistory: boolean): Promise { - if (this.state.startupNotice !== undefined) { - this.showStatus(this.state.startupNotice); - this.state.startupNotice = undefined; + if (this.startupNotice !== undefined) { + this.showStatus(this.startupNotice); + this.startupNotice = undefined; } void this.showTmuxKeyboardWarningIfNeeded(); if (this.state.startupState === 'picker') { @@ -701,8 +685,8 @@ export class KimiTUI { shouldReplayHistory = true; } else { session = await this.harness.createSession(createSessionOptions); - this.state.startupNotice = combineStartupNotice( - this.state.startupNotice, + this.startupNotice = combineStartupNotice( + this.startupNotice, `No sessions to continue under "${workDir}"; starting a fresh session.`, ); } @@ -850,6 +834,10 @@ export class KimiTUI { this.terminalFocusTrackingDispose = undefined; } + appendStartupNotice(extra: string): void { + this.startupNotice = combineStartupNotice(this.startupNotice, extra); + } + // Returns the currently selected session id shown by the UI. getCurrentSessionId(): string { return this.state.appState.sessionId; @@ -913,15 +901,15 @@ export class KimiTUI { return; } - if (this.state.appState.isStreaming) { + if (this.state.appState.isCompacting) { this.clearPendingExit(); - this.cancelCurrentStream(); + this.cancelCurrentCompaction(); return; } - if (this.state.appState.isCompacting) { + if (this.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); - this.cancelCurrentCompaction(); + this.cancelCurrentStream(); return; } @@ -948,16 +936,16 @@ export class KimiTUI { editor.onEscape = () => { if (this.pendingExit) this.clearPendingExit(); - if (this.state.showingSessionPicker) { + if (this.state.activeDialog === 'session-picker') { this.hideSessionPicker(); return; } - if (this.state.appState.isStreaming) { - this.cancelCurrentStream(); - return; - } if (this.state.appState.isCompacting) { this.cancelCurrentCompaction(); + return; + } + if (this.state.appState.streamingPhase !== 'idle') { + this.cancelCurrentStream(); } }; @@ -986,7 +974,7 @@ export class KimiTUI { editor.onTogglePlanExpand = () => this.togglePlanExpansion(); editor.onCtrlS = () => { - if (!this.state.appState.isStreaming || this.state.appState.isCompacting) return; + if (this.state.appState.streamingPhase === 'idle' || this.state.appState.isCompacting) return; const text = editor.getText().trim(); const queuedTexts = this.state.queuedMessages.map((m) => m.text); this.state.queuedMessages = []; @@ -1024,7 +1012,7 @@ export class KimiTUI { }; editor.onUpArrowEmpty = () => { - if (!this.state.appState.isStreaming && !this.state.appState.isCompacting) return false; + if (this.state.appState.streamingPhase === 'idle' && !this.state.appState.isCompacting) return false; const recalled = this.recallLastQueued(); if (recalled !== undefined) { editor.setText(recalled); @@ -1200,14 +1188,14 @@ export class KimiTUI { private async persistInputHistory(text: string): Promise { const trimmed = text.trim(); if (trimmed.length === 0) return; - if (trimmed === this.state.lastHistoryContent) return; + if (trimmed === this.lastHistoryContent) return; this.state.editor.addToHistory(trimmed); try { const file = getInputHistoryFile(this.state.appState.workDir); - const written = await appendInputHistory(file, trimmed, this.state.lastHistoryContent); - if (written) this.state.lastHistoryContent = trimmed; + const written = await appendInputHistory(file, trimmed, this.lastHistoryContent); + if (written) this.lastHistoryContent = trimmed; } catch { - this.state.lastHistoryContent = trimmed; + this.lastHistoryContent = trimmed; } } @@ -1250,7 +1238,6 @@ export class KimiTUI { pendingQuestion: null, }); this.setAppState({ - isStreaming: true, streamingPhase: 'waiting', streamingStartTime: Date.now(), }); @@ -1258,7 +1245,7 @@ export class KimiTUI { // Ends a failed session request and renders the failure to the transcript. failSessionRequest(message: string): void { - this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + this.setAppState({ streamingPhase: 'idle' }); this.resetLivePane(); this.showError(message); } @@ -1309,7 +1296,7 @@ export class KimiTUI { private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { if ( this.deferUserMessages || - this.state.appState.isStreaming || + this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting ) { this.enqueueMessage(input, options); @@ -1326,7 +1313,7 @@ export class KimiTUI { } return; } - if (!this.state.appState.isStreaming) { + if (this.state.appState.streamingPhase === 'idle') { for (const part of input) { this.sendMessageInternal(session, part); } @@ -1372,7 +1359,7 @@ export class KimiTUI { // Applies app-state changes and refreshes dependent UI surfaces. setAppState(patch: Partial): void { if (!hasPatchChanges(this.state.appState, patch)) return; - const busyChanged = 'isStreaming' in patch || 'isCompacting' in patch; + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); if ('planMode' in patch) this.updateEditorBorderHighlight(); this.state.footer.setState(this.state.appState); @@ -1441,7 +1428,6 @@ export class KimiTUI { model: status.model ?? '', thinking: status.thinkingLevel !== 'off', permissionMode: status.permission, - yolo: status.permission === 'yolo', planMode: status.planMode, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, @@ -1525,13 +1511,11 @@ export class KimiTUI { this.harness.interactiveAgentId = MAIN_AGENT_ID; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); - this.state.backgroundAgents.clear(); this.state.backgroundAgentMetadata.clear(); this.state.backgroundTasks.clear(); this.state.backgroundTaskTranscriptedTerminal.clear(); this.tasksBrowserController.close(); - this.state.subagentParentToolCallIds.clear(); - this.state.subagentNames.clear(); + this.state.subagentInfo.clear(); this.state.renderedSkillActivationIds.clear(); this.state.renderedMcpServerStatusKeys.clear(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); @@ -1549,7 +1533,7 @@ export class KimiTUI { this.showStatus('Already on this session.'); return true; } - if (this.state.appState.isStreaming) { + if (this.state.appState.streamingPhase !== 'idle') { this.showError('Cannot switch sessions while streaming — press Esc or Ctrl-C first.'); return false; } @@ -1872,13 +1856,13 @@ export class KimiTUI { this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); if ( - effectiveMode === this.state.lastActivityMode && + effectiveMode === this.lastActivityMode && (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') ) { return; } - this.state.lastActivityMode = effectiveMode; + this.lastActivityMode = effectiveMode; this.state.activityContainer.clear(); switch (effectiveMode) { @@ -1933,7 +1917,7 @@ export class KimiTUI { // Computes the effective activity-pane mode from modal and streaming state. private resolveActivityPaneMode(): EffectiveActivityPaneMode { - if (this.state.showingSessionPicker) return 'hidden'; + if (this.state.activeDialog === 'session-picker') return 'hidden'; if (this.state.livePane.pendingApproval !== null) return 'hidden'; if (this.state.appState.isCompacting) return 'hidden'; if (this.state.livePane.pendingQuestion !== null) return 'hidden'; @@ -1959,7 +1943,7 @@ export class KimiTUI { messages: queued, colors: this.state.theme.colors, isCompacting: this.state.appState.isCompacting, - isStreaming: this.state.appState.isStreaming, + isStreaming: this.state.appState.streamingPhase !== 'idle', canSteerImmediately: !this.deferUserMessages, }), ); @@ -2063,30 +2047,29 @@ export class KimiTUI { label = '', colorFn?: (s: string) => string, ): MoonLoader { - if (this.state.activitySpinnerStyle !== style) { + if (this.state.activitySpinner?.style !== style) { this.stopActivitySpinner(); } - if (this.state.activitySpinner === undefined) { - this.state.activitySpinner = new MoonLoader(this.state.ui, style, colorFn, label); - this.state.activitySpinnerStyle = style; - return this.state.activitySpinner; + if (this.state.activitySpinner === null) { + const instance = new MoonLoader(this.state.ui, style, colorFn, label); + this.state.activitySpinner = { instance, style }; + return instance; } - this.state.activitySpinner.setLabel(label); + this.state.activitySpinner.instance.setLabel(label); if (colorFn !== undefined) { - this.state.activitySpinner.setColorFn(colorFn); + this.state.activitySpinner.instance.setColorFn(colorFn); } - return this.state.activitySpinner; + return this.state.activitySpinner.instance; } // Stops and clears the activity spinner. private stopActivitySpinner(): void { - if (this.state.activitySpinner) { - this.state.activitySpinner.stop(); - this.state.activitySpinner = undefined; + if (this.state.activitySpinner !== null) { + this.state.activitySpinner.instance.stop(); + this.state.activitySpinner = null; } - this.state.activitySpinnerStyle = undefined; } // ========================================================================= @@ -2150,7 +2133,7 @@ export class KimiTUI { // Shows the help panel with the current slash command list. showHelpPanel(): void { - this.state.showingHelpPanel = true; + this.state.activeDialog = 'help'; this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), @@ -2164,7 +2147,7 @@ export class KimiTUI { // Hides the help panel and returns focus to the editor. private hideHelpPanel(): void { - this.state.showingHelpPanel = false; + this.state.activeDialog = null; this.restoreEditor(); } @@ -2187,13 +2170,13 @@ export class KimiTUI { // Hides the session picker and restores the editor. private hideSessionPicker(): void { - this.state.showingSessionPicker = false; + this.state.activeDialog = null; this.restoreEditor(); } // Mounts a session picker with shared selection behavior. private mountSessionPicker(onCancel: () => void): void { - this.state.showingSessionPicker = true; + this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( new SessionPickerComponent({ sessions: this.state.sessions, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 16be355031..a28ba8f4ef 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -14,14 +14,12 @@ export interface AppState { model: string; workDir: string; sessionId: string; - yolo: boolean; permissionMode: PermissionMode; planMode: boolean; thinking: boolean; contextUsage: number; contextTokens: number; maxContextTokens: number; - isStreaming: boolean; isCompacting: boolean; isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 5328d62247..510b5769e4 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -41,7 +41,6 @@ export interface SkillActivationProjection { } export interface ReplayBackgroundProjection { - readonly backgroundAgents: ReadonlySet; readonly backgroundAgentMetadata: ReadonlyMap; } @@ -55,7 +54,6 @@ export function appStateFromResumeAgent(agent: ResumedAgentState): Partial(); const backgroundAgentMetadata = new Map(); for (const info of background) { if (!info.taskId.startsWith('agent-')) continue; if (isTerminalBackgroundTask(info)) continue; - backgroundAgents.add(info.taskId); backgroundAgentMetadata.set(info.taskId, { agentId: info.taskId, parentToolCallId: info.taskId, description: info.description, }); } - return { backgroundAgents, backgroundAgentMetadata }; + return { backgroundAgentMetadata }; } export function createReplayRenderContext(): ReplayRenderContext { diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index d0eef5d08a..f0cbc4ac31 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -96,7 +96,7 @@ describe('updateActivityPane terminal progress', () => { expect(setProgress).toHaveBeenCalledTimes(1); expect(setProgress).toHaveBeenLastCalledWith(true); - expect(state.activitySpinner).toBeUndefined(); + expect(state.activitySpinner).toBeNull(); expect(state.activityContainer.children).toHaveLength(0); state.appState.streamingPhase = 'idle'; @@ -104,7 +104,7 @@ describe('updateActivityPane terminal progress', () => { expect(setProgress).toHaveBeenCalledTimes(2); expect(setProgress).toHaveBeenLastCalledWith(false); - expect(state.activitySpinner).toBeUndefined(); + expect(state.activitySpinner).toBeNull(); } finally { vi.useRealTimers(); } diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index e66e7f11f8..bb846c7ebd 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -14,14 +14,12 @@ function baseState(overrides: Partial = {}): AppState { model: 'k2', workDir: '/tmp/proj', sessionId: 'sess_1', - yolo: false, permissionMode: 'manual', planMode: false, thinking: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, - isStreaming: false, isCompacting: false, isReplaying: false, streamingPhase: 'idle', diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 4861b68cd4..7d14815c2d 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -23,14 +23,12 @@ function baseState(overrides: Partial = {}): AppState { model: 'k2', workDir: '/tmp', sessionId: 'sess_1', - yolo: false, permissionMode: 'manual', planMode: false, thinking: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, - isStreaming: false, isCompacting: false, isReplaying: false, streamingPhase: 'idle', diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 4da7438410..2e7f51bcca 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -9,14 +9,12 @@ function fakeInitialAppState(): AppState { model: 'test-model', workDir: '/tmp/kimi-test', sessionId: 'sess-1', - yolo: false, permissionMode: 'manual', planMode: false, thinking: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, - isStreaming: false, isCompacting: false, isReplaying: false, streamingPhase: 'idle', @@ -62,7 +60,6 @@ describe('createTUIState', () => { expect(state.appState.model).toBe('test-model'); expect(state.appState.sessionId).toBe('sess-1'); expect(state.startupState).toBe('pending'); - expect(state.startupNotice).toBeUndefined(); // LivePane defaults. expect(state.livePane.mode).toBe('idle'); @@ -75,27 +72,20 @@ describe('createTUIState', () => { expect(state.pendingToolComponents.size).toBe(0); expect(state.activeToolCalls.size).toBe(0); expect(state.streamingToolCallArguments.size).toBe(0); - expect(state.backgroundAgents.size).toBe(0); expect(state.backgroundAgentMetadata.size).toBe(0); expect(state.renderedSkillActivationIds.size).toBe(0); // Boolean, counter, and optional-field defaults. expect(state.toolOutputExpanded).toBe(false); - expect(state.showingSessionPicker).toBe(false); - expect(state.showingHelpPanel).toBe(false); + expect(state.activeDialog).toBeNull(); expect(state.externalEditorRunning).toBe(false); expect(state.loadingSessions).toBe(false); expect(state.currentTurnId).toBeUndefined(); expect(state.currentStep).toBe(0); - expect(state.assistantStreamActive).toBe(false); expect(state.assistantDraft).toBe(''); expect(state.thinkingDraft).toBe(''); - expect(state.lastHistoryContent).toBeUndefined(); - expect(state.lastActivityMode).toBeUndefined(); - expect(state.activitySpinner).toBeUndefined(); - expect(state.activitySpinnerStyle).toBeUndefined(); - expect(state.streamingComponent).toBeUndefined(); - expect(state.streamingTranscriptEntry).toBeUndefined(); + expect(state.activitySpinner).toBeNull(); + expect(state.streamingBlock).toBeNull(); expect(state.activeCompactionBlock).toBeUndefined(); expect(state.pendingAgentGroup).toBeNull(); expect(state.pendingReadGroup).toBeNull(); 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 3639b0723f..be48c56d88 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 @@ -372,7 +372,7 @@ describe('KimiTUI message flow', () => { it('tracks blocked slash commands as invalid without counting them as executed commands', async () => { const { driver, harness } = await makeDriver(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; for (const command of ['/new', '/model', '/sessions']) { harness.track.mockClear(); @@ -473,7 +473,6 @@ describe('KimiTUI message flow', () => { expect(session.setPermission).toHaveBeenCalledWith('yolo'); }); expect(driver.state.appState).toMatchObject({ - yolo: true, permissionMode: 'yolo', }); expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'yolo' }); @@ -626,7 +625,7 @@ describe('KimiTUI message flow', () => { driver.handleUserInput('hello'); expect(session.prompt).toHaveBeenCalledWith('hello'); - expect(driver.state.appState.isStreaming).toBe(true); + expect(driver.state.appState.streamingPhase).not.toBe('idle'); expect(driver.state.appState.streamingPhase).toBe('waiting'); expect(driver.state.livePane.mode).toBe('waiting'); expect(driver.state.transcriptEntries).toEqual([ @@ -659,7 +658,7 @@ describe('KimiTUI message flow', () => { it('queues editor input instead of prompting while a turn is already streaming', async () => { const { driver, session, harness } = await makeDriver(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; harness.track.mockClear(); driver.handleUserInput('queued message'); @@ -673,13 +672,13 @@ describe('KimiTUI message flow', () => { it('cancels active streaming from Escape and Ctrl-C editor shortcuts', async () => { const { driver, session } = await makeDriver(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; driver.state.editor.onEscape?.(); expect(session.cancel).toHaveBeenCalledTimes(1); session.cancel.mockClear(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; driver.state.editor.onCtrlC?.(); expect(session.cancel).toHaveBeenCalledTimes(1); @@ -690,7 +689,7 @@ describe('KimiTUI message flow', () => { try { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; driver.state.appState.streamingStartTime = 1; driver.state.currentTurnId = '1'; driver.state.queuedMessages = [{ text: 'next' }]; @@ -709,7 +708,7 @@ describe('KimiTUI message flow', () => { expect(sendQueued).toHaveBeenCalledWith({ text: 'next' }); expect(driver.state.queuedMessages).toEqual([]); - expect(driver.state.appState.isStreaming).toBe(false); + expect(driver.state.appState.streamingPhase).toBe('idle'); } finally { vi.useRealTimers(); } @@ -731,7 +730,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - const component = driver.state.streamingComponent; + const component = driver.state.streamingBlock?.component; if (component === undefined) throw new Error('expected streaming component'); const updateSpy = vi.spyOn(component, 'updateContent'); @@ -771,7 +770,7 @@ describe('KimiTUI message flow', () => { try { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; driver.sessionEventHandler.handleEvent( { @@ -924,13 +923,13 @@ describe('KimiTUI message flow', () => { expect(session.init).toHaveBeenCalledTimes(1); }); expect(session.prompt).not.toHaveBeenCalled(); - expect(driver.state.appState.isStreaming).toBe(true); + expect(driver.state.appState.streamingPhase).not.toBe('idle'); expect(driver.state.livePane.mode).toBe('waiting'); resolveInit?.(); await vi.waitFor(() => { - expect(driver.state.appState.isStreaming).toBe(false); + expect(driver.state.appState.streamingPhase).toBe('idle'); }); expect(driver.state.livePane.mode).toBe('idle'); expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index d4ea31b135..41c5566556 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -198,7 +198,6 @@ describe("KimiTUI startup", () => { sessionId: "ses-1", model: "k2", permissionMode: "yolo", - yolo: true, planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -349,7 +348,7 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); expect(driver.state.startupState).toBe("ready"); - expect(driver.state.startupNotice).toContain("OAuth login expired"); + expect((driver as any).startupNotice).toContain("OAuth login expired"); expect(driver.state.appState).toMatchObject({ sessionId: "", model: "", @@ -395,7 +394,6 @@ describe("KimiTUI startup", () => { sessionId: "", model: "", permissionMode: "yolo", - yolo: true, planMode: true, }); @@ -418,7 +416,6 @@ describe("KimiTUI startup", () => { sessionId: "ses-1", model: "k2", permissionMode: "yolo", - yolo: true, planMode: true, }); }); @@ -464,7 +461,6 @@ describe("KimiTUI startup", () => { }); expect(driver.state.appState).toMatchObject({ permissionMode: "auto", - yolo: false, }); }); From 935d33e5a729e5ce4a3c911c2ff40824d0ee5118 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:05:15 +0800 Subject: [PATCH 19/28] refactor(tui): extract TUIState types and move streaming state into StreamingUIController Break the type-level circular dependency where controllers imported TUIState from kimi-tui.ts while kimi-tui.ts imported runtime values from controllers. Shared types now live in tui-state.ts (TUIState, createTUIState) and types.ts (KimiTUIOptions, LoginProgressSpinnerHandle, etc.) so the import graph is strictly unidirectional. Move 12 streaming-related fields (currentTurnId, currentStep, assistantDraft, thinkingDraft, streamingBlock, activeThinkingComponent, activeCompactionBlock, activeToolCalls, streamingToolCallArguments, pendingToolComponents, pendingAgentGroup, pendingReadGroup) from the flat TUIState bag into StreamingUIController as instance properties, along with 3 dispose methods. TUIState shrinks from 40+ to 28 fields. --- .../src/tui/controllers/auth-flow.ts | 4 +- .../tui/controllers/session-event-handler.ts | 114 ++++----- .../src/tui/controllers/session-replay.ts | 36 +-- .../src/tui/controllers/slash-commands.ts | 4 +- .../src/tui/controllers/streaming-ui.ts | 214 ++++++++++------ apps/kimi-code/src/tui/index.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 241 ++---------------- apps/kimi-code/src/tui/tui-state.ts | 116 +++++++++ apps/kimi-code/src/tui/types.ts | 31 +++ .../kimi-code/src/tui/utils/terminal-focus.ts | 2 +- .../src/tui/utils/terminal-notification.ts | 2 +- .../kimi-code/src/tui/utils/terminal-theme.ts | 2 +- .../test/tui/create-tui-state.test.ts | 11 - .../test/tui/kimi-tui-message-flow.test.ts | 18 +- .../kimi-code/test/tui/message-replay.test.ts | 14 +- 15 files changed, 406 insertions(+), 405 deletions(-) create mode 100644 apps/kimi-code/src/tui/tui-state.ts diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 3f38c56b25..48cadf45a5 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -3,8 +3,8 @@ import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import type { SessionEventHandler } from './session-event-handler'; -import type { AppState } from '../types'; -import type { KimiTUIOptions, TUIState } from '../kimi-tui'; +import type { AppState, KimiTUIOptions } from '../types'; +import type { TUIState } from '../tui-state'; export interface AuthFlowHost { state: TUIState; 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 575960219a..0003c5cfcf 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -70,7 +70,7 @@ import type { ToolResultBlockData, TranscriptEntry, } from '../types'; -import type { TUIState } from '../kimi-tui'; +import type { TUIState } from '../tui-state'; export interface SessionEventHost { state: TUIState; @@ -153,7 +153,7 @@ export class SessionEventHandler { if (this.routeSubagentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { - this.host.state.currentTurnId = String(event.turnId); + this.host.streamingUI.currentTurnId = String(event.turnId); } switch (event.type) { @@ -207,12 +207,12 @@ export class SessionEventHandler { const subagentId = event.agentId; if (subagentId === MAIN_AGENT_ID) return false; - const { state } = this.host; + const { state, streamingUI } = this.host; const info = state.subagentInfo.get(subagentId); if (info === undefined || info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; - const toolCall = state.pendingToolComponents.get(parentToolCallId); + const toolCall = streamingUI.pendingToolComponents.get(parentToolCallId); if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); @@ -287,7 +287,7 @@ export class SessionEventHandler { private handleTurnBegin(_event: TurnStartedEvent): void { void _event; this.host.streamingUI.resetToolUi(); - this.host.state.currentStep = 0; + this.host.streamingUI.currentStep = 0; this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, @@ -312,7 +312,7 @@ export class SessionEventHandler { private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); - this.host.state.currentStep = event.step; + this.host.streamingUI.currentStep = event.step; this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.patchLivePane({ @@ -330,22 +330,22 @@ export class SessionEventHandler { this.host.streamingUI.flushNow(); if (event.finishReason !== 'max_tokens') return; - const { state } = this.host; + const streamingUI = this.host.streamingUI; const eventTurnId = String(event.turnId); let truncatedCount = 0; - for (const toolCall of state.activeToolCalls.values()) { + for (const toolCall of streamingUI.activeToolCalls.values()) { if (toolCall.result !== undefined) continue; if (toolCall.streamingArguments === undefined) continue; if (toolCall.turnId !== eventTurnId) continue; if (toolCall.step !== event.step) continue; toolCall.truncated = true; - const component = state.pendingToolComponents.get(toolCall.id); + const component = streamingUI.pendingToolComponents.get(toolCall.id); if (component !== undefined) { component.updateToolCall(toolCall); } truncatedCount += 1; } - state.streamingToolCallArguments.clear(); + streamingUI.streamingToolCallArguments.clear(); const title = truncatedCount > 0 @@ -382,28 +382,28 @@ export class SessionEventHandler { } private handleThinkingDelta(event: ThinkingDeltaEvent): void { - const { state } = this.host; - state.thinkingDraft += event.delta; - this.host.streamingUI.markThinkingDirty(); + const { state, streamingUI } = this.host; + streamingUI.thinkingDraft += event.delta; + streamingUI.markThinkingDirty(); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); } - this.host.streamingUI.scheduleFlush(); + streamingUI.scheduleFlush(); } private handleAssistantDelta(event: AssistantDeltaEvent): void { - const { state } = this.host; - if (state.thinkingDraft.length > 0) { - this.host.streamingUI.flushThinkingToTranscript('idle'); + const { state, streamingUI } = this.host; + if (streamingUI.thinkingDraft.length > 0) { + streamingUI.flushThinkingToTranscript('idle'); } - if (state.streamingBlock === null) { - this.host.streamingUI.onStreamingTextStart(); + if (streamingUI.streamingBlock === null) { + streamingUI.onStreamingTextStart(); } - state.assistantDraft += event.delta; - this.host.streamingUI.markAssistantDirty(); + streamingUI.assistantDraft += event.delta; + streamingUI.markAssistantDirty(); this.host.patchLivePane({ mode: 'idle', @@ -413,12 +413,12 @@ export class SessionEventHandler { if (state.appState.streamingPhase !== 'composing') { this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); } - this.host.streamingUI.scheduleFlush(); + streamingUI.scheduleFlush(); } private handleHookResult(event: HookResultEvent): void { this.host.streamingUI.flushNow(); - if (this.host.state.thinkingDraft.length > 0) { + if (this.host.streamingUI.thinkingDraft.length > 0) { this.host.streamingUI.flushThinkingToTranscript('idle'); } this.host.streamingUI.finalizeAssistantStream(); @@ -437,7 +437,7 @@ export class SessionEventHandler { } private handleToolCall(event: ToolCallStartedEvent): void { - const { state, streamingUI } = this.host; + const { streamingUI } = this.host; streamingUI.flushNow(); const toolCall: ToolCallBlockData = { id: event.toolCallId, @@ -445,14 +445,14 @@ export class SessionEventHandler { args: argsRecord(event.args), description: event.description, display: event.display, - step: state.currentStep, - turnId: state.currentTurnId, + step: streamingUI.currentStep, + turnId: streamingUI.currentTurnId, }; - const existing = state.activeToolCalls.get(event.toolCallId); - state.activeToolCalls.set(event.toolCallId, toolCall); + const existing = streamingUI.activeToolCalls.get(event.toolCallId); + streamingUI.activeToolCalls.set(event.toolCallId, toolCall); streamingUI.pendingToolCallFlushIds.delete(event.toolCallId); - state.streamingToolCallArguments.delete(event.toolCallId); - const existingComponent = state.pendingToolComponents.get(event.toolCallId); + streamingUI.streamingToolCallArguments.delete(event.toolCallId); + const existingComponent = streamingUI.pendingToolComponents.get(event.toolCallId); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); } else if (existing === undefined) { @@ -472,14 +472,14 @@ export class SessionEventHandler { if (event.toolCallId.length === 0) return; const { state, streamingUI } = this.host; const id = event.toolCallId; - const existing = state.streamingToolCallArguments.get(id); + const existing = streamingUI.streamingToolCallArguments.get(id); const argumentsText = appendStreamingArgsPreview( existing?.argumentsText, event.argumentsPart, ); - const name = event.name ?? existing?.name ?? state.activeToolCalls.get(id)?.name ?? 'Tool'; + const name = event.name ?? existing?.name ?? streamingUI.activeToolCalls.get(id)?.name ?? 'Tool'; const startedAtMs = existing?.startedAtMs ?? Date.now(); - state.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); + streamingUI.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); streamingUI.pendingToolCallFlushIds.add(id); this.host.patchLivePane({ @@ -497,15 +497,15 @@ export class SessionEventHandler { if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; - const tc = this.host.state.pendingToolComponents.get(event.toolCallId); + const tc = this.host.streamingUI.pendingToolComponents.get(event.toolCallId); if (tc === undefined) return; tc.appendProgress(text); } private handleToolResult(event: ToolResultEvent): void { - const { state, streamingUI } = this.host; + const { streamingUI } = this.host; streamingUI.flushNow(); - const matchedCall = state.activeToolCalls.get(event.toolCallId); + const matchedCall = streamingUI.activeToolCalls.get(event.toolCallId); const resultData: ToolResultBlockData = { tool_call_id: event.toolCallId, output: serializeToolResultOutput(event.output), @@ -526,8 +526,8 @@ export class SessionEventHandler { } } } - state.activeToolCalls.delete(event.toolCallId); - state.streamingToolCallArguments.delete(event.toolCallId); + streamingUI.activeToolCalls.delete(event.toolCallId); + streamingUI.streamingToolCallArguments.delete(event.toolCallId); this.host.patchLivePane({ mode: 'waiting' }); } @@ -688,7 +688,7 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const { state } = this.host; - const hasActiveTurn = state.currentTurnId !== undefined; + const hasActiveTurn = this.host.streamingUI.currentTurnId !== undefined; if (!hasActiveTurn) { this.host.setAppState({ isCompacting: false, @@ -724,12 +724,12 @@ export class SessionEventHandler { return; } - let tc = state.pendingToolComponents.get(event.parentToolCallId); + let tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); if (tc === undefined) { - const toolCall = state.activeToolCalls.get(event.parentToolCallId); + const toolCall = streamingUI.activeToolCalls.get(event.parentToolCallId); if (toolCall !== undefined) { streamingUI.onToolCallStart(toolCall); - tc = state.pendingToolComponents.get(event.parentToolCallId); + tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); } } tc ??= this.createStandaloneSubagentToolCall(event); @@ -742,7 +742,7 @@ export class SessionEventHandler { } private handleSubagentCompleted(event: SubagentCompletedEvent): void { - const { state } = this.host; + const { state, streamingUI } = this.host; const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { state.backgroundAgentMetadata.delete(event.subagentId); @@ -759,20 +759,20 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } - const tc = state.pendingToolComponents.get(event.parentToolCallId); + const tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentCompleted({ contextTokens: event.contextTokens, usage: event.usage, resultSummary: event.resultSummary, }); - if (!state.activeToolCalls.has(event.parentToolCallId)) { - state.pendingToolComponents.delete(event.parentToolCallId); + if (!streamingUI.activeToolCalls.has(event.parentToolCallId)) { + streamingUI.pendingToolComponents.delete(event.parentToolCallId); } } private handleSubagentFailed(event: SubagentFailedEvent): void { - const { state } = this.host; + const { state, streamingUI } = this.host; const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { state.backgroundAgentMetadata.delete(event.subagentId); @@ -787,16 +787,16 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } - const tc = state.pendingToolComponents.get(event.parentToolCallId); + const tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentFailed({ error: event.error }); - if (!state.activeToolCalls.has(event.parentToolCallId)) { - state.pendingToolComponents.delete(event.parentToolCallId); + if (!streamingUI.activeToolCalls.has(event.parentToolCallId)) { + streamingUI.pendingToolComponents.delete(event.parentToolCallId); } } private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent) { - const { state, streamingUI } = this.host; + const { streamingUI } = this.host; const description = event.description ?? `Run ${event.subagentName} agent`; const toolCall: ToolCallBlockData = { id: event.parentToolCallId, @@ -806,11 +806,11 @@ export class SessionEventHandler { subagent_type: event.subagentName, }, description, - step: state.currentStep, - turnId: state.currentTurnId, + step: streamingUI.currentStep, + turnId: streamingUI.currentTurnId, }; streamingUI.onToolCallStart(toolCall); - return state.pendingToolComponents.get(event.parentToolCallId); + return streamingUI.pendingToolComponents.get(event.parentToolCallId); } private findAgentTaskId(subagentId: string): string | undefined { @@ -829,7 +829,7 @@ export class SessionEventHandler { } private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { - const parent = this.host.state.activeToolCalls.get(event.parentToolCallId); + const parent = this.host.streamingUI.activeToolCalls.get(event.parentToolCallId); const description = parent?.args['description'] ?? event.description; return { agentId: event.subagentId, @@ -848,7 +848,7 @@ export class SessionEventHandler { const entry: TranscriptEntry = { id: nextTranscriptId(), kind: 'status', - turnId: this.host.state.currentTurnId, + turnId: this.host.streamingUI.currentTurnId, renderMode: 'plain', content: status.headline, detail: status.detail, @@ -919,7 +919,7 @@ export class SessionEventHandler { const entry: TranscriptEntry = { id: nextTranscriptId(), kind: 'status', - turnId: this.host.state.currentTurnId, + turnId: this.host.streamingUI.currentTurnId, renderMode: 'plain', content: status.headline, detail: status.detail, diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 80aa341d72..a4add19076 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -40,7 +40,7 @@ import { type SkillActivationProjection, } from '../utils/message-replay'; import type { StreamingUIController } from './streaming-ui'; -import type { TUIState } from '../kimi-tui'; +import type { TUIState } from '../tui-state'; export interface SessionReplayHost { state: TUIState; @@ -220,14 +220,14 @@ export class SessionReplayRenderer { private renderToolCalls(context: ReplayRenderContext, toolCalls: readonly ToolCall[]): void { if (toolCalls.length === 0) return; - const { state, streamingUI } = this.host; + const { streamingUI } = this.host; context.stepIndex += 1; this.applyStepContext(context); for (const rawToolCall of toolCalls) { const toolCall = toolCallFromReplayMessage(rawToolCall, context); if (toolCall === undefined) continue; context.toolCalls.set(toolCall.id, toolCall); - state.activeToolCalls.set(toolCall.id, toolCall); + streamingUI.activeToolCalls.set(toolCall.id, toolCall); streamingUI.onToolCallStart(toolCall); } } @@ -246,7 +246,7 @@ export class SessionReplayRenderer { call.result = result; this.applyStepContext(context); this.host.streamingUI.onToolCallEnd(toolCallId, result); - this.host.state.activeToolCalls.delete(toolCallId); + this.host.streamingUI.activeToolCalls.delete(toolCallId); context.completedToolCallIds.add(toolCallId); } @@ -258,12 +258,12 @@ export class SessionReplayRenderer { } private applyStepContext(context: ReplayRenderContext): void { - this.host.state.currentTurnId = context.currentTurnId; - this.host.state.currentStep = context.stepIndex; + this.host.streamingUI.currentTurnId = context.currentTurnId; + this.host.streamingUI.currentStep = context.stepIndex; } private flushAssistant(context: ReplayRenderContext): void { - const { state, streamingUI } = this.host; + const { streamingUI } = this.host; const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); context.assistant = { thinking: [], text: [] }; @@ -277,22 +277,22 @@ export class SessionReplayRenderer { streamingUI.onStreamingTextStart(); streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); - state.assistantDraft = ''; + streamingUI.assistantDraft = ''; } } private cleanupRuntime(context: ReplayRenderContext): void { const { state, streamingUI } = this.host; this.flushAssistant(context); - state.activeToolCalls.clear(); + streamingUI.activeToolCalls.clear(); for (const toolCallId of context.completedToolCallIds) { - state.pendingToolComponents.delete(toolCallId); + streamingUI.pendingToolComponents.delete(toolCallId); } - state.pendingAgentGroup = null; - state.pendingReadGroup = null; - state.currentTurnId = undefined; - state.currentStep = 0; - state.streamingToolCallArguments.clear(); + streamingUI.pendingAgentGroup = null; + streamingUI.pendingReadGroup = null; + streamingUI.currentTurnId = undefined; + streamingUI.currentStep = 0; + streamingUI.streamingToolCallArguments.clear(); streamingUI.pendingToolCallFlushIds.clear(); state.ui.requestRender(); } @@ -412,9 +412,9 @@ export class SessionReplayRenderer { } private removeToolCall(toolCallId: string): void { - const { state } = this.host; - state.activeToolCalls.delete(toolCallId); - state.pendingToolComponents.delete(toolCallId); + const { state, streamingUI } = this.host; + streamingUI.activeToolCalls.delete(toolCallId); + streamingUI.pendingToolComponents.delete(toolCallId); const index = state.transcriptEntries.findIndex( (entry) => entry.toolCallData?.id === toolCallId, ); diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index e27f375873..eb95e165fe 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -85,8 +85,8 @@ import { import type { AuthFlowController } from './auth-flow'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; -import type { AppState, QueuedMessage } from '../types'; -import type { TUIState, LoginProgressSpinnerHandle } from '../kimi-tui'; +import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; +import type { TUIState } from '../tui-state'; export interface SlashCommandHost { state: TUIState; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 8390a65cca..42fff2ed9a 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -7,6 +7,7 @@ import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; +import { hasDispose } from '../utils/component-capabilities'; import { parseStreamingArgs } from '../utils/event-payload'; import { notifyTerminalOnce } from '../utils/terminal-notification'; import { nextTranscriptId } from '../utils/transcript-id'; @@ -17,8 +18,9 @@ import type { QueuedMessage, ToolCallBlockData, ToolResultBlockData, + TranscriptEntry, } from '../types'; -import type { TUIState } from '../kimi-tui'; +import type { TUIState } from '../tui-state'; export interface StreamingUIHost { state: TUIState; @@ -28,8 +30,6 @@ export interface StreamingUIHost { resetLivePane(): void; updateActivityPane(): void; updateQueueDisplay(): void; - disposeActiveThinkingComponent(): void; - disposeAndClearPendingToolComponents(): void; requireSession(): Session; deferUserMessages: boolean; } @@ -41,8 +41,63 @@ export class StreamingUIController { private pendingThinkingFlush = false; readonly pendingToolCallFlushIds = new Set(); + // --------------------------------------------------------------------------- + // Streaming runtime state (moved from TUIState) + // --------------------------------------------------------------------------- + + currentTurnId: string | undefined = undefined; + currentStep = 0; + assistantDraft = ''; + thinkingDraft = ''; + streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; + activeThinkingComponent: ThinkingComponent | undefined = undefined; + activeCompactionBlock: CompactionComponent | undefined = undefined; + activeToolCalls = new Map(); + streamingToolCallArguments = new Map< + string, + { name?: string; argumentsText: string; startedAtMs: number } + >(); + pendingToolComponents = new Map(); + pendingAgentGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: AgentGroupComponent; + } | null = null; + pendingReadGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: ReadGroupComponent; + } | null = null; + constructor(private readonly host: StreamingUIHost) {} + // --------------------------------------------------------------------------- + // Dispose helpers (moved from KimiTUI) + // --------------------------------------------------------------------------- + + disposeActiveThinkingComponent(): void { + if (this.activeThinkingComponent !== undefined) { + this.activeThinkingComponent.dispose(); + this.activeThinkingComponent = undefined; + } + } + + disposeAndClearPendingToolComponents(): void { + for (const component of this.pendingToolComponents.values()) { + if (hasDispose(component)) component.dispose(); + } + this.pendingToolComponents.clear(); + } + + disposeActiveCompactionBlock(): void { + if (this.activeCompactionBlock !== undefined) { + this.activeCompactionBlock.dispose(); + this.activeCompactionBlock = undefined; + } + } + // --------------------------------------------------------------------------- // Flush control // --------------------------------------------------------------------------- @@ -101,11 +156,11 @@ export class StreamingUIController { this.pendingAssistantFlush = false; this.pendingToolCallFlushIds.clear(); - if (shouldFlushThinking && this.host.state.thinkingDraft.length > 0) { - this.onThinkingUpdate(this.host.state.thinkingDraft); + if (shouldFlushThinking && this.thinkingDraft.length > 0) { + this.onThinkingUpdate(this.thinkingDraft); } if (shouldFlushAssistant) { - this.onStreamingTextUpdate(this.host.state.assistantDraft); + this.onStreamingTextUpdate(this.assistantDraft); } for (const id of toolCallIds) { this.flushToolCallPreview(id); @@ -126,21 +181,21 @@ export class StreamingUIController { flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { this.flushNow(); - if (this.host.state.thinkingDraft.length === 0) { + if (this.thinkingDraft.length === 0) { this.host.patchLivePane({ mode: nextMode }); return; } - this.host.state.thinkingDraft = ''; + this.thinkingDraft = ''; this.onThinkingEnd(); this.host.patchLivePane({ mode: nextMode }); } finalizeAssistantStream(): void { this.flushNow(); - if (this.host.state.streamingBlock !== null) { + if (this.streamingBlock !== null) { this.onStreamingTextEnd(); } - this.host.state.assistantDraft = ''; + this.assistantDraft = ''; this.host.updateActivityPane(); this.host.state.ui.requestRender(); } @@ -149,23 +204,23 @@ export class StreamingUIController { this.pendingAssistantFlush = false; this.pendingThinkingFlush = false; this.clearFlushTimerIfIdle(); - this.host.state.assistantDraft = ''; - this.host.state.streamingBlock = null; - this.host.state.thinkingDraft = ''; - this.host.disposeActiveThinkingComponent(); + this.assistantDraft = ''; + this.streamingBlock = null; + this.thinkingDraft = ''; + this.disposeActiveThinkingComponent(); } resetToolUi(): void { this.pendingToolCallFlushIds.clear(); this.clearFlushTimerIfIdle(); - this.host.state.streamingToolCallArguments.clear(); - this.host.disposeAndClearPendingToolComponents(); - this.host.state.pendingAgentGroup = null; - this.host.state.pendingReadGroup = null; + this.streamingToolCallArguments.clear(); + this.disposeAndClearPendingToolComponents(); + this.pendingAgentGroup = null; + this.pendingReadGroup = null; } resetToolCallState(): void { - this.host.state.activeToolCalls.clear(); + this.activeToolCalls.clear(); } finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { @@ -178,10 +233,10 @@ export class StreamingUIController { if (state.appState.streamingPhase === 'idle') return; this.host.deferUserMessages = false; const completedTurnKey = - state.currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; + this.currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); this.resetToolCallState(); - state.currentTurnId = undefined; + this.currentTurnId = undefined; if (state.queuedMessages.length > 0) { const [next, ...rest] = state.queuedMessages; @@ -210,12 +265,12 @@ export class StreamingUIController { onStreamingTextStart(): void { const { state } = this.host; - state.pendingAgentGroup = null; - state.pendingReadGroup = null; + this.pendingAgentGroup = null; + this.pendingReadGroup = null; const entry = { id: nextTranscriptId(), kind: 'assistant' as const, - turnId: state.currentTurnId, + turnId: this.currentTurnId, renderMode: 'markdown' as const, content: '', }; @@ -223,52 +278,50 @@ export class StreamingUIController { state.theme.markdownTheme, state.theme.colors, ); - state.streamingBlock = { component, entry }; + this.streamingBlock = { component, entry }; state.transcriptEntries.push(entry); state.transcriptContainer.addChild(component); state.ui.requestRender(); } onStreamingTextUpdate(fullText: string): void { - const { state } = this.host; - const block = state.streamingBlock; + const block = this.streamingBlock; if (block !== null) { block.entry.content = fullText; block.component.updateContent(fullText); - state.ui.requestRender(); + this.host.state.ui.requestRender(); } } onStreamingTextEnd(): void { - this.host.state.streamingBlock = null; + this.streamingBlock = null; } onThinkingUpdate(fullText: string): void { const { state } = this.host; - if (state.activeThinkingComponent === undefined) { - state.pendingAgentGroup = null; - state.pendingReadGroup = null; - state.activeThinkingComponent = new ThinkingComponent( + if (this.activeThinkingComponent === undefined) { + this.pendingAgentGroup = null; + this.pendingReadGroup = null; + this.activeThinkingComponent = new ThinkingComponent( fullText, state.theme.colors, true, 'live', state.ui, ); - if (state.toolOutputExpanded) state.activeThinkingComponent.setExpanded(true); - state.transcriptContainer.addChild(state.activeThinkingComponent); + if (state.toolOutputExpanded) this.activeThinkingComponent.setExpanded(true); + state.transcriptContainer.addChild(this.activeThinkingComponent); } else { - state.activeThinkingComponent.setText(fullText); + this.activeThinkingComponent.setText(fullText); } state.ui.requestRender(); } onThinkingEnd(): void { - const { state } = this.host; - if (state.activeThinkingComponent === undefined) return; - state.activeThinkingComponent.finalize(); - state.activeThinkingComponent = undefined; - state.ui.requestRender(); + if (this.activeThinkingComponent === undefined) return; + this.activeThinkingComponent.finalize(); + this.activeThinkingComponent = undefined; + this.host.state.ui.requestRender(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -285,10 +338,10 @@ export class StreamingUIController { ); if (state.toolOutputExpanded) tc.setExpanded(true); if (state.planExpanded) tc.setPlanExpanded(true); - state.pendingToolComponents.set(toolCall.id, tc); + this.pendingToolComponents.set(toolCall.id, tc); - if (toolCall.name !== 'Agent') state.pendingAgentGroup = null; - if (toolCall.name !== 'Read') state.pendingReadGroup = null; + if (toolCall.name !== 'Agent') this.pendingAgentGroup = null; + if (toolCall.name !== 'Read') this.pendingReadGroup = null; let handled = this.tryAttachAgentToolCall(toolCall, tc); if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); @@ -312,11 +365,11 @@ export class StreamingUIController { onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { const { state } = this.host; - const matchedCall = state.activeToolCalls.get(toolCallId); - const tc = state.pendingToolComponents.get(toolCallId); + const matchedCall = this.activeToolCalls.get(toolCallId); + const tc = this.pendingToolComponents.get(toolCallId); if (tc) { tc.setResult(result); - state.pendingToolComponents.delete(toolCallId); + this.pendingToolComponents.delete(toolCallId); state.ui.requestRender(); return; } @@ -349,29 +402,29 @@ export class StreamingUIController { beginCompaction(instruction?: string): void { const { state } = this.host; - if (state.activeCompactionBlock !== undefined) { - state.activeCompactionBlock.markDone(); - state.activeCompactionBlock = undefined; + if (this.activeCompactionBlock !== undefined) { + this.activeCompactionBlock.markDone(); + this.activeCompactionBlock = undefined; } const block = new CompactionComponent(state.theme.colors, state.ui, instruction); - state.activeCompactionBlock = block; + this.activeCompactionBlock = block; state.transcriptContainer.addChild(block); state.ui.requestRender(); } endCompaction(tokensBefore?: number, tokensAfter?: number): void { - const block = this.host.state.activeCompactionBlock; + const block = this.activeCompactionBlock; if (block === undefined) return; block.markDone(tokensBefore, tokensAfter); - this.host.state.activeCompactionBlock = undefined; + this.activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } cancelCompaction(): void { - const block = this.host.state.activeCompactionBlock; + const block = this.activeCompactionBlock; if (block === undefined) return; block.markCanceled(); - this.host.state.activeCompactionBlock = undefined; + this.activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } @@ -380,25 +433,24 @@ export class StreamingUIController { // --------------------------------------------------------------------------- private flushToolCallPreview(id: string): void { - const { state } = this.host; - const streaming = state.streamingToolCallArguments.get(id); + const streaming = this.streamingToolCallArguments.get(id); if (streaming === undefined) return; const toolCall: ToolCallBlockData = { id, - name: streaming.name ?? state.activeToolCalls.get(id)?.name ?? 'Tool', + name: streaming.name ?? this.activeToolCalls.get(id)?.name ?? 'Tool', args: parseStreamingArgs(streaming.argumentsText), streamingArguments: streaming.argumentsText, streamingStartedAtMs: streaming.startedAtMs, - step: state.currentStep, - turnId: state.currentTurnId, + step: this.currentStep, + turnId: this.currentTurnId, }; - state.activeToolCalls.set(id, toolCall); + this.activeToolCalls.set(id, toolCall); - if (state.thinkingDraft.length > 0 || state.streamingBlock !== null) { + if (this.thinkingDraft.length > 0 || this.streamingBlock !== null) { this.finalizeLiveTextBuffers('tool'); } - const existingComponent = state.pendingToolComponents.get(id); + const existingComponent = this.pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); } else if (toolCall.name !== 'Agent') { @@ -409,21 +461,21 @@ export class StreamingUIController { private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { const { state } = this.host; if (toolCall.name !== 'Agent') { - state.pendingAgentGroup = null; + this.pendingAgentGroup = null; return false; } - const step = toolCall.step ?? state.currentStep; - const turnId = toolCall.turnId ?? state.currentTurnId; - const pending = state.pendingAgentGroup; + const step = toolCall.step ?? this.currentStep; + const turnId = toolCall.turnId ?? this.currentTurnId; + const pending = this.pendingAgentGroup; if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - state.pendingAgentGroup = null; + this.pendingAgentGroup = null; } - const cur = state.pendingAgentGroup; + const cur = this.pendingAgentGroup; if (cur === null) { - state.pendingAgentGroup = { step, turnId, solo: tc }; + this.pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; @@ -436,14 +488,14 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { - state.pendingAgentGroup = { step, turnId, solo: tc }; + this.pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } const group = this.upgradeSoloAgentToGroup(solo); group.attach(toolCall.id, tc); - state.pendingAgentGroup = { step, turnId, group }; + this.pendingAgentGroup = { step, turnId, group }; state.ui.requestRender(); return true; } @@ -466,21 +518,21 @@ export class StreamingUIController { private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { const { state } = this.host; if (toolCall.name !== 'Read') { - state.pendingReadGroup = null; + this.pendingReadGroup = null; return false; } - const step = toolCall.step ?? state.currentStep; - const turnId = toolCall.turnId ?? state.currentTurnId; - const pending = state.pendingReadGroup; + const step = toolCall.step ?? this.currentStep; + const turnId = toolCall.turnId ?? this.currentTurnId; + const pending = this.pendingReadGroup; if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - state.pendingReadGroup = null; + this.pendingReadGroup = null; } - const cur = state.pendingReadGroup; + const cur = this.pendingReadGroup; if (cur === null) { - state.pendingReadGroup = { step, turnId, solo: tc }; + this.pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; @@ -493,14 +545,14 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { - state.pendingReadGroup = { step, turnId, solo: tc }; + this.pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } const group = this.upgradeSoloReadToGroup(solo); group.attach(toolCall.id, tc); - state.pendingReadGroup = { step, turnId, group }; + this.pendingReadGroup = { step, turnId, group }; state.ui.requestRender(); return true; } diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts index e4581aa398..06af194eca 100644 --- a/apps/kimi-code/src/tui/index.ts +++ b/apps/kimi-code/src/tui/index.ts @@ -1,3 +1,3 @@ export { KimiTUI } from './kimi-tui'; export type { KimiTUIStartupInput } from './kimi-tui'; -export type { KimiTUIOptions } from './kimi-tui'; +export type { KimiTUIOptions } from './types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2d3f0de8cb..0dfe679a36 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -11,24 +11,19 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { - Container, deleteAllKittyImages, type Component, type Focusable, getCapabilities, - ProcessTerminal, type SlashCommand, Spacer, - TUI, } from '@earendil-works/pi-tui'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, ApprovalResponse, - BackgroundTaskInfo, CreateSessionOptions, - Event, KimiHarness, PermissionMode, PromptPart, @@ -55,11 +50,9 @@ import { type SkillListSession, } from './commands'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; -import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; import { CHROME_GUTTER } from './constant/rendering'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; -import { TodoPanelComponent } from './components/chrome/todo-panel'; import { WelcomeComponent } from './components/chrome/welcome'; import { ApprovalPanelComponent, @@ -68,19 +61,16 @@ import { import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; -import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { SessionPickerComponent } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; import { SessionEventHandler } from './controllers/session-event-handler'; import * as slashCommands from './controllers/slash-commands'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; -import { TasksBrowserController, type TasksBrowserState } from './controllers/tasks-browser'; -import { CustomEditor } from './components/editor/custom-editor'; +import { TasksBrowserController } from './controllers/tasks-browser'; import { FileMentionProvider } from './components/editor/file-mention-provider'; -import { AgentGroupComponent } from './components/messages/agent-group'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; -import { ReadGroupComponent } from './components/messages/read-group'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, @@ -108,19 +98,23 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { createKimiTUIThemeBundle } from './theme/bundle'; import type { ResolvedTheme } from './theme/colors'; import type { Theme } from './theme/index'; import { INITIAL_LIVE_PANE, type AppState, - type BackgroundAgentMetadata, + type KimiTUIOptions, type LivePaneState, + type LoginProgressSpinnerHandle, + type PendingExit, type QueuedMessage, - type ToolCallBlockData, type TranscriptEntry, + type TUIStartupOptions, + type TUIStartupState, } from './types'; -import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { createTUIState, type TUIState } from './tui-state'; +import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage, @@ -133,11 +127,20 @@ import { setProcessTitle } from './utils/proctitle'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; -import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; import { nextTranscriptId } from './utils/transcript-id'; +export type { TUIState } from './tui-state'; +export { createTUIState } from './tui-state'; +export type { + KimiTUIOptions, + LoginProgressSpinnerHandle, + PendingExit, + TUIStartupOptions, + TUIStartupState, +} from './types'; + export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; readonly tuiConfig: TuiConfig; @@ -150,102 +153,8 @@ export interface KimiTUIStartupInput { readonly migrateOnly?: boolean; } -export interface PendingExit { - readonly kind: 'ctrl-c' | 'ctrl-d'; - readonly timer: ReturnType; -} - type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; -export interface TUIStartupOptions { - readonly sessionFlag?: string; - readonly continueLast: boolean; - readonly yolo: boolean; - readonly plan: boolean; - readonly model?: string; - readonly startupNotice?: string; -} - -export type TUIStartupState = 'pending' | 'ready' | 'picker'; - -export interface KimiTUIOptions { - initialAppState: AppState; - startup: TUIStartupOptions; - resolvedTheme?: ResolvedTheme; -} - -export interface TUIState { - ui: TUI; - terminal: ProcessTerminal; - transcriptContainer: Container; - activityContainer: Container; - todoPanelContainer: Container; - todoPanel: TodoPanelComponent; - queueContainer: Container; - editorContainer: Container; - footer: FooterComponent; - editor: CustomEditor; - theme: KimiTUIThemeBundle; - appState: AppState; - startupState: TUIStartupState; - livePane: LivePaneState; - transcriptEntries: TranscriptEntry[]; - terminalState: TerminalState; - activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; - activeThinkingComponent: ThinkingComponent | undefined; - streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null; - activeCompactionBlock: CompactionComponent | undefined; - toolOutputExpanded: boolean; - planExpanded: boolean; - pendingToolComponents: Map; - pendingAgentGroup: { - readonly turnId: string | undefined; - readonly step: number; - solo?: ToolCallComponent; - group?: AgentGroupComponent; - } | null; - pendingReadGroup: { - readonly turnId: string | undefined; - readonly step: number; - solo?: ToolCallComponent; - group?: ReadGroupComponent; - } | null; - backgroundAgentMetadata: Map; - /** - * Authoritative live mirror of the BPM. Keyed by `taskId`. Includes - * both bash and agent tasks, and retains terminal entries until they - * are explicitly forgotten (kept so transcript replay and footer - * lookups stay consistent). - */ - backgroundTasks: Map; - /** - * Task IDs whose terminal transcript card has already been pushed. - * Used to dedupe between the BPM `background.task.terminated` event - * and the older `subagent.completed/failed` flow, both of which - * arrive for `agent-*` tasks. - */ - backgroundTaskTranscriptedTerminal: Set; - renderedSkillActivationIds: Set; - renderedMcpServerStatusKeys: Map; - mcpServerStatusSpinners: Map; - subagentInfo: Map; - sessions: SessionRow[]; - loadingSessions: boolean; - activeDialog: 'session-picker' | 'help' | null; - tasksBrowser: TasksBrowserState | undefined; - externalEditorRunning: boolean; - currentTurnId: string | undefined; - currentStep: number; - assistantDraft: string; - thinkingDraft: string; - activeToolCalls: Map; - streamingToolCallArguments: Map< - string, - { name?: string; argumentsText: string; startedAtMs: number } - >; - queuedMessages: QueuedMessage[]; -} - // Builds the app-state snapshot used before a session is attached. function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.yolo ? 'yolo' : 'manual'; @@ -273,88 +182,12 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { }; } -// Creates all pi-tui components and mutable runtime state owned by KimiTUI. -export function createTUIState(options: KimiTUIOptions): TUIState { - const initialAppState = options.initialAppState; - const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); - - const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); - - // Every chrome container runs with a 2-column outer gutter on each - // side. That gives the transcript, panels, the editor and the - // statusline a shared left edge — the input box's `│` lines up with - // panel borders like Welcome's `│`, and bullets / `>` share a column. - const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const todoPanel = new TodoPanelComponent(theme.colors); - const queueContainer = 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, () => { - ui.requestRender(); - }); - - return { - ui, - terminal, - transcriptContainer, - activityContainer, - todoPanelContainer, - todoPanel, - queueContainer, - editorContainer, - footer, - editor, - theme, - appState: { ...initialAppState }, - startupState: 'pending', - livePane: { ...INITIAL_LIVE_PANE }, - transcriptEntries: [], - terminalState: createTerminalState(), - activitySpinner: null, - activeThinkingComponent: undefined, - streamingBlock: null, - activeCompactionBlock: undefined, - toolOutputExpanded: false, - planExpanded: false, - pendingToolComponents: new Map(), - pendingAgentGroup: null, - pendingReadGroup: null, - backgroundAgentMetadata: new Map(), - backgroundTasks: new Map(), - backgroundTaskTranscriptedTerminal: new Set(), - renderedSkillActivationIds: new Set(), - renderedMcpServerStatusKeys: new Map(), - mcpServerStatusSpinners: new Map(), - subagentInfo: new Map(), - sessions: [], - loadingSessions: false, - activeDialog: null, - tasksBrowser: undefined, - externalEditorRunning: false, - currentTurnId: undefined, - currentStep: 0, - assistantDraft: '', - thinkingDraft: '', - activeToolCalls: new Map(), - streamingToolCallArguments: new Map(), - queuedMessages: [], - }; -} - interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly hasMedia?: boolean; } -export interface LoginProgressSpinnerHandle { - /** Stops the login progress row and replaces it with a final status. */ - stop(opts: { ok: boolean; label: string }): void; -} - export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -1227,7 +1060,7 @@ export class KimiTUI { // Resets request-scoped state before submitting work to the active session. beginSessionRequest(): void { - this.state.currentTurnId = undefined; + this.streamingUI.currentTurnId = undefined; this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.streamingUI.resetToolCallState(); @@ -1324,7 +1157,7 @@ export class KimiTUI { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', - turnId: this.state.currentTurnId, + turnId: this.streamingUI.currentTurnId, renderMode: 'plain', content: part, }); @@ -1521,8 +1354,8 @@ export class KimiTUI { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); - this.state.currentTurnId = undefined; - this.state.currentStep = 0; + this.streamingUI.currentTurnId = undefined; + this.streamingUI.currentStep = 0; this.streamingUI.resetLiveText(); this.updateQueueDisplay(); } @@ -1746,30 +1579,6 @@ export class KimiTUI { this.state.transcriptContainer.addChild(welcome); } - // Disposes the active compaction component if one is mounted. - private disposeActiveCompactionBlock(): void { - if (this.state.activeCompactionBlock !== undefined) { - this.state.activeCompactionBlock.dispose(); - this.state.activeCompactionBlock = undefined; - } - } - - // Disposes the active thinking component if one is mounted. - disposeActiveThinkingComponent(): void { - if (this.state.activeThinkingComponent !== undefined) { - this.state.activeThinkingComponent.dispose(); - this.state.activeThinkingComponent = undefined; - } - } - - // Disposes and forgets all pending live tool-call components. - disposeAndClearPendingToolComponents(): void { - for (const component of this.state.pendingToolComponents.values()) { - if (hasDispose(component)) component.dispose(); - } - this.state.pendingToolComponents.clear(); - } - private clearTerminalInlineImages(): void { if (getCapabilities().images !== 'kitty') return; this.state.terminal.write(deleteAllKittyImages()); @@ -1779,7 +1588,7 @@ export class KimiTUI { private clearTranscriptAndRedraw(): void { this.streamingUI.discardPending(); this.state.transcriptEntries = []; - this.disposeActiveCompactionBlock(); + this.streamingUI.disposeActiveCompactionBlock(); this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts new file mode 100644 index 0000000000..ec9963c090 --- /dev/null +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -0,0 +1,116 @@ +import { + Container, + ProcessTerminal, + TUI, +} from '@earendil-works/pi-tui'; +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { FooterComponent } from './components/chrome/footer'; +import { GutterContainer } from './components/chrome/gutter-container'; +import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; +import { TodoPanelComponent } from './components/chrome/todo-panel'; +import type { SessionRow } from './components/dialogs/session-picker'; +import { CustomEditor } from './components/editor/custom-editor'; +import { CHROME_GUTTER } from './constant/rendering'; +import type { TasksBrowserState } from './controllers/tasks-browser'; +import { createKimiTUIThemeBundle, type KimiTUIThemeBundle } from './theme/bundle'; +import { createTerminalState, type TerminalState } from './utils/terminal-state'; +import { + INITIAL_LIVE_PANE, + type AppState, + type BackgroundAgentMetadata, + type KimiTUIOptions, + type LivePaneState, + type QueuedMessage, + type TranscriptEntry, + type TUIStartupState, +} from './types'; + +export interface TUIState { + ui: TUI; + terminal: ProcessTerminal; + transcriptContainer: Container; + activityContainer: Container; + todoPanelContainer: Container; + todoPanel: TodoPanelComponent; + queueContainer: Container; + editorContainer: Container; + footer: FooterComponent; + editor: CustomEditor; + theme: KimiTUIThemeBundle; + appState: AppState; + startupState: TUIStartupState; + livePane: LivePaneState; + transcriptEntries: TranscriptEntry[]; + terminalState: TerminalState; + activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; + toolOutputExpanded: boolean; + planExpanded: boolean; + backgroundAgentMetadata: Map; + backgroundTasks: Map; + backgroundTaskTranscriptedTerminal: Set; + renderedSkillActivationIds: Set; + renderedMcpServerStatusKeys: Map; + mcpServerStatusSpinners: Map; + subagentInfo: Map; + sessions: SessionRow[]; + loadingSessions: boolean; + activeDialog: 'session-picker' | 'help' | null; + tasksBrowser: TasksBrowserState | undefined; + externalEditorRunning: boolean; + queuedMessages: QueuedMessage[]; +} + +export function createTUIState(options: KimiTUIOptions): TUIState { + const initialAppState = options.initialAppState; + const theme = createKimiTUIThemeBundle(initialAppState.theme, options.resolvedTheme); + + const terminal = new ProcessTerminal(); + const ui = new TUI(terminal); + + const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const todoPanel = new TodoPanelComponent(theme.colors); + const queueContainer = 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, () => { + ui.requestRender(); + }); + + return { + ui, + terminal, + transcriptContainer, + activityContainer, + todoPanelContainer, + todoPanel, + queueContainer, + editorContainer, + footer, + editor, + theme, + appState: { ...initialAppState }, + startupState: 'pending', + livePane: { ...INITIAL_LIVE_PANE }, + transcriptEntries: [], + terminalState: createTerminalState(), + activitySpinner: null, + toolOutputExpanded: false, + planExpanded: false, + backgroundAgentMetadata: new Map(), + backgroundTasks: new Map(), + backgroundTaskTranscriptedTerminal: new Set(), + renderedSkillActivationIds: new Set(), + renderedMcpServerStatusKeys: new Map(), + mcpServerStatusSpinners: new Map(), + subagentInfo: new Map(), + sessions: [], + loadingSessions: false, + activeDialog: null, + tasksBrowser: undefined, + externalEditorRunning: false, + queuedMessages: [], + }; +} diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index a28ba8f4ef..40dbdc3f0a 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,6 +9,7 @@ import type { import type { NotificationsConfig } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { Theme } from './theme'; +import type { ResolvedTheme } from './theme/colors'; export interface AppState { model: string; @@ -145,3 +146,33 @@ export const INITIAL_LIVE_PANE: LivePaneState = { pendingApproval: null, pendingQuestion: null, }; + +// --------------------------------------------------------------------------- +// TUI startup / options types (extracted from kimi-tui.ts) +// --------------------------------------------------------------------------- + +export interface TUIStartupOptions { + readonly sessionFlag?: string; + readonly continueLast: boolean; + readonly yolo: boolean; + readonly plan: boolean; + readonly model?: string; + readonly startupNotice?: string; +} + +export type TUIStartupState = 'pending' | 'ready' | 'picker'; + +export interface KimiTUIOptions { + initialAppState: AppState; + startup: TUIStartupOptions; + resolvedTheme?: ResolvedTheme; +} + +export interface PendingExit { + readonly kind: 'ctrl-c' | 'ctrl-d'; + readonly timer: ReturnType; +} + +export interface LoginProgressSpinnerHandle { + stop(opts: { ok: boolean; label: string }): void; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-focus.ts b/apps/kimi-code/src/tui/utils/terminal-focus.ts index 3a3559e607..bc07e92d34 100644 --- a/apps/kimi-code/src/tui/utils/terminal-focus.ts +++ b/apps/kimi-code/src/tui/utils/terminal-focus.ts @@ -4,7 +4,7 @@ import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, } from '#/tui/constant/terminal'; -import type { TUIState } from '#/tui/kimi-tui'; +import type { TUIState } from '#/tui/tui-state'; import type { TerminalState } from '#/tui/utils/terminal-state'; export { diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index a71da93475..2a0247cc77 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,7 +1,7 @@ import type { Terminal } from '@earendil-works/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; -import type { TUIState } from '#/tui/kimi-tui'; +import type { TUIState } from '#/tui/tui-state'; export interface TerminalNotification { readonly title: string; diff --git a/apps/kimi-code/src/tui/utils/terminal-theme.ts b/apps/kimi-code/src/tui/utils/terminal-theme.ts index 71c6b9bbfb..e66896a734 100644 --- a/apps/kimi-code/src/tui/utils/terminal-theme.ts +++ b/apps/kimi-code/src/tui/utils/terminal-theme.ts @@ -10,7 +10,7 @@ import { TERMINAL_THEME_DARK, TERMINAL_THEME_LIGHT, } from "#/tui/constant/terminal"; -import type { TUIState } from "#/tui/kimi-tui"; +import type { TUIState } from "#/tui/tui-state"; import type { ResolvedTheme } from "#/tui/theme/colors"; import { parseOsc11BackgroundTheme } from "#/tui/theme/terminal-background"; diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 2e7f51bcca..6042f3afbd 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -69,9 +69,6 @@ describe('createTUIState', () => { // Empty collections. expect(state.transcriptEntries).toHaveLength(0); expect(state.queuedMessages).toHaveLength(0); - expect(state.pendingToolComponents.size).toBe(0); - expect(state.activeToolCalls.size).toBe(0); - expect(state.streamingToolCallArguments.size).toBe(0); expect(state.backgroundAgentMetadata.size).toBe(0); expect(state.renderedSkillActivationIds.size).toBe(0); @@ -80,14 +77,6 @@ describe('createTUIState', () => { expect(state.activeDialog).toBeNull(); expect(state.externalEditorRunning).toBe(false); expect(state.loadingSessions).toBe(false); - expect(state.currentTurnId).toBeUndefined(); - expect(state.currentStep).toBe(0); - expect(state.assistantDraft).toBe(''); - expect(state.thinkingDraft).toBe(''); expect(state.activitySpinner).toBeNull(); - expect(state.streamingBlock).toBeNull(); - expect(state.activeCompactionBlock).toBeUndefined(); - expect(state.pendingAgentGroup).toBeNull(); - expect(state.pendingReadGroup).toBeNull(); }); }); 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 be48c56d88..25a5d29ec0 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 @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/controllers/slash-commands'; import { promptFeedbackInput, @@ -39,6 +40,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + streamingUI: StreamingUIController; sessionEventHandler: { startSubscription(): void; handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; @@ -691,7 +693,7 @@ describe('KimiTUI message flow', () => { const sendQueued = vi.fn(); driver.state.appState.streamingPhase = 'waiting'; driver.state.appState.streamingStartTime = 1; - driver.state.currentTurnId = '1'; + driver.streamingUI.currentTurnId = '1'; driver.state.queuedMessages = [{ text: 'next' }]; driver.sessionEventHandler.handleEvent( @@ -730,7 +732,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - const component = driver.state.streamingBlock?.component; + const component = driver.streamingUI.streamingBlock?.component; if (component === undefined) throw new Error('expected streaming component'); const updateSpy = vi.spyOn(component, 'updateContent'); @@ -803,8 +805,8 @@ describe('KimiTUI message flow', () => { vi.useFakeTimers(); try { const { driver } = await makeDriver(); - driver.state.currentTurnId = '1'; - driver.state.currentStep = 1; + driver.streamingUI.currentTurnId = '1'; + driver.streamingUI.currentStep = 1; driver.sessionEventHandler.handleEvent( { @@ -819,13 +821,13 @@ describe('KimiTUI message flow', () => { vi.fn(), ); - expect(driver.state.pendingToolComponents.has('call_bash')).toBe(false); - expect(driver.state.activeToolCalls.has('call_bash')).toBe(false); + expect(driver.streamingUI.pendingToolComponents.has('call_bash')).toBe(false); + expect(driver.streamingUI.activeToolCalls.has('call_bash')).toBe(false); await vi.runOnlyPendingTimersAsync(); - expect(driver.state.pendingToolComponents.has('call_bash')).toBe(true); - expect(driver.state.activeToolCalls.get('call_bash')?.args).toMatchObject({ + expect(driver.streamingUI.pendingToolComponents.has('call_bash')).toBe(true); + expect(driver.streamingUI.activeToolCalls.get('call_bash')?.args).toMatchObject({ command: 'echo hi', }); } finally { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index c21c8af3e7..23aa8f2122 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -11,6 +11,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; @@ -18,6 +19,7 @@ vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); interface ReplayDriver { readonly state: TUIState; + readonly streamingUI: StreamingUIController; init(): Promise; switchToSession(session: Session, statusMessage: string): Promise; } @@ -241,9 +243,9 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); - expect(driver.state.pendingAgentGroup).toBeNull(); - expect(driver.state.pendingToolComponents.has('call_agent_1')).toBe(false); - expect(driver.state.pendingToolComponents.has('call_agent_2')).toBe(false); + expect(driver.streamingUI.pendingAgentGroup).toBeNull(); + expect(driver.streamingUI.pendingToolComponents.has('call_agent_1')).toBe(false); + expect(driver.streamingUI.pendingToolComponents.has('call_agent_2')).toBe(false); }); it('groups replayed Read calls from one assistant message using live grouping', async () => { @@ -270,9 +272,9 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(ReadGroupComponent); expect((group as ReadGroupComponent).size()).toBe(2); - expect(driver.state.pendingReadGroup).toBeNull(); - expect(driver.state.pendingToolComponents.has('call_read_1')).toBe(false); - expect(driver.state.pendingToolComponents.has('call_read_2')).toBe(false); + expect(driver.streamingUI.pendingReadGroup).toBeNull(); + expect(driver.streamingUI.pendingToolComponents.has('call_read_1')).toBe(false); + expect(driver.streamingUI.pendingToolComponents.has('call_read_2')).toBe(false); }); it('hydrates todo and background snapshot state from resumed main agent', async () => { From 995ca180dcc240db29d0a5cc04826e3280292ed6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:11:39 +0800 Subject: [PATCH 20/28] refactor(tui): split slash-commands.ts by domain into 4 focused modules Extract the 1223-line slash-commands.ts into domain-specific files: - auth-commands.ts (~350 lines): login, logout, connect, OAuth flows - config-commands.ts (~380 lines): plan, yolo, compact, model/theme/ editor/permission pickers and apply logic - info-commands.ts (~185 lines): feedback, usage, status, mcp reports - session-commands.ts (~105 lines): title, fork, init slash-commands.ts is now a slim routing hub (~210 lines) that owns SlashCommandHost, dispatchInput, and the builtin command switch. All public handlers are re-exported so existing consumers are unaffected. --- .../src/tui/controllers/auth-commands.ts | 349 ++++++ .../src/tui/controllers/config-commands.ts | 383 ++++++ .../src/tui/controllers/info-commands.ts | 185 +++ .../src/tui/controllers/session-commands.ts | 105 ++ .../src/tui/controllers/slash-commands.ts | 1068 +---------------- 5 files changed, 1075 insertions(+), 1015 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/auth-commands.ts create mode 100644 apps/kimi-code/src/tui/controllers/config-commands.ts create mode 100644 apps/kimi-code/src/tui/controllers/info-commands.ts create mode 100644 apps/kimi-code/src/tui/controllers/session-commands.ts diff --git a/apps/kimi-code/src/tui/controllers/auth-commands.ts b/apps/kimi-code/src/tui/controllers/auth-commands.ts new file mode 100644 index 0000000000..5d2e579146 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/auth-commands.ts @@ -0,0 +1,349 @@ +import { + applyOpenPlatformConfig, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + OpenPlatformApiError, + type ManagedKimiCodeModelInfo, + type ManagedKimiConfigShape, + type OpenPlatformDefinition, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogBaseUrl, + catalogProviderModels, + CatalogFetchError, + fetchCatalog, + inferWireType, + loadBuiltInCatalog, + log, + type Catalog, +} from '@moonshot-ai/kimi-code-sdk'; + +import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; +import type { ChoiceOption } from '../components/dialogs/choice-picker'; +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; +import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { LoginProgressSpinnerHandle } from '../types'; +import { + promptApiKey, + promptCatalogProviderSelection, + promptLogoutProviderSelection, + promptModelSelectionForCatalog, + promptModelSelectionForOpenPlatform, + promptPlatformSelection, +} from './slash-command-prompts'; +import type { SlashCommandHost } from './slash-commands'; + +// --------------------------------------------------------------------------- +// Auth: login / logout / connect +// --------------------------------------------------------------------------- + +export async function handleLoginCommand(host: SlashCommandHost): Promise { + const platformId = await promptPlatformSelection(host); + if (platformId === undefined) return; + + if (platformId === 'kimi-code') { + await handleKimiCodeOAuthLogin(host); + return; + } + + const platform = getOpenPlatformById(platformId); + if (platform === undefined) return; + await handleOpenPlatformLogin(host, platform); +} + +async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { + const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const alreadyLoggedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + + let spinner: LoginProgressSpinnerHandle | undefined; + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + try { + await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { + signal: controller.signal, + onDeviceCode: (data) => { + spinner = host.showLoginAuthorizationPrompt(data); + }, + }); + spinner?.stop({ ok: true, label: 'Logged in.' }); + spinner = undefined; + try { + await host.authFlow.refreshConfigAfterLogin(); + } catch (refreshError) { + const message = formatErrorMessage(refreshError); + host.showError(`Authentication successful, but failed to refresh config: ${message}`); + return; + } + host.track('login', { + provider: DEFAULT_OAUTH_PROVIDER_NAME, + already_logged_in: alreadyLoggedIn, + }); + if (alreadyLoggedIn) { + host.showStatus('Already logged in. Model configuration refreshed.'); + } + } catch (error) { + const cancelled = controller.signal.aborted; + spinner?.stop({ + ok: false, + label: cancelled ? 'Login cancelled.' : 'Login failed.', + }); + spinner = undefined; + if (cancelled) return; + log.warn('login failed', { + providerName: DEFAULT_OAUTH_PROVIDER_NAME, + alreadyLoggedIn, + sessionId: host.session?.id, + error, + }); + const message = formatErrorMessage(error); + host.showError(`Login failed: ${message}`); + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } +} + +async function handleOpenPlatformLogin( + host: SlashCommandHost, + platform: OpenPlatformDefinition, +): Promise { + const apiKey = await promptApiKey(host, platform.name); + if (apiKey === undefined) return; + + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + + let models: ManagedKimiCodeModelInfo[]; + try { + models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = filterModelsByPrefix(models, platform); + } catch (error) { + if (controller.signal.aborted) return; + const msg = formatErrorMessage(error); + host.showError(`Failed to verify API key: ${msg}`); + if ( + error instanceof OpenPlatformApiError && + error.status === 401 + ) { + host.showStatus( + 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', + ); + } + return; + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } + + if (models.length === 0) { + host.showError('No models available for this platform.'); + return; + } + + const selection = await promptModelSelectionForOpenPlatform(host, models, platform); + if (selection === undefined) return; + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[platform.id] !== undefined) { + await host.harness.removeProvider(platform.id); + } + + const config = await host.harness.getConfig(); + applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + platform, + models, + selectedModel: selection.model, + thinking: selection.thinking, + apiKey, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('login', { provider: platform.id, method: 'api_key' }); + host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); +} + +export async function handleConnectCommand(host: SlashCommandHost, args: string): Promise { + const resolution = resolveConnectCatalogRequest(args); + if (resolution.kind === 'error') { + host.showError(resolution.message); + return; + } + const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request; + + let catalog: Catalog | undefined; + + if (preferBuiltIn) { + const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); + if (builtIn !== undefined) { + host.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.'); + catalog = builtIn; + } + } + + if (catalog === undefined) { + const controller = new AbortController(); + const cancel = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancel; + + const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${url}`); + try { + catalog = await fetchCatalog(url, controller.signal); + spinner.stop({ ok: true, label: 'Catalog loaded.' }); + } catch (error) { + if (controller.signal.aborted) { + spinner.stop({ ok: false, label: 'Aborted.' }); + } else { + const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; + if (!allowBuiltInFallback) { + spinner.stop({ ok: false, label: 'Failed to load catalog.' }); + host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); + } else { + const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); + if (fallback !== undefined) { + spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' }); + catalog = fallback; + } else { + spinner.stop({ ok: false, label: 'Failed to load catalog.' }); + host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); + } + } + } + } finally { + if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; + } + } + + if (catalog === undefined) return; + + const providerId = await promptCatalogProviderSelection(host, catalog); + if (providerId === undefined) return; + const entry = catalog[providerId]; + if (entry === undefined) return; + + const models = catalogProviderModels(entry); + if (models.length === 0) { + host.showError(`Provider "${providerId}" has no usable models in this catalog.`); + return; + } + + const selection = await promptModelSelectionForCatalog(host, providerId, models); + if (selection === undefined) return; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; + + const wire = inferWireType(entry); + if (wire === undefined) return; + const baseUrl = catalogBaseUrl(entry, wire); + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[providerId] !== undefined) { + await host.harness.removeProvider(providerId); + } + + const config = await host.harness.getConfig(); + applyCatalogProvider(config, { + providerId, + wire, + baseUrl, + apiKey, + models, + selectedModelId: selection.model.id, + thinking: selection.thinking, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('connect', { provider: providerId, model: selection.model.id }); + host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); +} + +export async function handleLogoutCommand(host: SlashCommandHost): Promise { + const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const hasOAuthToken = oauthStatus.providers.some( + (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, + ); + const config = await host.harness.getConfig(); + const hasManagedRemnant = + hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; + const apiKeyProviderIds = Object.keys(config.providers ?? {}) + .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) + .toSorted(); + + const options: ChoiceOption[] = []; + if (hasManagedRemnant) { + options.push({ + value: DEFAULT_OAUTH_PROVIDER_NAME, + label: PRODUCT_NAME, + description: 'OAuth login', + }); + } + for (const id of apiKeyProviderIds) { + const baseUrl = config.providers[id]?.baseUrl; + options.push({ + value: id, + label: id, + description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined, + }); + } + + if (options.length === 0) { + host.showStatus('Nothing to logout.'); + return; + } + + const currentModel = host.state.appState.model.trim(); + const currentProvider = host.state.appState.availableModels[currentModel]?.provider; + + const target = await promptLogoutProviderSelection(host, options, currentProvider); + if (target === undefined) return; + + if (target === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + } else { + await host.harness.removeProvider(target); + } + + if (target === currentProvider) { + await host.authFlow.refreshConfigAfterLogout(); + await host.authFlow.clearActiveSessionAfterLogout(); + } else { + const updated = await host.harness.getConfig({ reload: true }); + host.setAppState({ + availableModels: updated.models ?? {}, + availableProviders: updated.providers ?? {}, + }); + } + + host.track('logout', { provider: target }); + const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; + host.showStatus(`Logged out from ${label}.`); +} diff --git a/apps/kimi-code/src/tui/controllers/config-commands.ts b/apps/kimi-code/src/tui/controllers/config-commands.ts new file mode 100644 index 0000000000..ea25743b2f --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/config-commands.ts @@ -0,0 +1,383 @@ +import type { PermissionMode, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { ModelSelectorComponent } from '../components/dialogs/model-selector'; +import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; +import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; +import { saveTuiConfig } from '../config'; +import type { Theme } from '../theme'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { isTheme } from '../theme/index'; +import { formatErrorMessage } from '../utils/event-payload'; +import { showUsage } from './info-commands'; +import type { SlashCommandHost } from './slash-commands'; + +// --------------------------------------------------------------------------- +// Plan / Config commands +// --------------------------------------------------------------------------- + +export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const subcmd = args.trim().toLowerCase(); + if (subcmd === 'clear') { + await session.clearPlan(); + host.showNotice('Plan cleared'); + return; + } + + let enabled: boolean; + if (subcmd.length === 0) enabled = !host.state.appState.planMode; + else if (subcmd === 'on') enabled = true; + else if (subcmd === 'off') enabled = false; + else { + host.showError(`Unknown plan subcommand: ${subcmd}`); + return; + } + + await applyPlanMode(host, session, enabled); +} + +async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise { + try { + await session.setPlanMode(enabled); + host.setAppState({ planMode: enabled }); + if (enabled) { + const plan = await session.getPlan().catch(() => null); + host.showNotice( + 'Plan mode: ON', + plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, + ); + return; + } + host.showNotice('Plan mode: OFF'); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set plan mode: ${msg}`); + } +} + +export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let enabled: boolean; + if (args === 'on') enabled = true; + else if (args === 'off') enabled = false; + else enabled = host.state.appState.permissionMode !== 'yolo'; + + await session.setPermission(enabled ? 'yolo' : 'manual'); + host.setAppState({ permissionMode: enabled ? 'yolo' : 'manual' }); + if (enabled) { + host.showNotice( + 'YOLO mode: ON', + 'All actions will be approved automatically. Use with caution.', + ); + return; + } + host.showNotice('YOLO mode: OFF'); +} + +export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const customInstruction = args.trim() || undefined; + await session.compact({ instruction: customInstruction }); +} + +export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise { + const command = args.trim(); + if (command.length === 0) { + showEditorPicker(host); + return; + } + await applyEditorChoice(host, command); +} + +export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise { + const theme = args.trim(); + if (theme.length === 0) { + showThemePicker(host); + return; + } + if (!isTheme(theme)) { + host.showError(`Unknown theme: ${theme}`); + return; + } + await applyThemeChoice(host, theme); +} + +export function handleModelCommand(host: SlashCommandHost, args: string): void { + const alias = args.trim(); + if (alias.length === 0) { + showModelPicker(host); + return; + } + if (host.state.appState.availableModels[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); + return; + } + showModelPicker(host, alias); +} + +// --------------------------------------------------------------------------- +// Pickers & config apply +// --------------------------------------------------------------------------- + +function showEditorPicker(host: SlashCommandHost): void { + const currentValue = host.state.appState.editorCommand ?? ''; + host.mountEditorReplacement( + new EditorSelectorComponent({ + currentValue, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyEditorChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { + const previous = host.state.appState.editorCommand ?? ''; + if (value === previous && value.length > 0) { + host.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); + return; + } + + const editorCommand = value.length > 0 ? value : null; + try { + await saveTuiConfig({ + theme: host.state.appState.theme, + editorCommand, + notifications: host.state.appState.notifications, + }); + } catch (error) { + host.showStatus( + `Failed to save editor: ${formatErrorMessage(error)}`, + host.state.theme.colors.error, + ); + return; + } + + host.setAppState({ editorCommand }); + host.showStatus( + value.length > 0 + ? `Editor set to "${value}".` + : 'Editor set to auto-detect ($VISUAL / $EDITOR).', + ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const entries = Object.entries(host.state.appState.availableModels); + if (entries.length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.', + ); + return; + } + host.mountEditorReplacement( + new ModelSelectorComponent({ + models: host.state.appState.availableModels, + currentValue: host.state.appState.model, + selectedValue, + currentThinking: host.state.appState.thinking, + colors: host.state.theme.colors, + searchable: true, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return; + } + + const level = thinking ? 'on' : 'off'; + const prevModel = host.state.appState.model; + const prevThinking = host.state.appState.thinking; + const runtimeChanged = alias !== prevModel || thinking !== prevThinking; + + const session = host.session; + try { + if (session === undefined && runtimeChanged) { + await host.authFlow.activateModelAfterLogin(alias, thinking); + } else if (session !== undefined) { + if (alias !== prevModel) { + await session.setModel(alias); + } + if (thinking !== prevThinking) { + await session.setThinking(level); + } + } + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch model: ${msg}`); + return; + } + + host.setAppState({ model: alias, thinking }); + if (session === undefined && runtimeChanged) { + if (alias !== prevModel) { + host.track('model_switch', { model: alias }); + } + if (thinking !== prevThinking) { + host.track('thinking_toggle', { enabled: thinking }); + } + } + + let persisted = false; + try { + persisted = await persistModelSelection(host, alias, thinking); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); + return; + } + + const status = runtimeChanged + ? `Switched to ${alias} with thinking ${level}.` + : persisted + ? `Saved ${alias} with thinking ${level} as default.` + : `Already using ${alias} with thinking ${level}.`; + host.showStatus(status, host.state.theme.colors.success); +} + +async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { + const config = await host.harness.getConfig({ reload: true }); + if (config.defaultModel === alias && config.defaultThinking === thinking) { + return false; + } + await host.harness.setConfig({ + defaultModel: alias, + defaultThinking: thinking, + }); + return true; +} + +function showThemePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new ThemeSelectorComponent({ + currentValue: host.state.appState.theme, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyThemeChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { + if (theme === host.state.appState.theme) { + if (theme === 'auto') host.refreshTerminalThemeTracking(); + host.showStatus(`Theme unchanged: "${theme}".`); + return; + } + + try { + await saveTuiConfig({ + theme, + editorCommand: host.state.appState.editorCommand, + notifications: host.state.appState.notifications, + }); + } catch (error) { + host.showStatus( + `Failed to save theme: ${formatErrorMessage(error)}`, + host.state.theme.colors.error, + ); + return; + } + + const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme; + host.applyTheme(theme, resolved); + host.refreshTerminalThemeTracking(); + host.track('theme_switch', { theme }); + const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; + host.showStatus(`Theme set to "${theme}"${detail}.`); +} + +export function showPermissionPicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new PermissionSelectorComponent({ + currentValue: host.state.appState.permissionMode, + colors: host.state.theme.colors, + onSelect: (value) => { + host.restoreEditor(); + void applyPermissionChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise { + if (mode === host.state.appState.permissionMode) { + host.showStatus(`Permission mode unchanged: ${mode}.`); + return; + } + + try { + await host.requireSession().setPermission(mode); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set permission mode: ${msg}`); + return; + } + + host.setAppState({ permissionMode: mode }); + host.showNotice(`Permission mode: ${mode}`); +} + +export function showSettingsSelector(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SettingsSelectorComponent({ + colors: host.state.theme.colors, + onSelect: (value) => { + handleSettingsSelection(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelection): void { + host.restoreEditor(); + switch (value) { + case 'model': showModelPicker(host); return; + case 'permission': showPermissionPicker(host); return; + case 'theme': showThemePicker(host); return; + case 'editor': showEditorPicker(host); return; + case 'usage': void showUsage(host); return; + } +} diff --git a/apps/kimi-code/src/tui/controllers/info-commands.ts b/apps/kimi-code/src/tui/controllers/info-commands.ts new file mode 100644 index 0000000000..dc2d80da59 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/info-commands.ts @@ -0,0 +1,185 @@ +import { release as osRelease, type as osType } from 'node:os'; + +import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; + +import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; +import { buildStatusReportLines } from '../components/messages/status-panel'; +import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; +import { + FEEDBACK_ISSUE_URL, + FEEDBACK_STATUS_CANCELLED, + FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NOT_SIGNED_IN, + FEEDBACK_STATUS_SUBMITTING, + FEEDBACK_STATUS_SUCCESS, + FEEDBACK_TELEMETRY_EVENT, + feedbackSessionLine, + withFeedbackVersionPrefix, +} from '../constant/feedback'; +import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { openUrl } from '../utils/open-url'; +import { promptFeedbackInput } from './slash-command-prompts'; +import type { SlashCommandHost } from './slash-commands'; + +// --------------------------------------------------------------------------- +// Feedback +// --------------------------------------------------------------------------- + +export async function handleFeedbackCommand(host: SlashCommandHost): Promise { + const fallback = (reason: string): void => { + host.showStatus(reason); + host.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); + }; + + const providerKey = host.state.appState.availableModels[host.state.appState.model]?.provider; + if (!isManagedUsageProvider(providerKey)) { + fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); + return; + } + + const content = await promptFeedbackInput(host); + if (content === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + const res = await host.harness.auth.submitFeedback({ + content, + sessionId: host.state.appState.sessionId, + version: withFeedbackVersionPrefix(host.state.appState.version), + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind === 'ok') { + spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + return; + } + + spinner.stop({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); +} + +// --------------------------------------------------------------------------- +// Info commands +// --------------------------------------------------------------------------- + +interface SessionUsageResult { + readonly usage?: SessionUsage; + readonly error?: string; +} + +interface RuntimeStatusResult { + readonly status?: SessionStatus; + readonly error?: string; +} + +interface ManagedUsageResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; +} + +export async function showUsage(host: SlashCommandHost): Promise { + const sessionUsage = await loadSessionUsageReport(host); + const managedUsage = await loadManagedUsageReport(host); + const lines = buildUsageReportLines({ + colors: host.state.theme.colors, + sessionUsage: sessionUsage.usage, + sessionUsageError: sessionUsage.error, + contextUsage: host.state.appState.contextUsage, + contextTokens: host.state.appState.contextTokens, + maxContextTokens: host.state.appState.maxContextTokens, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showStatusReport(host: SlashCommandHost): Promise { + const [runtimeStatus, managedUsage] = await Promise.all([ + loadRuntimeStatusReport(host), + loadManagedUsageReport(host), + ]); + const appState = host.state.appState; + const lines = buildStatusReportLines({ + colors: host.state.theme.colors, + version: appState.version, + model: appState.model, + workDir: appState.workDir, + sessionId: appState.sessionId, + sessionTitle: appState.sessionTitle, + thinking: appState.thinking, + permissionMode: appState.permissionMode, + planMode: appState.planMode, + contextUsage: appState.contextUsage, + contextTokens: appState.contextTokens, + maxContextTokens: appState.maxContextTokens, + availableModels: appState.availableModels, + status: runtimeStatus.status, + statusError: runtimeStatus.error, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, + }); + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +export async function showMcpServers(host: SlashCommandHost): Promise { + let servers: readonly McpServerInfo[]; + try { + servers = await host.requireSession().listMcpServers(); + } catch (error) { + host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); + return; + } + + const lines = buildMcpStatusReportLines({ + colors: host.state.theme.colors, + servers, + }); + const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function loadSessionUsageReport(host: SlashCommandHost): Promise { + try { + return { usage: await host.requireSession().getUsage() }; + } catch (error) { + return { error: formatErrorMessage(error) }; + } +} + +async function loadRuntimeStatusReport(host: SlashCommandHost): Promise { + try { + return { status: await host.requireSession().getStatus() }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +async function loadManagedUsageReport(host: SlashCommandHost): Promise { + const alias = host.state.appState.model; + const providerKey = host.state.appState.availableModels[alias]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; + + let res; + try { + res = await host.harness.auth.getManagedUsage(providerKey); + } catch (error) { + return { error: formatErrorMessage(error) }; + } + if (res.kind === 'error') { + return { error: res.message }; + } + return { usage: { summary: res.summary, limits: res.limits } }; +} diff --git a/apps/kimi-code/src/tui/controllers/session-commands.ts b/apps/kimi-code/src/tui/controllers/session-commands.ts new file mode 100644 index 0000000000..1ec8632a3b --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-commands.ts @@ -0,0 +1,105 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { isAbortError } from '../utils/errors'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './slash-commands'; + +// --------------------------------------------------------------------------- +// Session commands +// --------------------------------------------------------------------------- + +export async function handleTitleCommand(host: SlashCommandHost, args: string): Promise { + const title = args.trim(); + if (title.length === 0) { + const current = host.state.appState.sessionTitle; + host.showStatus( + current !== null && current.length > 0 + ? `Session title: ${current}` + : `Session title: (not set) — id: ${host.state.appState.sessionId}`, + ); + return; + } + + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const newTitle = title.slice(0, 200); + try { + await host.harness.renameSession({ id: session.id, title: newTitle }); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to set title: ${msg}`); + return; + } + host.showStatus(`Session title set to: ${newTitle}`); +} + +export async function handleForkCommand(host: SlashCommandHost, args: string): Promise { + void args; + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const sourceTitle = forkSourceTitle(host, session); + let forked: Session; + try { + forked = await host.harness.forkSession({ + id: session.id, + title: `Fork: ${sourceTitle}`, + }); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to fork session: ${msg}`); + return; + } + + try { + await host.switchToSession(forked, `Session forked (${forked.id}).`); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch to forked session: ${msg}`); + } +} + +function forkSourceTitle(host: SlashCommandHost, session: Session): string { + const currentTitle = host.state.appState.sessionTitle?.trim(); + if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; + + const summaryTitle = + typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; + return summaryTitle.length > 0 ? summaryTitle : session.id; +} + +export async function handleInitCommand(host: SlashCommandHost): Promise { + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + host.deferUserMessages = true; + host.beginSessionRequest(); + try { + await session.init(); + host.track('init_complete'); + host.streamingUI.finalizeTurn((item) => { + host.sendQueuedMessage(session, item); + }); + } catch (error) { + if (isAbortError(error)) { + host.setAppState({ streamingPhase: 'idle' }); + host.resetLivePane(); + return; + } + const msg = error instanceof Error ? error.message : String(error); + host.failSessionRequest(`Init failed: ${msg}`); + } finally { + host.deferUserMessages = false; + } +} diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/controllers/slash-commands.ts index eb95e165fe..4c796346b6 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/controllers/slash-commands.ts @@ -1,81 +1,13 @@ -import { release as osRelease, type as osType } from 'node:os'; - -import { - applyOpenPlatformConfig, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - OpenPlatformApiError, - type DeviceAuthorization, - type ManagedKimiCodeModelInfo, - type ManagedKimiConfigShape, - type OpenPlatformDefinition, -} from '@moonshot-ai/kimi-code-oauth'; -import { - applyCatalogProvider, - catalogBaseUrl, - catalogProviderModels, - CatalogFetchError, - fetchCatalog, - inferWireType, - loadBuiltInCatalog, - log, - type Catalog, - type KimiHarness, - type McpServerInfo, - type PermissionMode, - type Session, - type SessionStatus, - type SessionUsage, -} from '@moonshot-ai/kimi-code-sdk'; - import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; -import { BUILT_IN_CATALOG_JSON } from '../../built-in-catalog'; -import type { ChoiceOption } from '../components/dialogs/choice-picker'; -import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; -import { ModelSelectorComponent } from '../components/dialogs/model-selector'; -import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; -import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; -import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; -import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; -import { buildStatusReportLines } from '../components/messages/status-panel'; -import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; -import { saveTuiConfig } from '../config'; import type { Theme } from '../theme'; import type { ResolvedTheme } from '../theme/colors'; import { - promptApiKey, - promptCatalogProviderSelection, - promptFeedbackInput, - promptLogoutProviderSelection, - promptModelSelectionForCatalog, - promptModelSelectionForOpenPlatform, - promptPlatformSelection, -} from './slash-command-prompts'; -import { - DEFAULT_OAUTH_PROVIDER_NAME, LLM_NOT_SET_MESSAGE, - NO_ACTIVE_SESSION_MESSAGE, - PRODUCT_NAME, } from '../constant/kimi-tui'; -import { - FEEDBACK_ISSUE_URL, - FEEDBACK_STATUS_CANCELLED, - FEEDBACK_STATUS_FALLBACK, - FEEDBACK_STATUS_NOT_SIGNED_IN, - FEEDBACK_STATUS_SUBMITTING, - FEEDBACK_STATUS_SUCCESS, - FEEDBACK_TELEMETRY_EVENT, - feedbackSessionLine, - withFeedbackVersionPrefix, -} from '../constant/feedback'; -import { isManagedUsageProvider } from '../constant/kimi-tui'; -import { isTheme } from '../theme/index'; -import { resolveConnectCatalogRequest } from '../utils/connect-catalog'; -import { isAbortError } from '../utils/errors'; import { formatErrorMessage } from '../utils/event-payload'; -import { openUrl } from '../utils/open-url'; import { parseSlashInput, resolveSlashCommandInput, @@ -88,6 +20,57 @@ import type { TasksBrowserController } from './tasks-browser'; import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; +import { handleConnectCommand, handleLoginCommand, handleLogoutCommand } from './auth-commands'; +import { + handleCompactCommand, + handleEditorCommand, + handleModelCommand, + handlePlanCommand, + handleThemeCommand, + handleYoloCommand, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config-commands'; +import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info-commands'; +import { handleForkCommand, handleInitCommand, handleTitleCommand } from './session-commands'; + +// --------------------------------------------------------------------------- +// Re-exports — keep existing consumers working +// --------------------------------------------------------------------------- + +export { + handleConnectCommand, + handleLoginCommand, + handleLogoutCommand, +} from './auth-commands'; +export { + handleCompactCommand, + handleEditorCommand, + handleModelCommand, + handlePlanCommand, + handleThemeCommand, + handleYoloCommand, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config-commands'; +export { + handleFeedbackCommand, + showMcpServers, + showStatusReport, + showUsage, +} from './info-commands'; +export { + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session-commands'; + +// --------------------------------------------------------------------------- +// Host interface +// --------------------------------------------------------------------------- + export interface SlashCommandHost { state: TUIState; session: Session | undefined; @@ -132,950 +115,6 @@ export interface SlashCommandHost { readonly streamingUI: StreamingUIController; readonly tasksBrowserController: TasksBrowserController; readonly authFlow: AuthFlowController; - -} - -// --------------------------------------------------------------------------- -// Plan / Config commands -// --------------------------------------------------------------------------- - -export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const subcmd = args.trim().toLowerCase(); - if (subcmd === 'clear') { - await session.clearPlan(); - host.showNotice('Plan cleared'); - return; - } - - let enabled: boolean; - if (subcmd.length === 0) enabled = !host.state.appState.planMode; - else if (subcmd === 'on') enabled = true; - else if (subcmd === 'off') enabled = false; - else { - host.showError(`Unknown plan subcommand: ${subcmd}`); - return; - } - - await applyPlanMode(host, session, enabled); -} - -async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: boolean): Promise { - try { - await session.setPlanMode(enabled); - host.setAppState({ planMode: enabled }); - if (enabled) { - const plan = await session.getPlan().catch(() => null); - host.showNotice( - 'Plan mode: ON', - plan?.path !== undefined ? `Plan will be created here: ${plan.path}` : undefined, - ); - return; - } - host.showNotice('Plan mode: OFF'); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to set plan mode: ${msg}`); - } -} - -export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - let enabled: boolean; - if (args === 'on') enabled = true; - else if (args === 'off') enabled = false; - else enabled = host.state.appState.permissionMode !== 'yolo'; - - await session.setPermission(enabled ? 'yolo' : 'manual'); - host.setAppState({ permissionMode: enabled ? 'yolo' : 'manual' }); - if (enabled) { - host.showNotice( - 'YOLO mode: ON', - 'All actions will be approved automatically. Use with caution.', - ); - return; - } - host.showNotice('YOLO mode: OFF'); -} - -export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - const customInstruction = args.trim() || undefined; - await session.compact({ instruction: customInstruction }); -} - -export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise { - const command = args.trim(); - if (command.length === 0) { - showEditorPicker(host); - return; - } - await applyEditorChoice(host, command); -} - -export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise { - const theme = args.trim(); - if (theme.length === 0) { - showThemePicker(host); - return; - } - if (!isTheme(theme)) { - host.showError(`Unknown theme: ${theme}`); - return; - } - await applyThemeChoice(host, theme); -} - -export function handleModelCommand(host: SlashCommandHost, args: string): void { - const alias = args.trim(); - if (alias.length === 0) { - showModelPicker(host); - return; - } - if (host.state.appState.availableModels[alias] === undefined) { - host.showError(`Unknown model alias: ${alias}`); - return; - } - showModelPicker(host, alias); -} - -// --------------------------------------------------------------------------- -// Session commands -// --------------------------------------------------------------------------- - -export async function handleTitleCommand(host: SlashCommandHost, args: string): Promise { - const title = args.trim(); - if (title.length === 0) { - const current = host.state.appState.sessionTitle; - host.showStatus( - current !== null && current.length > 0 - ? `Session title: ${current}` - : `Session title: (not set) — id: ${host.state.appState.sessionId}`, - ); - return; - } - - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const newTitle = title.slice(0, 200); - try { - await host.harness.renameSession({ id: session.id, title: newTitle }); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to set title: ${msg}`); - return; - } - host.showStatus(`Session title set to: ${newTitle}`); -} - -export async function handleForkCommand(host: SlashCommandHost, args: string): Promise { - void args; - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const sourceTitle = forkSourceTitle(host, session); - let forked: Session; - try { - forked = await host.harness.forkSession({ - id: session.id, - title: `Fork: ${sourceTitle}`, - }); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to fork session: ${msg}`); - return; - } - - try { - await host.switchToSession(forked, `Session forked (${forked.id}).`); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to switch to forked session: ${msg}`); - } -} - -function forkSourceTitle(host: SlashCommandHost, session: Session): string { - const currentTitle = host.state.appState.sessionTitle?.trim(); - if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; - - const summaryTitle = - typeof session.summary?.title === 'string' ? session.summary.title.trim() : ''; - return summaryTitle.length > 0 ? summaryTitle : session.id; -} - -export async function handleInitCommand(host: SlashCommandHost): Promise { - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - - host.deferUserMessages = true; - host.beginSessionRequest(); - try { - await session.init(); - host.track('init_complete'); - host.streamingUI.finalizeTurn((item) => { - host.sendQueuedMessage(session, item); - }); - } catch (error) { - if (isAbortError(error)) { - host.setAppState({ streamingPhase: 'idle' }); - host.resetLivePane(); - return; - } - const msg = error instanceof Error ? error.message : String(error); - host.failSessionRequest(`Init failed: ${msg}`); - } finally { - host.deferUserMessages = false; - } -} - -// --------------------------------------------------------------------------- -// Feedback -// --------------------------------------------------------------------------- - -export async function handleFeedbackCommand(host: SlashCommandHost): Promise { - const fallback = (reason: string): void => { - host.showStatus(reason); - host.showStatus(FEEDBACK_ISSUE_URL); - openUrl(FEEDBACK_ISSUE_URL); - }; - - const providerKey = host.state.appState.availableModels[host.state.appState.model]?.provider; - if (!isManagedUsageProvider(providerKey)) { - fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); - return; - } - - const content = await promptFeedbackInput(host); - if (content === undefined) { - host.showStatus(FEEDBACK_STATUS_CANCELLED); - return; - } - - const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); - const res = await host.harness.auth.submitFeedback({ - content, - sessionId: host.state.appState.sessionId, - version: withFeedbackVersionPrefix(host.state.appState.version), - os: `${osType()} ${osRelease()}`, - model: host.state.appState.model.length > 0 ? host.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); - return; - } - - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); -} - - -// --------------------------------------------------------------------------- -// Auth: login / logout / connect -// --------------------------------------------------------------------------- - -export async function handleLoginCommand(host: SlashCommandHost): Promise { - const platformId = await promptPlatformSelection(host); - if (platformId === undefined) return; - - if (platformId === 'kimi-code') { - await handleKimiCodeOAuthLogin(host); - return; - } - - const platform = getOpenPlatformById(platformId); - if (platform === undefined) return; - await handleOpenPlatformLogin(host, platform); -} - -async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { - const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const alreadyLoggedIn = status.providers.some( - (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, - ); - - let spinner: LoginProgressSpinnerHandle | undefined; - const controller = new AbortController(); - const cancelLogin = (): void => { - controller.abort(); - }; - host.cancelInFlight = cancelLogin; - try { - await host.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { - signal: controller.signal, - onDeviceCode: (data) => { - spinner = host.showLoginAuthorizationPrompt(data); - }, - }); - spinner?.stop({ ok: true, label: 'Logged in.' }); - spinner = undefined; - try { - await host.authFlow.refreshConfigAfterLogin(); - } catch (refreshError) { - const message = formatErrorMessage(refreshError); - host.showError(`Authentication successful, but failed to refresh config: ${message}`); - return; - } - host.track('login', { - provider: DEFAULT_OAUTH_PROVIDER_NAME, - already_logged_in: alreadyLoggedIn, - }); - if (alreadyLoggedIn) { - host.showStatus('Already logged in. Model configuration refreshed.'); - } - } catch (error) { - const cancelled = controller.signal.aborted; - spinner?.stop({ - ok: false, - label: cancelled ? 'Login cancelled.' : 'Login failed.', - }); - spinner = undefined; - if (cancelled) return; - log.warn('login failed', { - providerName: DEFAULT_OAUTH_PROVIDER_NAME, - alreadyLoggedIn, - sessionId: host.session?.id, - error, - }); - const message = formatErrorMessage(error); - host.showError(`Login failed: ${message}`); - } finally { - if (host.cancelInFlight === cancelLogin) { - host.cancelInFlight = undefined; - } - } -} - -async function handleOpenPlatformLogin( - host: SlashCommandHost, - platform: OpenPlatformDefinition, -): Promise { - const apiKey = await promptApiKey(host, platform.name); - if (apiKey === undefined) return; - - const controller = new AbortController(); - const cancelLogin = (): void => { - controller.abort(); - }; - host.cancelInFlight = cancelLogin; - - let models: ManagedKimiCodeModelInfo[]; - try { - models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); - models = filterModelsByPrefix(models, platform); - } catch (error) { - if (controller.signal.aborted) return; - const msg = formatErrorMessage(error); - host.showError(`Failed to verify API key: ${msg}`); - if ( - error instanceof OpenPlatformApiError && - error.status === 401 - ) { - host.showStatus( - 'Hint: If your API key was obtained from Kimi Code, please select "Kimi Code" instead.', - ); - } - return; - } finally { - if (host.cancelInFlight === cancelLogin) { - host.cancelInFlight = undefined; - } - } - - if (models.length === 0) { - host.showError('No models available for this platform.'); - return; - } - - const selection = await promptModelSelectionForOpenPlatform(host, models, platform); - if (selection === undefined) return; - - const existingConfig = await host.harness.getConfig(); - if (existingConfig.providers[platform.id] !== undefined) { - await host.harness.removeProvider(platform.id); - } - - const config = await host.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedKimiConfigShape, { - platform, - models, - selectedModel: selection.model, - thinking: selection.thinking, - apiKey, - }); - - await host.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - - await host.authFlow.refreshConfigAfterLogin(); - host.track('login', { provider: platform.id, method: 'api_key' }); - host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); -} - -export async function handleConnectCommand(host: SlashCommandHost, args: string): Promise { - const resolution = resolveConnectCatalogRequest(args); - if (resolution.kind === 'error') { - host.showError(resolution.message); - return; - } - const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request; - - let catalog: Catalog | undefined; - - if (preferBuiltIn) { - const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (builtIn !== undefined) { - host.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.'); - catalog = builtIn; - } - } - - if (catalog === undefined) { - const controller = new AbortController(); - const cancel = (): void => { - controller.abort(); - }; - host.cancelInFlight = cancel; - - const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${url}`); - try { - catalog = await fetchCatalog(url, controller.signal); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); - } catch (error) { - if (controller.signal.aborted) { - spinner.stop({ ok: false, label: 'Aborted.' }); - } else { - const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : ''; - if (!allowBuiltInFallback) { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } else { - const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON); - if (fallback !== undefined) { - spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' }); - catalog = fallback; - } else { - spinner.stop({ ok: false, label: 'Failed to load catalog.' }); - host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`); - } - } - } - } finally { - if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; - } - } - - if (catalog === undefined) return; - - const providerId = await promptCatalogProviderSelection(host, catalog); - if (providerId === undefined) return; - const entry = catalog[providerId]; - if (entry === undefined) return; - - const models = catalogProviderModels(entry); - if (models.length === 0) { - host.showError(`Provider "${providerId}" has no usable models in this catalog.`); - return; - } - - const selection = await promptModelSelectionForCatalog(host, providerId, models); - if (selection === undefined) return; - - const apiKey = await promptApiKey(host, entry.name ?? providerId); - if (apiKey === undefined) return; - - const wire = inferWireType(entry); - if (wire === undefined) return; - const baseUrl = catalogBaseUrl(entry, wire); - - const existingConfig = await host.harness.getConfig(); - if (existingConfig.providers[providerId] !== undefined) { - await host.harness.removeProvider(providerId); - } - - const config = await host.harness.getConfig(); - applyCatalogProvider(config, { - providerId, - wire, - baseUrl, - apiKey, - models, - selectedModelId: selection.model.id, - thinking: selection.thinking, - }); - - await host.harness.setConfig({ - providers: config.providers, - models: config.models, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, - }); - - await host.authFlow.refreshConfigAfterLogin(); - host.track('connect', { provider: providerId, model: selection.model.id }); - host.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`); -} - -export async function handleLogoutCommand(host: SlashCommandHost): Promise { - const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const hasOAuthToken = oauthStatus.providers.some( - (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, - ); - const config = await host.harness.getConfig(); - const hasManagedRemnant = - hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; - const apiKeyProviderIds = Object.keys(config.providers ?? {}) - .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) - .toSorted(); - - const options: ChoiceOption[] = []; - if (hasManagedRemnant) { - options.push({ - value: DEFAULT_OAUTH_PROVIDER_NAME, - label: PRODUCT_NAME, - description: 'OAuth login', - }); - } - for (const id of apiKeyProviderIds) { - const baseUrl = config.providers[id]?.baseUrl; - options.push({ - value: id, - label: id, - description: typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined, - }); - } - - if (options.length === 0) { - host.showStatus('Nothing to logout.'); - return; - } - - const currentModel = host.state.appState.model.trim(); - const currentProvider = host.state.appState.availableModels[currentModel]?.provider; - - const target = await promptLogoutProviderSelection(host, options, currentProvider); - if (target === undefined) return; - - if (target === DEFAULT_OAUTH_PROVIDER_NAME) { - await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); - } else { - await host.harness.removeProvider(target); - } - - if (target === currentProvider) { - await host.authFlow.refreshConfigAfterLogout(); - await host.authFlow.clearActiveSessionAfterLogout(); - } else { - const updated = await host.harness.getConfig({ reload: true }); - host.setAppState({ - availableModels: updated.models ?? {}, - availableProviders: updated.providers ?? {}, - }); - } - - host.track('logout', { provider: target }); - const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; - host.showStatus(`Logged out from ${label}.`); -} - -// --------------------------------------------------------------------------- -// Pickers & config apply -// --------------------------------------------------------------------------- - -function showEditorPicker(host: SlashCommandHost): void { - const currentValue = host.state.appState.editorCommand ?? ''; - host.mountEditorReplacement( - new EditorSelectorComponent({ - currentValue, - colors: host.state.theme.colors, - onSelect: (value) => { - host.restoreEditor(); - void applyEditorChoice(host, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function applyEditorChoice(host: SlashCommandHost, value: string): Promise { - const previous = host.state.appState.editorCommand ?? ''; - if (value === previous && value.length > 0) { - host.showStatus(`Editor unchanged: ${value.length > 0 ? value : 'auto-detect'}`); - return; - } - - const editorCommand = value.length > 0 ? value : null; - try { - await saveTuiConfig({ - theme: host.state.appState.theme, - editorCommand, - notifications: host.state.appState.notifications, - }); - } catch (error) { - host.showStatus( - `Failed to save editor: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, - ); - return; - } - - host.setAppState({ editorCommand }); - host.showStatus( - value.length > 0 - ? `Editor set to "${value}".` - : 'Editor set to auto-detect ($VISUAL / $EDITOR).', - ); -} - -export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { - const entries = Object.entries(host.state.appState.availableModels); - if (entries.length === 0) { - host.showNotice( - 'No models configured', - 'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.', - ); - return; - } - host.mountEditorReplacement( - new ModelSelectorComponent({ - models: host.state.appState.availableModels, - currentValue: host.state.appState.model, - selectedValue, - currentThinking: host.state.appState.thinking, - colors: host.state.theme.colors, - searchable: true, - onSelect: ({ alias, thinking }) => { - host.restoreEditor(); - void performModelSwitch(host, alias, thinking); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function performModelSwitch(host: SlashCommandHost, alias: string, thinking: boolean): Promise { - if (host.state.appState.streamingPhase !== 'idle') { - host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); - return; - } - - const level = thinking ? 'on' : 'off'; - const prevModel = host.state.appState.model; - const prevThinking = host.state.appState.thinking; - const runtimeChanged = alias !== prevModel || thinking !== prevThinking; - - const session = host.session; - try { - if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, thinking); - } else if (session !== undefined) { - if (alias !== prevModel) { - await session.setModel(alias); - } - if (thinking !== prevThinking) { - await session.setThinking(level); - } - } - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to switch model: ${msg}`); - return; - } - - host.setAppState({ model: alias, thinking }); - if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - host.track('model_switch', { model: alias }); - } - if (thinking !== prevThinking) { - host.track('thinking_toggle', { enabled: thinking }); - } - } - - let persisted = false; - try { - persisted = await persistModelSelection(host, alias, thinking); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; - } - - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${level}.` - : persisted - ? `Saved ${alias} with thinking ${level} as default.` - : `Already using ${alias} with thinking ${level}.`; - host.showStatus(status, host.state.theme.colors.success); -} - -async function persistModelSelection(host: SlashCommandHost, alias: string, thinking: boolean): Promise { - const config = await host.harness.getConfig({ reload: true }); - if (config.defaultModel === alias && config.defaultThinking === thinking) { - return false; - } - await host.harness.setConfig({ - defaultModel: alias, - defaultThinking: thinking, - }); - return true; -} - -function showThemePicker(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ThemeSelectorComponent({ - currentValue: host.state.appState.theme, - colors: host.state.theme.colors, - onSelect: (value) => { - host.restoreEditor(); - void applyThemeChoice(host, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function applyThemeChoice(host: SlashCommandHost, theme: Theme): Promise { - if (theme === host.state.appState.theme) { - if (theme === 'auto') host.refreshTerminalThemeTracking(); - host.showStatus(`Theme unchanged: "${theme}".`); - return; - } - - try { - await saveTuiConfig({ - theme, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, - }); - } catch (error) { - host.showStatus( - `Failed to save theme: ${formatErrorMessage(error)}`, - host.state.theme.colors.error, - ); - return; - } - - const resolved = theme === 'auto' ? host.state.theme.resolvedTheme : theme; - host.applyTheme(theme, resolved); - host.refreshTerminalThemeTracking(); - host.track('theme_switch', { theme }); - const detail = theme === 'auto' ? ` (tracking terminal; current: ${resolved})` : ''; - host.showStatus(`Theme set to "${theme}"${detail}.`); -} - -export function showPermissionPicker(host: SlashCommandHost): void { - host.mountEditorReplacement( - new PermissionSelectorComponent({ - currentValue: host.state.appState.permissionMode, - colors: host.state.theme.colors, - onSelect: (value) => { - host.restoreEditor(); - void applyPermissionChoice(host, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise { - if (mode === host.state.appState.permissionMode) { - host.showStatus(`Permission mode unchanged: ${mode}.`); - return; - } - - try { - await host.requireSession().setPermission(mode); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to set permission mode: ${msg}`); - return; - } - - host.setAppState({ permissionMode: mode }); - host.showNotice(`Permission mode: ${mode}`); -} - -export function showSettingsSelector(host: SlashCommandHost): void { - host.mountEditorReplacement( - new SettingsSelectorComponent({ - colors: host.state.theme.colors, - onSelect: (value) => { - handleSettingsSelection(host, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelection): void { - host.restoreEditor(); - switch (value) { - case 'model': showModelPicker(host); return; - case 'permission': showPermissionPicker(host); return; - case 'theme': showThemePicker(host); return; - case 'editor': showEditorPicker(host); return; - case 'usage': void showUsage(host); return; - } -} - -// --------------------------------------------------------------------------- -// Info commands -// --------------------------------------------------------------------------- - -interface SessionUsageResult { - readonly usage?: SessionUsage; - readonly error?: string; -} - -interface RuntimeStatusResult { - readonly status?: SessionStatus; - readonly error?: string; -} - -interface ManagedUsageResult { - readonly usage?: ManagedUsageReport; - readonly error?: string; -} - -export async function showUsage(host: SlashCommandHost): Promise { - const sessionUsage = await loadSessionUsageReport(host); - const managedUsage = await loadManagedUsageReport(host); - const lines = buildUsageReportLines({ - colors: host.state.theme.colors, - sessionUsage: sessionUsage.usage, - sessionUsageError: sessionUsage.error, - contextUsage: host.state.appState.contextUsage, - contextTokens: host.state.appState.contextTokens, - maxContextTokens: host.state.appState.maxContextTokens, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary); - host.state.transcriptContainer.addChild(panel); - host.state.ui.requestRender(); -} - -export async function showStatusReport(host: SlashCommandHost): Promise { - const [runtimeStatus, managedUsage] = await Promise.all([ - loadRuntimeStatusReport(host), - loadManagedUsageReport(host), - ]); - const appState = host.state.appState; - const lines = buildStatusReportLines({ - colors: host.state.theme.colors, - version: appState.version, - model: appState.model, - workDir: appState.workDir, - sessionId: appState.sessionId, - sessionTitle: appState.sessionTitle, - thinking: appState.thinking, - permissionMode: appState.permissionMode, - planMode: appState.planMode, - contextUsage: appState.contextUsage, - contextTokens: appState.contextTokens, - maxContextTokens: appState.maxContextTokens, - availableModels: appState.availableModels, - status: runtimeStatus.status, - statusError: runtimeStatus.error, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, - }); - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ' Status '); - host.state.transcriptContainer.addChild(panel); - host.state.ui.requestRender(); -} - -export async function showMcpServers(host: SlashCommandHost): Promise { - let servers: readonly McpServerInfo[]; - try { - servers = await host.requireSession().listMcpServers(); - } catch (error) { - host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); - return; - } - - const lines = buildMcpStatusReportLines({ - colors: host.state.theme.colors, - servers, - }); - const title = servers.length > 0 ? ` MCP (${servers.length}) ` : ' MCP '; - const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); - host.state.transcriptContainer.addChild(panel); - host.state.ui.requestRender(); -} - -async function loadSessionUsageReport(host: SlashCommandHost): Promise { - try { - return { usage: await host.requireSession().getUsage() }; - } catch (error) { - return { error: formatErrorMessage(error) }; - } -} - -async function loadRuntimeStatusReport(host: SlashCommandHost): Promise { - try { - return { status: await host.requireSession().getStatus() }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } -} - -async function loadManagedUsageReport(host: SlashCommandHost): Promise { - const alias = host.state.appState.model; - const providerKey = host.state.appState.availableModels[alias]?.provider; - if (!isManagedUsageProvider(providerKey)) return undefined; - - let res; - try { - res = await host.harness.auth.getManagedUsage(providerKey); - } catch (error) { - return { error: formatErrorMessage(error) }; - } - if (res.kind === 'error') { - return { error: res.message }; - } - return { usage: { summary: res.summary, limits: res.limits } }; } // --------------------------------------------------------------------------- @@ -1220,4 +259,3 @@ async function handleBuiltInSlashCommand( return; } } - From 78b7990b4c28d496a7ddc5ba28b14c57d426a814 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:26:06 +0800 Subject: [PATCH 21/28] refactor(tui): move command handlers from controllers/ to commands/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the 6 command-related files into the existing commands/ directory where parsing and routing logic already lives: controllers/slash-commands.ts → commands/dispatch.ts controllers/slash-command-prompts.ts → commands/prompts.ts controllers/auth-commands.ts → commands/auth.ts controllers/config-commands.ts → commands/config.ts controllers/info-commands.ts → commands/info.ts controllers/session-commands.ts → commands/session.ts controllers/ now contains only state/lifecycle controllers (auth-flow, session-event-handler, session-replay, streaming-ui, tasks-browser). commands/index.ts re-exports all public symbols. --- .../auth-commands.ts => commands/auth.ts} | 4 +- .../config-commands.ts => commands/config.ts} | 4 +- .../dispatch.ts} | 28 ++++++------- apps/kimi-code/src/tui/commands/index.ts | 39 +++++++++++++++++++ .../info-commands.ts => commands/info.ts} | 4 +- .../prompts.ts} | 2 +- .../session.ts} | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 2 +- .../test/tui/kimi-tui-message-flow.test.ts | 8 ++-- .../test/tui/kimi-tui-startup.test.ts | 8 ++-- 10 files changed, 70 insertions(+), 31 deletions(-) rename apps/kimi-code/src/tui/{controllers/auth-commands.ts => commands/auth.ts} (99%) rename apps/kimi-code/src/tui/{controllers/config-commands.ts => commands/config.ts} (99%) rename apps/kimi-code/src/tui/{controllers/slash-commands.ts => commands/dispatch.ts} (93%) rename apps/kimi-code/src/tui/{controllers/info-commands.ts => commands/info.ts} (98%) rename apps/kimi-code/src/tui/{controllers/slash-command-prompts.ts => commands/prompts.ts} (99%) rename apps/kimi-code/src/tui/{controllers/session-commands.ts => commands/session.ts} (98%) diff --git a/apps/kimi-code/src/tui/controllers/auth-commands.ts b/apps/kimi-code/src/tui/commands/auth.ts similarity index 99% rename from apps/kimi-code/src/tui/controllers/auth-commands.ts rename to apps/kimi-code/src/tui/commands/auth.ts index 5d2e579146..e9e8304ccb 100644 --- a/apps/kimi-code/src/tui/controllers/auth-commands.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -33,8 +33,8 @@ import { promptModelSelectionForCatalog, promptModelSelectionForOpenPlatform, promptPlatformSelection, -} from './slash-command-prompts'; -import type { SlashCommandHost } from './slash-commands'; +} from './prompts'; +import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Auth: login / logout / connect diff --git a/apps/kimi-code/src/tui/controllers/config-commands.ts b/apps/kimi-code/src/tui/commands/config.ts similarity index 99% rename from apps/kimi-code/src/tui/controllers/config-commands.ts rename to apps/kimi-code/src/tui/commands/config.ts index ea25743b2f..dc20cb96d3 100644 --- a/apps/kimi-code/src/tui/controllers/config-commands.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -10,8 +10,8 @@ import type { Theme } from '../theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { isTheme } from '../theme/index'; import { formatErrorMessage } from '../utils/event-payload'; -import { showUsage } from './info-commands'; -import type { SlashCommandHost } from './slash-commands'; +import { showUsage } from './info'; +import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Plan / Config commands diff --git a/apps/kimi-code/src/tui/controllers/slash-commands.ts b/apps/kimi-code/src/tui/commands/dispatch.ts similarity index 93% rename from apps/kimi-code/src/tui/controllers/slash-commands.ts rename to apps/kimi-code/src/tui/commands/dispatch.ts index 4c796346b6..63a01981e8 100644 --- a/apps/kimi-code/src/tui/controllers/slash-commands.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -8,19 +8,19 @@ import { LLM_NOT_SET_MESSAGE, } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { parseSlashInput } from './parse'; import { - parseSlashInput, resolveSlashCommandInput, slashBusyMessage, - type BuiltinSlashCommandName, -} from '../commands'; -import type { AuthFlowController } from './auth-flow'; -import type { StreamingUIController } from './streaming-ui'; -import type { TasksBrowserController } from './tasks-browser'; +} from './resolve'; +import type { BuiltinSlashCommandName } from './registry'; +import type { AuthFlowController } from '../controllers/auth-flow'; +import type { StreamingUIController } from '../controllers/streaming-ui'; +import type { TasksBrowserController } from '../controllers/tasks-browser'; import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; -import { handleConnectCommand, handleLoginCommand, handleLogoutCommand } from './auth-commands'; +import { handleConnectCommand, handleLoginCommand, handleLogoutCommand } from './auth'; import { handleCompactCommand, handleEditorCommand, @@ -31,9 +31,9 @@ import { showModelPicker, showPermissionPicker, showSettingsSelector, -} from './config-commands'; -import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info-commands'; -import { handleForkCommand, handleInitCommand, handleTitleCommand } from './session-commands'; +} from './config'; +import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working @@ -43,7 +43,7 @@ export { handleConnectCommand, handleLoginCommand, handleLogoutCommand, -} from './auth-commands'; +} from './auth'; export { handleCompactCommand, handleEditorCommand, @@ -54,18 +54,18 @@ export { showModelPicker, showPermissionPicker, showSettingsSelector, -} from './config-commands'; +} from './config'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage, -} from './info-commands'; +} from './info'; export { handleForkCommand, handleInitCommand, handleTitleCommand, -} from './session-commands'; +} from './session'; // --------------------------------------------------------------------------- // Host interface diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index fab43f75c2..b4c49d7c95 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,3 +3,42 @@ export * from './registry'; export * from './resolve'; export * from './skills'; export * from './types'; + +export { dispatchInput, type SlashCommandHost } from './dispatch'; +export { + handleConnectCommand, + handleLoginCommand, + handleLogoutCommand, +} from './auth'; +export { + handleCompactCommand, + handleEditorCommand, + handleModelCommand, + handlePlanCommand, + handleThemeCommand, + handleYoloCommand, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config'; +export { + handleFeedbackCommand, + showMcpServers, + showStatusReport, + showUsage, +} from './info'; +export { + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session'; +export { + promptApiKey, + promptCatalogProviderSelection, + promptFeedbackInput, + promptLogoutProviderSelection, + promptModelSelectionForCatalog, + promptModelSelectionForOpenPlatform, + promptPlatformSelection, + runModelSelector, +} from './prompts'; diff --git a/apps/kimi-code/src/tui/controllers/info-commands.ts b/apps/kimi-code/src/tui/commands/info.ts similarity index 98% rename from apps/kimi-code/src/tui/controllers/info-commands.ts rename to apps/kimi-code/src/tui/commands/info.ts index dc2d80da59..a5dd479598 100644 --- a/apps/kimi-code/src/tui/controllers/info-commands.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -19,8 +19,8 @@ import { import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '../utils/open-url'; -import { promptFeedbackInput } from './slash-command-prompts'; -import type { SlashCommandHost } from './slash-commands'; +import { promptFeedbackInput } from './prompts'; +import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Feedback diff --git a/apps/kimi-code/src/tui/controllers/slash-command-prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts similarity index 99% rename from apps/kimi-code/src/tui/controllers/slash-command-prompts.ts rename to apps/kimi-code/src/tui/commands/prompts.ts index 563f90ba1a..dbc86a25a6 100644 --- a/apps/kimi-code/src/tui/controllers/slash-command-prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -16,7 +16,7 @@ import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/ import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; import { ModelSelectorComponent } from '../components/dialogs/model-selector'; import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; -import type { SlashCommandHost } from './slash-commands'; +import type { SlashCommandHost } from './dispatch'; export function promptPlatformSelection(host: SlashCommandHost): Promise { return new Promise((resolve) => { diff --git a/apps/kimi-code/src/tui/controllers/session-commands.ts b/apps/kimi-code/src/tui/commands/session.ts similarity index 98% rename from apps/kimi-code/src/tui/controllers/session-commands.ts rename to apps/kimi-code/src/tui/commands/session.ts index 1ec8632a3b..b83441c557 100644 --- a/apps/kimi-code/src/tui/controllers/session-commands.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -3,7 +3,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { isAbortError } from '../utils/errors'; import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './slash-commands'; +import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Session commands diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0dfe679a36..6dd0657352 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -64,7 +64,7 @@ import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; import { SessionEventHandler } from './controllers/session-event-handler'; -import * as slashCommands from './controllers/slash-commands'; +import * as slashCommands from './commands/dispatch'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; 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 25a5d29ec0..0da4c33edb 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 @@ -14,15 +14,15 @@ import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel' import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; -import { handleFeedbackCommand } from '#/tui/controllers/slash-commands'; +import { handleFeedbackCommand } from '#/tui/commands/info'; import { promptFeedbackInput, runModelSelector, -} from '#/tui/controllers/slash-command-prompts'; +} from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; -vi.mock('#/tui/controllers/slash-command-prompts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, promptFeedbackInput: vi.fn() }; }); import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 41c5566556..682c6fcd90 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -7,14 +7,14 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui import { handleLoginCommand, handleLogoutCommand, -} from "#/tui/controllers/slash-commands"; +} from "#/tui/commands/auth"; import { promptPlatformSelection, promptLogoutProviderSelection, -} from "#/tui/controllers/slash-command-prompts"; +} from "#/tui/commands/prompts"; -vi.mock("#/tui/controllers/slash-command-prompts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("#/tui/commands/prompts", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); import { From 19478a2dbc4c3a126978fee54aa4313eb149ad2a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:37:31 +0800 Subject: [PATCH 22/28] refactor(tui): move background/render-dedup state into SessionEventHandler Move 7 fields from TUIState into SessionEventHandler as instance properties: backgroundAgentMetadata, backgroundTasks, backgroundTaskTranscriptedTerminal, subagentInfo, renderedSkillActivationIds, renderedMcpServerStatusKeys, mcpServerStatusSpinners. Add resetRuntimeState() to SessionEventHandler that clears all 7 fields in one call. TUIState shrinks from 28 to 21 fields. TasksBrowserHost.backgroundTasks is now a top-level ReadonlyMap property instead of being embedded in the state subset, with a getter on KimiTUI that delegates to sessionEventHandler. --- .../tui/controllers/session-event-handler.ts | 91 +++++++++++-------- .../src/tui/controllers/session-replay.ts | 28 +++--- .../src/tui/controllers/tasks-browser.ts | 8 +- apps/kimi-code/src/tui/kimi-tui.ts | 14 +-- apps/kimi-code/src/tui/tui-state.ts | 16 ---- .../test/tui/create-tui-state.test.ts | 2 - .../kimi-code/test/tui/message-replay.test.ts | 10 +- 7 files changed, 86 insertions(+), 83 deletions(-) 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 0003c5cfcf..6979aced6c 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -94,6 +94,25 @@ export interface SessionEventHost { export class SessionEventHandler { constructor(private readonly host: SessionEventHost) {} + // Runtime state – owned by this handler, reset between sessions. + backgroundAgentMetadata: Map = new Map(); + backgroundTasks: Map = new Map(); + backgroundTaskTranscriptedTerminal: Set = new Set(); + subagentInfo: Map = new Map(); + renderedSkillActivationIds: Set = new Set(); + renderedMcpServerStatusKeys: Map = new Map(); + mcpServerStatusSpinners: Map = new Map(); + + resetRuntimeState(): void { + this.backgroundAgentMetadata.clear(); + this.backgroundTasks.clear(); + this.backgroundTaskTranscriptedTerminal.clear(); + this.subagentInfo.clear(); + this.renderedSkillActivationIds.clear(); + this.renderedMcpServerStatusKeys.clear(); + this.stopAllMcpServerStatusSpinners(); + } + startSubscription(): void { const { host } = this; const session = host.requireSession(); @@ -130,15 +149,15 @@ export class SessionEventHandler { const visible = selectMcpStartupStatusRows(servers); const visibleNames = new Set(visible.map((server) => server.name)); for (const server of visible) { - if (host.state.renderedMcpServerStatusKeys.has(server.name)) continue; + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; this.renderMcpServerStatus(server); } const hidden: McpServerStatusSnapshot[] = []; for (const server of servers) { if (visibleNames.has(server.name)) continue; - if (host.state.renderedMcpServerStatusKeys.has(server.name)) continue; - host.state.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); hidden.push(server); } if (hidden.length > 0) { @@ -193,10 +212,10 @@ export class SessionEventHandler { } stopAllMcpServerStatusSpinners(): void { - for (const spinner of this.host.state.mcpServerStatusSpinners.values()) { + for (const spinner of this.mcpServerStatusSpinners.values()) { spinner.stop(); } - this.host.state.mcpServerStatusSpinners.clear(); + this.mcpServerStatusSpinners.clear(); } // --------------------------------------------------------------------------- @@ -207,8 +226,8 @@ export class SessionEventHandler { const subagentId = event.agentId; if (subagentId === MAIN_AGENT_ID) return false; - const { state, streamingUI } = this.host; - const info = state.subagentInfo.get(subagentId); + const { streamingUI } = this.host; + const info = this.subagentInfo.get(subagentId); if (info === undefined || info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; @@ -574,8 +593,8 @@ export class SessionEventHandler { private renderMcpServerStatus(server: McpServerStatusSnapshot): void { const { state } = this.host; const key = mcpServerStatusKey(server); - if (state.renderedMcpServerStatusKeys.get(server.name) === key) return; - state.renderedMcpServerStatusKeys.set(server.name, key); + if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; + this.renderedMcpServerStatusKeys.set(server.name, key); const colors = state.theme.colors; switch (server.status) { @@ -611,7 +630,7 @@ export class SessionEventHandler { private showMcpServerStatusSpinner(name: string): void { const { state } = this.host; const label = `MCP server "${name}" connecting…`; - const existing = state.mcpServerStatusSpinners.get(name); + const existing = this.mcpServerStatusSpinners.get(name); if (existing !== undefined) { existing.setLabel(label); return; @@ -619,13 +638,13 @@ export class SessionEventHandler { const tint = (s: string): string => chalk.hex(state.theme.colors.textMuted)(s); const spinner = new MoonLoader(state.ui, 'braille', tint, label); state.transcriptContainer.addChild(spinner); - state.mcpServerStatusSpinners.set(name, spinner); + this.mcpServerStatusSpinners.set(name, spinner); state.ui.requestRender(); } private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { const { state } = this.host; - const spinner = state.mcpServerStatusSpinners.get(name); + const spinner = this.mcpServerStatusSpinners.get(name); if (spinner === undefined) { this.host.showStatus(message, color); return; @@ -640,14 +659,13 @@ export class SessionEventHandler { } else { state.transcriptContainer.addChild(status); } - state.mcpServerStatusSpinners.delete(name); + this.mcpServerStatusSpinners.delete(name); state.ui.requestRender(); } private handleSkillActivated(event: SkillActivatedEvent): void { - const { state } = this.host; - if (state.renderedSkillActivationIds.has(event.activationId)) return; - state.renderedSkillActivationIds.add(event.activationId); + if (this.renderedSkillActivationIds.has(event.activationId)) return; + this.renderedSkillActivationIds.add(event.activationId); this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'skill_activation', @@ -710,15 +728,15 @@ export class SessionEventHandler { } private handleSubagentSpawned(event: SubagentSpawnedEvent): void { - const { state, streamingUI } = this.host; - state.subagentInfo.set(event.subagentId, { + const { streamingUI } = this.host; + this.subagentInfo.set(event.subagentId, { parentToolCallId: event.parentToolCallId, name: event.subagentName, }); if (event.runInBackground) { const meta = this.buildBackgroundAgentMetadata(event); - state.backgroundAgentMetadata.set(event.subagentId, meta); + this.backgroundAgentMetadata.set(event.subagentId, meta); this.appendBackgroundAgentEntry('started', meta); this.syncBackgroundAgentBadge(); return; @@ -742,17 +760,17 @@ export class SessionEventHandler { } private handleSubagentCompleted(event: SubagentCompletedEvent): void { - const { state, streamingUI } = this.host; - const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); + const { streamingUI } = this.host; + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { - state.backgroundAgentMetadata.delete(event.subagentId); + this.backgroundAgentMetadata.delete(event.subagentId); this.syncBackgroundAgentBadge(); const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { + if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { return; } if (taskId !== undefined) { - state.backgroundTaskTranscriptedTerminal.add(taskId); + this.backgroundTaskTranscriptedTerminal.add(taskId); } const extras = event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; @@ -772,17 +790,17 @@ export class SessionEventHandler { } private handleSubagentFailed(event: SubagentFailedEvent): void { - const { state, streamingUI } = this.host; - const backgroundMeta = state.backgroundAgentMetadata.get(event.subagentId); + const { streamingUI } = this.host; + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { - state.backgroundAgentMetadata.delete(event.subagentId); + this.backgroundAgentMetadata.delete(event.subagentId); this.syncBackgroundAgentBadge(); const taskId = this.findAgentTaskId(event.subagentId); - if (taskId !== undefined && state.backgroundTaskTranscriptedTerminal.has(taskId)) { + if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { return; } if (taskId !== undefined) { - state.backgroundTaskTranscriptedTerminal.add(taskId); + this.backgroundTaskTranscriptedTerminal.add(taskId); } this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; @@ -814,12 +832,11 @@ export class SessionEventHandler { } private findAgentTaskId(subagentId: string): string | undefined { - const { state } = this.host; - const meta = state.backgroundAgentMetadata.get(subagentId); + const meta = this.backgroundAgentMetadata.get(subagentId); const description = meta?.description ?? meta?.agentName; if (description === undefined) return undefined; let match: string | undefined; - for (const info of state.backgroundTasks.values()) { + for (const info of this.backgroundTasks.values()) { if (!info.taskId.startsWith('agent-')) continue; if (info.description !== description) continue; if (match !== undefined) return undefined; @@ -870,8 +887,8 @@ export class SessionEventHandler { ): void { const { state } = this.host; const { info } = event; - const previous = state.backgroundTasks.get(info.taskId); - state.backgroundTasks.set(info.taskId, info); + const previous = this.backgroundTasks.get(info.taskId); + this.backgroundTasks.set(info.taskId, info); const viewer = state.tasksBrowser?.viewer; if (viewer !== undefined && viewer.taskId === info.taskId) { @@ -897,11 +914,11 @@ export class SessionEventHandler { } if (event.type === 'background.task.terminated' && isTerminal) { - if (!state.backgroundTaskTranscriptedTerminal.has(info.taskId)) { + if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { if (info.taskId.startsWith('bash-')) { this.appendBackgroundTaskEntry(info); } - state.backgroundTaskTranscriptedTerminal.add(info.taskId); + this.backgroundTaskTranscriptedTerminal.add(info.taskId); } this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); @@ -932,7 +949,7 @@ export class SessionEventHandler { const { state } = this.host; let bashTasks = 0; let agentTasks = 0; - for (const info of state.backgroundTasks.values()) { + for (const info of this.backgroundTasks.values()) { if ( info.status === 'completed' || info.status === 'failed' || diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a4add19076..d5896f1d61 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -40,11 +40,13 @@ import { type SkillActivationProjection, } from '../utils/message-replay'; import type { StreamingUIController } from './streaming-ui'; +import type { SessionEventHandler } from './session-event-handler'; import type { TUIState } from '../tui-state'; export interface SessionReplayHost { state: TUIState; readonly streamingUI: StreamingUIController; + readonly sessionEventHandler: SessionEventHandler; setAppState(patch: Partial): void; showError(msg: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; @@ -103,19 +105,19 @@ export class SessionReplayRenderer { } private hydrateBackgroundState(agent: ResumedAgentState): void { - const { state } = this.host; + const { state, sessionEventHandler } = this.host; const projection = replayBackgroundProjection(agent.background); - state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); - state.backgroundTasks = new Map( + sessionEventHandler.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); + sessionEventHandler.backgroundTasks = new Map( agent.background.map((info) => [info.taskId, info]), ); - state.backgroundTaskTranscriptedTerminal.clear(); + sessionEventHandler.backgroundTaskTranscriptedTerminal.clear(); for (const info of agent.background) { if (isTerminalBackgroundTask(info)) { - state.backgroundTaskTranscriptedTerminal.add(info.taskId); + sessionEventHandler.backgroundTaskTranscriptedTerminal.add(info.taskId); } } - state.footer.setBackgroundCounts(countActiveBackgroundTasks(state.backgroundTasks)); + state.footer.setBackgroundCounts(countActiveBackgroundTasks(sessionEventHandler.backgroundTasks)); state.ui.requestRender(); } @@ -305,11 +307,11 @@ export class SessionReplayRenderer { context: ReplayRenderContext, skill: SkillActivationProjection, ): void { - const { state } = this.host; + const { sessionEventHandler } = this.host; if (context.skillActivationIds.has(skill.activationId)) return; - if (state.renderedSkillActivationIds.has(skill.activationId)) return; + if (sessionEventHandler.renderedSkillActivationIds.has(skill.activationId)) return; context.skillActivationIds.add(skill.activationId); - state.renderedSkillActivationIds.add(skill.activationId); + sessionEventHandler.renderedSkillActivationIds.add(skill.activationId); this.host.appendTranscriptEntry({ ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), skillActivationId: skill.activationId, @@ -433,8 +435,8 @@ export class SessionReplayRenderer { context: ReplayRenderContext, origin: Extract, ): void { - const { state } = this.host; - const task = state.backgroundTasks.get(origin.taskId); + const { sessionEventHandler } = this.host; + const task = sessionEventHandler.backgroundTasks.get(origin.taskId); if (task !== undefined && task.taskId.startsWith('bash-')) { const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); this.host.appendTranscriptEntry({ @@ -442,7 +444,7 @@ export class SessionReplayRenderer { detail: status.detail, backgroundAgentStatus: status, }); - state.backgroundTaskTranscriptedTerminal.add(origin.taskId); + sessionEventHandler.backgroundTaskTranscriptedTerminal.add(origin.taskId); return; } @@ -471,6 +473,6 @@ export class SessionReplayRenderer { detail: status.detail, backgroundAgentStatus: status, }); - state.backgroundAgentMetadata.delete(meta.agentId); + sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId); } } diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index af2d217e90..03eaf58038 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -9,12 +9,12 @@ import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { tasksBrowser: TasksBrowserState | undefined; - readonly backgroundTasks: Map; readonly theme: { readonly colors: ColorPalette }; readonly terminal: ProcessTerminal; readonly ui: TUI; readonly editor: CustomEditor; }; + readonly backgroundTasks: ReadonlyMap; readonly session: Session | undefined; showError(msg: string): void; } @@ -131,7 +131,7 @@ export class TasksBrowserController { repaint(): void { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; - const tasks = [...this.host.state.backgroundTasks.values()]; + const tasks = [...this.host.backgroundTasks.values()]; this.pushProps(tasks); } @@ -161,7 +161,7 @@ export class TasksBrowserController { } if (output === viewer.output) return; viewer.output = output; - const info = state.backgroundTasks.get(viewer.taskId); + const info = this.host.backgroundTasks.get(viewer.taskId); viewer.component.setProps({ taskId: viewer.taskId, info, @@ -339,7 +339,7 @@ export class TasksBrowserController { const current = state.tasksBrowser; if (current === undefined || current !== browser) return; - const info = state.backgroundTasks.get(taskId); + const info = this.host.backgroundTasks.get(taskId); const viewer = new TaskOutputViewer( { taskId, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 6dd0657352..5ff0c598a3 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -23,6 +23,7 @@ import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, ApprovalResponse, + BackgroundTaskInfo, CreateSessionOptions, KimiHarness, PermissionMode, @@ -671,6 +672,11 @@ export class KimiTUI { this.startupNotice = combineStartupNotice(this.startupNotice, extra); } + // Exposes background tasks owned by the event handler for host interfaces. + get backgroundTasks(): ReadonlyMap { + return this.sessionEventHandler.backgroundTasks; + } + // Returns the currently selected session id shown by the UI. getCurrentSessionId(): string { return this.state.appState.sessionId; @@ -1344,14 +1350,8 @@ export class KimiTUI { this.harness.interactiveAgentId = MAIN_AGENT_ID; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); - this.state.backgroundAgentMetadata.clear(); - this.state.backgroundTasks.clear(); - this.state.backgroundTaskTranscriptedTerminal.clear(); + this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); - this.state.subagentInfo.clear(); - this.state.renderedSkillActivationIds.clear(); - this.state.renderedMcpServerStatusKeys.clear(); - this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.currentTurnId = undefined; diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index ec9963c090..1fb1f44450 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -3,7 +3,6 @@ import { ProcessTerminal, TUI, } from '@earendil-works/pi-tui'; -import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; @@ -18,7 +17,6 @@ import { createTerminalState, type TerminalState } from './utils/terminal-state' import { INITIAL_LIVE_PANE, type AppState, - type BackgroundAgentMetadata, type KimiTUIOptions, type LivePaneState, type QueuedMessage, @@ -46,13 +44,6 @@ export interface TUIState { activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; toolOutputExpanded: boolean; planExpanded: boolean; - backgroundAgentMetadata: Map; - backgroundTasks: Map; - backgroundTaskTranscriptedTerminal: Set; - renderedSkillActivationIds: Set; - renderedMcpServerStatusKeys: Map; - mcpServerStatusSpinners: Map; - subagentInfo: Map; sessions: SessionRow[]; loadingSessions: boolean; activeDialog: 'session-picker' | 'help' | null; @@ -99,13 +90,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState { activitySpinner: null, toolOutputExpanded: false, planExpanded: false, - backgroundAgentMetadata: new Map(), - backgroundTasks: new Map(), - backgroundTaskTranscriptedTerminal: new Set(), - renderedSkillActivationIds: new Set(), - renderedMcpServerStatusKeys: new Map(), - mcpServerStatusSpinners: new Map(), - subagentInfo: new Map(), sessions: [], loadingSessions: false, activeDialog: null, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 6042f3afbd..76fa03f882 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -69,8 +69,6 @@ describe('createTUIState', () => { // Empty collections. expect(state.transcriptEntries).toHaveLength(0); expect(state.queuedMessages).toHaveLength(0); - expect(state.backgroundAgentMetadata.size).toBe(0); - expect(state.renderedSkillActivationIds.size).toBe(0); // Boolean, counter, and optional-field defaults. expect(state.toolOutputExpanded).toBe(false); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 23aa8f2122..5a9105b877 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -11,6 +11,7 @@ import type { import { describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; @@ -20,6 +21,7 @@ vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); interface ReplayDriver { readonly state: TUIState; readonly streamingUI: StreamingUIController; + readonly sessionEventHandler: SessionEventHandler; init(): Promise; switchToSession(session: Session, statusMessage: string): Promise; } @@ -296,9 +298,9 @@ describe('KimiTUI resume message replay', () => { { title: 'Review resume snapshot', status: 'done' }, { title: 'Render replay transcript', status: 'in_progress' }, ]); - expect(driver.state.backgroundTasks.has('agent-bg1')).toBe(true); - expect(driver.state.backgroundTasks.has('bash-bg1')).toBe(true); - expect(driver.state.backgroundTaskTranscriptedTerminal.has('bash-bg1')).toBe(true); + expect(driver.sessionEventHandler.backgroundTasks.has('agent-bg1')).toBe(true); + expect(driver.sessionEventHandler.backgroundTasks.has('bash-bg1')).toBe(true); + expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('bash-bg1')).toBe(true); }); it('renders replayed bash background notifications as bash tasks', async () => { @@ -390,7 +392,7 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('review'); expect(transcript).toContain('src/app.ts'); expect(transcript).not.toContain('Review the requested file'); - expect(driver.state.renderedSkillActivationIds.has('act-review')).toBe(true); + expect(driver.sessionEventHandler.renderedSkillActivationIds.has('act-review')).toBe(true); }); it('renders replayed hook results as assistant transcript entries', async () => { From cba8e6c13959673b8fe0b3cc3e1f12707057f7c6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 19:57:13 +0800 Subject: [PATCH 23/28] refactor(tui): encapsulate StreamingUIController internal state behind semantic methods Make 12 fields private and expose 18 semantic methods instead of letting SessionEventHandler, SessionReplayRenderer, and KimiTUI directly manipulate internal maps and counters. Key methods: registerToolCall(), accumulateToolCallDelta(), completeToolResult(), markStepTruncated(), appendThinkingDelta(), appendAssistantDelta(), cleanupAfterReplay(). --- .../tui/controllers/session-event-handler.ts | 133 +++---- .../src/tui/controllers/session-replay.ts | 27 +- .../src/tui/controllers/streaming-ui.ts | 362 +++++++++++++----- apps/kimi-code/src/tui/kimi-tui.ts | 8 +- .../test/tui/kimi-tui-message-flow.test.ts | 16 +- .../kimi-code/test/tui/message-replay.test.ts | 12 +- 6 files changed, 338 insertions(+), 220 deletions(-) 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 6979aced6c..6080cff976 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -39,7 +39,6 @@ import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; import { - appendStreamingArgsPreview, argsRecord, isTodoItemShape, serializeToolResultOutput, @@ -172,7 +171,7 @@ export class SessionEventHandler { if (this.routeSubagentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { - this.host.streamingUI.currentTurnId = String(event.turnId); + this.host.streamingUI.setTurnId(String(event.turnId)); } switch (event.type) { @@ -231,7 +230,7 @@ export class SessionEventHandler { if (info === undefined || info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; - const toolCall = streamingUI.pendingToolComponents.get(parentToolCallId); + const toolCall = streamingUI.getToolComponent(parentToolCallId); if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); @@ -306,7 +305,7 @@ export class SessionEventHandler { private handleTurnBegin(_event: TurnStartedEvent): void { void _event; this.host.streamingUI.resetToolUi(); - this.host.streamingUI.currentStep = 0; + this.host.streamingUI.setStep(0); this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, @@ -331,7 +330,7 @@ export class SessionEventHandler { private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); - this.host.streamingUI.currentStep = event.step; + this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.patchLivePane({ @@ -349,22 +348,10 @@ export class SessionEventHandler { this.host.streamingUI.flushNow(); if (event.finishReason !== 'max_tokens') return; - const streamingUI = this.host.streamingUI; - const eventTurnId = String(event.turnId); - let truncatedCount = 0; - for (const toolCall of streamingUI.activeToolCalls.values()) { - if (toolCall.result !== undefined) continue; - if (toolCall.streamingArguments === undefined) continue; - if (toolCall.turnId !== eventTurnId) continue; - if (toolCall.step !== event.step) continue; - toolCall.truncated = true; - const component = streamingUI.pendingToolComponents.get(toolCall.id); - if (component !== undefined) { - component.updateToolCall(toolCall); - } - truncatedCount += 1; - } - streamingUI.streamingToolCallArguments.clear(); + const truncatedCount = this.host.streamingUI.markStepTruncated( + String(event.turnId), + event.step, + ); const title = truncatedCount > 0 @@ -402,8 +389,7 @@ export class SessionEventHandler { private handleThinkingDelta(event: ThinkingDeltaEvent): void { const { state, streamingUI } = this.host; - streamingUI.thinkingDraft += event.delta; - streamingUI.markThinkingDirty(); + streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); @@ -413,16 +399,11 @@ export class SessionEventHandler { private handleAssistantDelta(event: AssistantDeltaEvent): void { const { state, streamingUI } = this.host; - if (streamingUI.thinkingDraft.length > 0) { + if (streamingUI.hasThinkingDraft()) { streamingUI.flushThinkingToTranscript('idle'); } - if (streamingUI.streamingBlock === null) { - streamingUI.onStreamingTextStart(); - } - - streamingUI.assistantDraft += event.delta; - streamingUI.markAssistantDirty(); + streamingUI.appendAssistantDelta(event.delta); this.host.patchLivePane({ mode: 'idle', @@ -437,7 +418,7 @@ export class SessionEventHandler { private handleHookResult(event: HookResultEvent): void { this.host.streamingUI.flushNow(); - if (this.host.streamingUI.thinkingDraft.length > 0) { + if (this.host.streamingUI.hasThinkingDraft()) { this.host.streamingUI.flushThinkingToTranscript('idle'); } this.host.streamingUI.finalizeAssistantStream(); @@ -458,28 +439,17 @@ export class SessionEventHandler { private handleToolCall(event: ToolCallStartedEvent): void { const { streamingUI } = this.host; streamingUI.flushNow(); + const { turnId, step } = streamingUI.getTurnContext(); const toolCall: ToolCallBlockData = { id: event.toolCallId, name: event.name, args: argsRecord(event.args), description: event.description, display: event.display, - step: streamingUI.currentStep, - turnId: streamingUI.currentTurnId, + step, + turnId, }; - const existing = streamingUI.activeToolCalls.get(event.toolCallId); - streamingUI.activeToolCalls.set(event.toolCallId, toolCall); - streamingUI.pendingToolCallFlushIds.delete(event.toolCallId); - streamingUI.streamingToolCallArguments.delete(event.toolCallId); - const existingComponent = streamingUI.pendingToolComponents.get(event.toolCallId); - if (existingComponent !== undefined) { - existingComponent.updateToolCall(toolCall); - } else if (existing === undefined) { - streamingUI.finalizeLiveTextBuffers('tool'); - if (event.name !== 'Agent') { - streamingUI.onToolCallStart(toolCall); - } - } + streamingUI.registerToolCall(toolCall); this.host.patchLivePane({ mode: 'tool', pendingApproval: null, @@ -490,16 +460,7 @@ export class SessionEventHandler { private handleToolCallDelta(event: ToolCallDeltaEvent): void { if (event.toolCallId.length === 0) return; const { state, streamingUI } = this.host; - const id = event.toolCallId; - const existing = streamingUI.streamingToolCallArguments.get(id); - const argumentsText = appendStreamingArgsPreview( - existing?.argumentsText, - event.argumentsPart, - ); - const name = event.name ?? existing?.name ?? streamingUI.activeToolCalls.get(id)?.name ?? 'Tool'; - const startedAtMs = existing?.startedAtMs ?? Date.now(); - streamingUI.streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); - streamingUI.pendingToolCallFlushIds.add(id); + streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); this.host.patchLivePane({ mode: 'tool', @@ -516,7 +477,7 @@ export class SessionEventHandler { if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; - const tc = this.host.streamingUI.pendingToolComponents.get(event.toolCallId); + const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined) return; tc.appendProgress(text); } @@ -524,29 +485,24 @@ export class SessionEventHandler { private handleToolResult(event: ToolResultEvent): void { const { streamingUI } = this.host; streamingUI.flushNow(); - const matchedCall = streamingUI.activeToolCalls.get(event.toolCallId); const resultData: ToolResultBlockData = { tool_call_id: event.toolCallId, output: serializeToolResultOutput(event.output), is_error: event.isError, synthetic: event.synthetic, }; - if (matchedCall !== undefined) { - streamingUI.onToolCallEnd(event.toolCallId, resultData); - if (matchedCall.name === 'TodoList' && !event.isError) { - const rawTodos = (matchedCall.args as { todos?: unknown }).todos; - if (Array.isArray(rawTodos)) { - const sanitized = rawTodos - .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => - isTodoItemShape(todo), - ) - .map((t) => ({ title: t.title, status: t.status })); - streamingUI.setTodoList(sanitized); - } + const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { + const rawTodos = (matchedCall.args as { todos?: unknown }).todos; + if (Array.isArray(rawTodos)) { + const sanitized = rawTodos + .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => + isTodoItemShape(todo), + ) + .map((t) => ({ title: t.title, status: t.status })); + streamingUI.setTodoList(sanitized); } } - streamingUI.activeToolCalls.delete(event.toolCallId); - streamingUI.streamingToolCallArguments.delete(event.toolCallId); this.host.patchLivePane({ mode: 'waiting' }); } @@ -706,7 +662,7 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const { state } = this.host; - const hasActiveTurn = this.host.streamingUI.currentTurnId !== undefined; + const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { this.host.setAppState({ isCompacting: false, @@ -742,12 +698,12 @@ export class SessionEventHandler { return; } - let tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); + let tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) { - const toolCall = streamingUI.activeToolCalls.get(event.parentToolCallId); + const toolCall = streamingUI.getActiveToolCall(event.parentToolCallId); if (toolCall !== undefined) { streamingUI.onToolCallStart(toolCall); - tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); + tc = streamingUI.getToolComponent(event.parentToolCallId); } } tc ??= this.createStandaloneSubagentToolCall(event); @@ -777,16 +733,14 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } - const tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); + const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentCompleted({ contextTokens: event.contextTokens, usage: event.usage, resultSummary: event.resultSummary, }); - if (!streamingUI.activeToolCalls.has(event.parentToolCallId)) { - streamingUI.pendingToolComponents.delete(event.parentToolCallId); - } + streamingUI.removeToolComponentIfInactive(event.parentToolCallId); } private handleSubagentFailed(event: SubagentFailedEvent): void { @@ -805,17 +759,16 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } - const tc = streamingUI.pendingToolComponents.get(event.parentToolCallId); + const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentFailed({ error: event.error }); - if (!streamingUI.activeToolCalls.has(event.parentToolCallId)) { - streamingUI.pendingToolComponents.delete(event.parentToolCallId); - } + streamingUI.removeToolComponentIfInactive(event.parentToolCallId); } private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent) { const { streamingUI } = this.host; const description = event.description ?? `Run ${event.subagentName} agent`; + const { turnId, step } = streamingUI.getTurnContext(); const toolCall: ToolCallBlockData = { id: event.parentToolCallId, name: 'Agent', @@ -824,11 +777,11 @@ export class SessionEventHandler { subagent_type: event.subagentName, }, description, - step: streamingUI.currentStep, - turnId: streamingUI.currentTurnId, + step, + turnId, }; streamingUI.onToolCallStart(toolCall); - return streamingUI.pendingToolComponents.get(event.parentToolCallId); + return streamingUI.getToolComponent(event.parentToolCallId); } private findAgentTaskId(subagentId: string): string | undefined { @@ -846,7 +799,7 @@ export class SessionEventHandler { } private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { - const parent = this.host.streamingUI.activeToolCalls.get(event.parentToolCallId); + const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); const description = parent?.args['description'] ?? event.description; return { agentId: event.subagentId, @@ -865,7 +818,7 @@ export class SessionEventHandler { const entry: TranscriptEntry = { id: nextTranscriptId(), kind: 'status', - turnId: this.host.streamingUI.currentTurnId, + turnId: this.host.streamingUI.getTurnContext().turnId, renderMode: 'plain', content: status.headline, detail: status.detail, @@ -936,7 +889,7 @@ export class SessionEventHandler { const entry: TranscriptEntry = { id: nextTranscriptId(), kind: 'status', - turnId: this.host.streamingUI.currentTurnId, + turnId: this.host.streamingUI.getTurnContext().turnId, renderMode: 'plain', content: status.headline, detail: status.detail, diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index d5896f1d61..106b384bf0 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -229,7 +229,7 @@ export class SessionReplayRenderer { const toolCall = toolCallFromReplayMessage(rawToolCall, context); if (toolCall === undefined) continue; context.toolCalls.set(toolCall.id, toolCall); - streamingUI.activeToolCalls.set(toolCall.id, toolCall); + streamingUI.setActiveToolCall(toolCall.id, toolCall); streamingUI.onToolCallStart(toolCall); } } @@ -248,7 +248,7 @@ export class SessionReplayRenderer { call.result = result; this.applyStepContext(context); this.host.streamingUI.onToolCallEnd(toolCallId, result); - this.host.streamingUI.activeToolCalls.delete(toolCallId); + this.host.streamingUI.removeActiveToolCall(toolCallId); context.completedToolCallIds.add(toolCallId); } @@ -260,8 +260,8 @@ export class SessionReplayRenderer { } private applyStepContext(context: ReplayRenderContext): void { - this.host.streamingUI.currentTurnId = context.currentTurnId; - this.host.streamingUI.currentStep = context.stepIndex; + this.host.streamingUI.setTurnId(context.currentTurnId); + this.host.streamingUI.setStep(context.stepIndex); } private flushAssistant(context: ReplayRenderContext): void { @@ -279,24 +279,13 @@ export class SessionReplayRenderer { streamingUI.onStreamingTextStart(); streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); - streamingUI.assistantDraft = ''; + streamingUI.clearAssistantDraft(); } } private cleanupRuntime(context: ReplayRenderContext): void { - const { state, streamingUI } = this.host; this.flushAssistant(context); - streamingUI.activeToolCalls.clear(); - for (const toolCallId of context.completedToolCallIds) { - streamingUI.pendingToolComponents.delete(toolCallId); - } - streamingUI.pendingAgentGroup = null; - streamingUI.pendingReadGroup = null; - streamingUI.currentTurnId = undefined; - streamingUI.currentStep = 0; - streamingUI.streamingToolCallArguments.clear(); - streamingUI.pendingToolCallFlushIds.clear(); - state.ui.requestRender(); + this.host.streamingUI.cleanupAfterReplay(context.completedToolCallIds); } // --------------------------------------------------------------------------- @@ -415,8 +404,8 @@ export class SessionReplayRenderer { private removeToolCall(toolCallId: string): void { const { state, streamingUI } = this.host; - streamingUI.activeToolCalls.delete(toolCallId); - streamingUI.pendingToolComponents.delete(toolCallId); + streamingUI.removeActiveToolCall(toolCallId); + streamingUI.removeToolComponent(toolCallId); const index = state.transcriptEntries.findIndex( (entry) => entry.toolCallData?.id === toolCallId, ); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 42fff2ed9a..c3dfffad9d 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -8,7 +8,7 @@ import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; import { hasDispose } from '../utils/component-capabilities'; -import { parseStreamingArgs } from '../utils/event-payload'; +import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload'; import { notifyTerminalOnce } from '../utils/terminal-notification'; import { nextTranscriptId } from '../utils/transcript-id'; import type { TodoItem } from '../components/chrome/todo-panel'; @@ -42,29 +42,29 @@ export class StreamingUIController { readonly pendingToolCallFlushIds = new Set(); // --------------------------------------------------------------------------- - // Streaming runtime state (moved from TUIState) + // Streaming runtime state (private — accessed via semantic methods below) // --------------------------------------------------------------------------- - currentTurnId: string | undefined = undefined; - currentStep = 0; - assistantDraft = ''; - thinkingDraft = ''; - streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; - activeThinkingComponent: ThinkingComponent | undefined = undefined; - activeCompactionBlock: CompactionComponent | undefined = undefined; - activeToolCalls = new Map(); - streamingToolCallArguments = new Map< + private _currentTurnId: string | undefined = undefined; + private _currentStep = 0; + private _assistantDraft = ''; + private _thinkingDraft = ''; + private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; + private _activeThinkingComponent: ThinkingComponent | undefined = undefined; + private _activeCompactionBlock: CompactionComponent | undefined = undefined; + private _activeToolCalls = new Map(); + private _streamingToolCallArguments = new Map< string, { name?: string; argumentsText: string; startedAtMs: number } >(); - pendingToolComponents = new Map(); - pendingAgentGroup: { + private _pendingToolComponents = new Map(); + private _pendingAgentGroup: { readonly turnId: string | undefined; readonly step: number; solo?: ToolCallComponent; group?: AgentGroupComponent; } | null = null; - pendingReadGroup: { + private _pendingReadGroup: { readonly turnId: string | undefined; readonly step: number; solo?: ToolCallComponent; @@ -73,28 +73,204 @@ export class StreamingUIController { constructor(private readonly host: StreamingUIHost) {} + // --------------------------------------------------------------------------- + // Turn context — read/write accessors + // --------------------------------------------------------------------------- + + getTurnContext(): { turnId: string | undefined; step: number } { + return { turnId: this._currentTurnId, step: this._currentStep }; + } + + setTurnId(turnId: string | undefined): void { + this._currentTurnId = turnId; + } + + setStep(step: number): void { + this._currentStep = step; + } + + hasActiveTurn(): boolean { + return this._currentTurnId !== undefined; + } + + // --------------------------------------------------------------------------- + // Text streaming — semantic write accessors + // --------------------------------------------------------------------------- + + appendThinkingDelta(delta: string): void { + this._thinkingDraft += delta; + this.pendingThinkingFlush = true; + } + + appendAssistantDelta(delta: string): void { + if (this._streamingBlock === null) { + this.onStreamingTextStart(); + } + this._assistantDraft += delta; + this.pendingAssistantFlush = true; + } + + hasThinkingDraft(): boolean { + return this._thinkingDraft.length > 0; + } + + hasStreamingBlock(): boolean { + return this._streamingBlock !== null; + } + + getStreamingBlockComponent(): AssistantMessageComponent | undefined { + return this._streamingBlock?.component; + } + + clearAssistantDraft(): void { + this._assistantDraft = ''; + } + + // --------------------------------------------------------------------------- + // Tool call state — semantic accessors + // --------------------------------------------------------------------------- + + getActiveToolCall(id: string): ToolCallBlockData | undefined { + return this._activeToolCalls.get(id); + } + + hasActiveToolCall(id: string): boolean { + return this._activeToolCalls.has(id); + } + + setActiveToolCall(id: string, toolCall: ToolCallBlockData): void { + this._activeToolCalls.set(id, toolCall); + } + + removeActiveToolCall(id: string): void { + this._activeToolCalls.delete(id); + } + + getToolComponent(id: string): ToolCallComponent | undefined { + return this._pendingToolComponents.get(id); + } + + removeToolComponent(id: string): void { + this._pendingToolComponents.delete(id); + } + + hasPendingAgentGroup(): boolean { + return this._pendingAgentGroup !== null; + } + + hasPendingReadGroup(): boolean { + return this._pendingReadGroup !== null; + } + + removeToolComponentIfInactive(toolCallId: string): void { + if (!this._activeToolCalls.has(toolCallId)) { + this._pendingToolComponents.delete(toolCallId); + } + } + + /** Registers a tool call that arrived via tool.call.started. + * Clears any pending streaming state for this id, updates or creates the + * component, and returns whether the call was new (no previous entry). */ + registerToolCall(toolCall: ToolCallBlockData): boolean { + const existing = this._activeToolCalls.get(toolCall.id); + this._activeToolCalls.set(toolCall.id, toolCall); + this.pendingToolCallFlushIds.delete(toolCall.id); + this._streamingToolCallArguments.delete(toolCall.id); + const existingComponent = this._pendingToolComponents.get(toolCall.id); + if (existingComponent !== undefined) { + existingComponent.updateToolCall(toolCall); + } else if (existing === undefined) { + this.finalizeLiveTextBuffers('tool'); + if (toolCall.name !== 'Agent') { + this.onToolCallStart(toolCall); + } + } + return existing === undefined; + } + + /** Accumulates a streaming tool-call argument delta. */ + accumulateToolCallDelta( + id: string, + eventName: string | undefined, + argumentsPart: string | null | undefined, + ): void { + const existing = this._streamingToolCallArguments.get(id); + const argumentsText = appendStreamingArgsPreview(existing?.argumentsText, argumentsPart); + const name = eventName ?? existing?.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool'; + const startedAtMs = existing?.startedAtMs ?? Date.now(); + this._streamingToolCallArguments.set(id, { name, argumentsText, startedAtMs }); + this.pendingToolCallFlushIds.add(id); + } + + /** Completes a tool call: delivers the result and removes tracking state. + * Returns the matched ToolCallBlockData, or undefined if no call was tracked. */ + completeToolResult(toolCallId: string, result: ToolResultBlockData): ToolCallBlockData | undefined { + const matchedCall = this._activeToolCalls.get(toolCallId); + if (matchedCall !== undefined) { + this.onToolCallEnd(toolCallId, result); + } + this._activeToolCalls.delete(toolCallId); + this._streamingToolCallArguments.delete(toolCallId); + return matchedCall; + } + + /** Marks in-flight tool calls as truncated when a step hits max_tokens. + * Returns the count of tool calls that were truncated. */ + markStepTruncated(turnId: string, step: number): number { + let count = 0; + for (const toolCall of this._activeToolCalls.values()) { + if (toolCall.result !== undefined) continue; + if (toolCall.streamingArguments === undefined) continue; + if (toolCall.turnId !== turnId) continue; + if (toolCall.step !== step) continue; + toolCall.truncated = true; + const component = this._pendingToolComponents.get(toolCall.id); + if (component !== undefined) { + component.updateToolCall(toolCall); + } + count += 1; + } + this._streamingToolCallArguments.clear(); + return count; + } + + /** Tears down replay-specific state after session history has been rendered. */ + cleanupAfterReplay(completedToolCallIds: Set): void { + this._activeToolCalls.clear(); + for (const toolCallId of completedToolCallIds) { + this._pendingToolComponents.delete(toolCallId); + } + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + this._currentTurnId = undefined; + this._currentStep = 0; + this._streamingToolCallArguments.clear(); + this.pendingToolCallFlushIds.clear(); + this.host.state.ui.requestRender(); + } + // --------------------------------------------------------------------------- // Dispose helpers (moved from KimiTUI) // --------------------------------------------------------------------------- disposeActiveThinkingComponent(): void { - if (this.activeThinkingComponent !== undefined) { - this.activeThinkingComponent.dispose(); - this.activeThinkingComponent = undefined; + if (this._activeThinkingComponent !== undefined) { + this._activeThinkingComponent.dispose(); + this._activeThinkingComponent = undefined; } } disposeAndClearPendingToolComponents(): void { - for (const component of this.pendingToolComponents.values()) { + for (const component of this._pendingToolComponents.values()) { if (hasDispose(component)) component.dispose(); } - this.pendingToolComponents.clear(); + this._pendingToolComponents.clear(); } disposeActiveCompactionBlock(): void { - if (this.activeCompactionBlock !== undefined) { - this.activeCompactionBlock.dispose(); - this.activeCompactionBlock = undefined; + if (this._activeCompactionBlock !== undefined) { + this._activeCompactionBlock.dispose(); + this._activeCompactionBlock = undefined; } } @@ -156,11 +332,11 @@ export class StreamingUIController { this.pendingAssistantFlush = false; this.pendingToolCallFlushIds.clear(); - if (shouldFlushThinking && this.thinkingDraft.length > 0) { - this.onThinkingUpdate(this.thinkingDraft); + if (shouldFlushThinking && this._thinkingDraft.length > 0) { + this.onThinkingUpdate(this._thinkingDraft); } if (shouldFlushAssistant) { - this.onStreamingTextUpdate(this.assistantDraft); + this.onStreamingTextUpdate(this._assistantDraft); } for (const id of toolCallIds) { this.flushToolCallPreview(id); @@ -181,21 +357,21 @@ export class StreamingUIController { flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { this.flushNow(); - if (this.thinkingDraft.length === 0) { + if (this._thinkingDraft.length === 0) { this.host.patchLivePane({ mode: nextMode }); return; } - this.thinkingDraft = ''; + this._thinkingDraft = ''; this.onThinkingEnd(); this.host.patchLivePane({ mode: nextMode }); } finalizeAssistantStream(): void { this.flushNow(); - if (this.streamingBlock !== null) { + if (this._streamingBlock !== null) { this.onStreamingTextEnd(); } - this.assistantDraft = ''; + this._assistantDraft = ''; this.host.updateActivityPane(); this.host.state.ui.requestRender(); } @@ -204,23 +380,23 @@ export class StreamingUIController { this.pendingAssistantFlush = false; this.pendingThinkingFlush = false; this.clearFlushTimerIfIdle(); - this.assistantDraft = ''; - this.streamingBlock = null; - this.thinkingDraft = ''; + this._assistantDraft = ''; + this._streamingBlock = null; + this._thinkingDraft = ''; this.disposeActiveThinkingComponent(); } resetToolUi(): void { this.pendingToolCallFlushIds.clear(); this.clearFlushTimerIfIdle(); - this.streamingToolCallArguments.clear(); + this._streamingToolCallArguments.clear(); this.disposeAndClearPendingToolComponents(); - this.pendingAgentGroup = null; - this.pendingReadGroup = null; + this._pendingAgentGroup = null; + this._pendingReadGroup = null; } resetToolCallState(): void { - this.activeToolCalls.clear(); + this._activeToolCalls.clear(); } finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { @@ -233,10 +409,10 @@ export class StreamingUIController { if (state.appState.streamingPhase === 'idle') return; this.host.deferUserMessages = false; const completedTurnKey = - this.currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; + this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); this.resetToolCallState(); - this.currentTurnId = undefined; + this._currentTurnId = undefined; if (state.queuedMessages.length > 0) { const [next, ...rest] = state.queuedMessages; @@ -265,12 +441,12 @@ export class StreamingUIController { onStreamingTextStart(): void { const { state } = this.host; - this.pendingAgentGroup = null; - this.pendingReadGroup = null; + this._pendingAgentGroup = null; + this._pendingReadGroup = null; const entry = { id: nextTranscriptId(), kind: 'assistant' as const, - turnId: this.currentTurnId, + turnId: this._currentTurnId, renderMode: 'markdown' as const, content: '', }; @@ -278,14 +454,14 @@ export class StreamingUIController { state.theme.markdownTheme, state.theme.colors, ); - this.streamingBlock = { component, entry }; + this._streamingBlock = { component, entry }; state.transcriptEntries.push(entry); state.transcriptContainer.addChild(component); state.ui.requestRender(); } onStreamingTextUpdate(fullText: string): void { - const block = this.streamingBlock; + const block = this._streamingBlock; if (block !== null) { block.entry.content = fullText; block.component.updateContent(fullText); @@ -294,33 +470,33 @@ export class StreamingUIController { } onStreamingTextEnd(): void { - this.streamingBlock = null; + this._streamingBlock = null; } onThinkingUpdate(fullText: string): void { const { state } = this.host; - if (this.activeThinkingComponent === undefined) { - this.pendingAgentGroup = null; - this.pendingReadGroup = null; - this.activeThinkingComponent = new ThinkingComponent( + if (this._activeThinkingComponent === undefined) { + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + this._activeThinkingComponent = new ThinkingComponent( fullText, state.theme.colors, true, 'live', state.ui, ); - if (state.toolOutputExpanded) this.activeThinkingComponent.setExpanded(true); - state.transcriptContainer.addChild(this.activeThinkingComponent); + if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); + state.transcriptContainer.addChild(this._activeThinkingComponent); } else { - this.activeThinkingComponent.setText(fullText); + this._activeThinkingComponent.setText(fullText); } state.ui.requestRender(); } onThinkingEnd(): void { - if (this.activeThinkingComponent === undefined) return; - this.activeThinkingComponent.finalize(); - this.activeThinkingComponent = undefined; + if (this._activeThinkingComponent === undefined) return; + this._activeThinkingComponent.finalize(); + this._activeThinkingComponent = undefined; this.host.state.ui.requestRender(); } @@ -338,10 +514,10 @@ export class StreamingUIController { ); if (state.toolOutputExpanded) tc.setExpanded(true); if (state.planExpanded) tc.setPlanExpanded(true); - this.pendingToolComponents.set(toolCall.id, tc); + this._pendingToolComponents.set(toolCall.id, tc); - if (toolCall.name !== 'Agent') this.pendingAgentGroup = null; - if (toolCall.name !== 'Read') this.pendingReadGroup = null; + if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; + if (toolCall.name !== 'Read') this._pendingReadGroup = null; let handled = this.tryAttachAgentToolCall(toolCall, tc); if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); @@ -365,11 +541,11 @@ export class StreamingUIController { onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { const { state } = this.host; - const matchedCall = this.activeToolCalls.get(toolCallId); - const tc = this.pendingToolComponents.get(toolCallId); + const matchedCall = this._activeToolCalls.get(toolCallId); + const tc = this._pendingToolComponents.get(toolCallId); if (tc) { tc.setResult(result); - this.pendingToolComponents.delete(toolCallId); + this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); return; } @@ -402,29 +578,29 @@ export class StreamingUIController { beginCompaction(instruction?: string): void { const { state } = this.host; - if (this.activeCompactionBlock !== undefined) { - this.activeCompactionBlock.markDone(); - this.activeCompactionBlock = undefined; + if (this._activeCompactionBlock !== undefined) { + this._activeCompactionBlock.markDone(); + this._activeCompactionBlock = undefined; } const block = new CompactionComponent(state.theme.colors, state.ui, instruction); - this.activeCompactionBlock = block; + this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); state.ui.requestRender(); } endCompaction(tokensBefore?: number, tokensAfter?: number): void { - const block = this.activeCompactionBlock; + const block = this._activeCompactionBlock; if (block === undefined) return; block.markDone(tokensBefore, tokensAfter); - this.activeCompactionBlock = undefined; + this._activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } cancelCompaction(): void { - const block = this.activeCompactionBlock; + const block = this._activeCompactionBlock; if (block === undefined) return; block.markCanceled(); - this.activeCompactionBlock = undefined; + this._activeCompactionBlock = undefined; this.host.state.ui.requestRender(); } @@ -433,24 +609,24 @@ export class StreamingUIController { // --------------------------------------------------------------------------- private flushToolCallPreview(id: string): void { - const streaming = this.streamingToolCallArguments.get(id); + const streaming = this._streamingToolCallArguments.get(id); if (streaming === undefined) return; const toolCall: ToolCallBlockData = { id, - name: streaming.name ?? this.activeToolCalls.get(id)?.name ?? 'Tool', + name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', args: parseStreamingArgs(streaming.argumentsText), streamingArguments: streaming.argumentsText, streamingStartedAtMs: streaming.startedAtMs, - step: this.currentStep, - turnId: this.currentTurnId, + step: this._currentStep, + turnId: this._currentTurnId, }; - this.activeToolCalls.set(id, toolCall); + this._activeToolCalls.set(id, toolCall); - if (this.thinkingDraft.length > 0 || this.streamingBlock !== null) { + if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) { this.finalizeLiveTextBuffers('tool'); } - const existingComponent = this.pendingToolComponents.get(id); + const existingComponent = this._pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); } else if (toolCall.name !== 'Agent') { @@ -461,21 +637,21 @@ export class StreamingUIController { private tryAttachAgentToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { const { state } = this.host; if (toolCall.name !== 'Agent') { - this.pendingAgentGroup = null; + this._pendingAgentGroup = null; return false; } - const step = toolCall.step ?? this.currentStep; - const turnId = toolCall.turnId ?? this.currentTurnId; - const pending = this.pendingAgentGroup; + const step = toolCall.step ?? this._currentStep; + const turnId = toolCall.turnId ?? this._currentTurnId; + const pending = this._pendingAgentGroup; if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - this.pendingAgentGroup = null; + this._pendingAgentGroup = null; } - const cur = this.pendingAgentGroup; + const cur = this._pendingAgentGroup; if (cur === null) { - this.pendingAgentGroup = { step, turnId, solo: tc }; + this._pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; @@ -488,14 +664,14 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { - this.pendingAgentGroup = { step, turnId, solo: tc }; + this._pendingAgentGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } const group = this.upgradeSoloAgentToGroup(solo); group.attach(toolCall.id, tc); - this.pendingAgentGroup = { step, turnId, group }; + this._pendingAgentGroup = { step, turnId, group }; state.ui.requestRender(); return true; } @@ -518,21 +694,21 @@ export class StreamingUIController { private tryAttachReadToolCall(toolCall: ToolCallBlockData, tc: ToolCallComponent): boolean { const { state } = this.host; if (toolCall.name !== 'Read') { - this.pendingReadGroup = null; + this._pendingReadGroup = null; return false; } - const step = toolCall.step ?? this.currentStep; - const turnId = toolCall.turnId ?? this.currentTurnId; - const pending = this.pendingReadGroup; + const step = toolCall.step ?? this._currentStep; + const turnId = toolCall.turnId ?? this._currentTurnId; + const pending = this._pendingReadGroup; if (pending !== null && (pending.step !== step || pending.turnId !== turnId)) { - this.pendingReadGroup = null; + this._pendingReadGroup = null; } - const cur = this.pendingReadGroup; + const cur = this._pendingReadGroup; if (cur === null) { - this.pendingReadGroup = { step, turnId, solo: tc }; + this._pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; @@ -545,14 +721,14 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { - this.pendingReadGroup = { step, turnId, solo: tc }; + this._pendingReadGroup = { step, turnId, solo: tc }; state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } const group = this.upgradeSoloReadToGroup(solo); group.attach(toolCall.id, tc); - this.pendingReadGroup = { step, turnId, group }; + this._pendingReadGroup = { step, turnId, group }; state.ui.requestRender(); return true; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5ff0c598a3..b214bedb05 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1066,7 +1066,7 @@ export class KimiTUI { // Resets request-scoped state before submitting work to the active session. beginSessionRequest(): void { - this.streamingUI.currentTurnId = undefined; + this.streamingUI.setTurnId(undefined); this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); this.streamingUI.resetToolCallState(); @@ -1163,7 +1163,7 @@ export class KimiTUI { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', - turnId: this.streamingUI.currentTurnId, + turnId: this.streamingUI.getTurnContext().turnId, renderMode: 'plain', content: part, }); @@ -1354,8 +1354,8 @@ export class KimiTUI { this.tasksBrowserController.close(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); - this.streamingUI.currentTurnId = undefined; - this.streamingUI.currentStep = 0; + this.streamingUI.setTurnId(undefined); + this.streamingUI.setStep(0); this.streamingUI.resetLiveText(); this.updateQueueDisplay(); } 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 0da4c33edb..6e720825be 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 @@ -693,7 +693,7 @@ describe('KimiTUI message flow', () => { const sendQueued = vi.fn(); driver.state.appState.streamingPhase = 'waiting'; driver.state.appState.streamingStartTime = 1; - driver.streamingUI.currentTurnId = '1'; + driver.streamingUI.setTurnId('1'); driver.state.queuedMessages = [{ text: 'next' }]; driver.sessionEventHandler.handleEvent( @@ -732,7 +732,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - const component = driver.streamingUI.streamingBlock?.component; + const component = driver.streamingUI.getStreamingBlockComponent(); if (component === undefined) throw new Error('expected streaming component'); const updateSpy = vi.spyOn(component, 'updateContent'); @@ -805,8 +805,8 @@ describe('KimiTUI message flow', () => { vi.useFakeTimers(); try { const { driver } = await makeDriver(); - driver.streamingUI.currentTurnId = '1'; - driver.streamingUI.currentStep = 1; + driver.streamingUI.setTurnId('1'); + driver.streamingUI.setStep(1); driver.sessionEventHandler.handleEvent( { @@ -821,13 +821,13 @@ describe('KimiTUI message flow', () => { vi.fn(), ); - expect(driver.streamingUI.pendingToolComponents.has('call_bash')).toBe(false); - expect(driver.streamingUI.activeToolCalls.has('call_bash')).toBe(false); + expect(driver.streamingUI.getToolComponent('call_bash')).toBeUndefined(); + expect(driver.streamingUI.hasActiveToolCall('call_bash')).toBe(false); await vi.runOnlyPendingTimersAsync(); - expect(driver.streamingUI.pendingToolComponents.has('call_bash')).toBe(true); - expect(driver.streamingUI.activeToolCalls.get('call_bash')?.args).toMatchObject({ + expect(driver.streamingUI.getToolComponent('call_bash')).toBeDefined(); + expect(driver.streamingUI.getActiveToolCall('call_bash')?.args).toMatchObject({ command: 'echo hi', }); } finally { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5a9105b877..c4a64fc99c 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -245,9 +245,9 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); - expect(driver.streamingUI.pendingAgentGroup).toBeNull(); - expect(driver.streamingUI.pendingToolComponents.has('call_agent_1')).toBe(false); - expect(driver.streamingUI.pendingToolComponents.has('call_agent_2')).toBe(false); + expect(driver.streamingUI.hasPendingAgentGroup()).toBe(false); + expect(driver.streamingUI.getToolComponent('call_agent_1')).toBeUndefined(); + expect(driver.streamingUI.getToolComponent('call_agent_2')).toBeUndefined(); }); it('groups replayed Read calls from one assistant message using live grouping', async () => { @@ -274,9 +274,9 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(ReadGroupComponent); expect((group as ReadGroupComponent).size()).toBe(2); - expect(driver.streamingUI.pendingReadGroup).toBeNull(); - expect(driver.streamingUI.pendingToolComponents.has('call_read_1')).toBe(false); - expect(driver.streamingUI.pendingToolComponents.has('call_read_2')).toBe(false); + expect(driver.streamingUI.hasPendingReadGroup()).toBe(false); + expect(driver.streamingUI.getToolComponent('call_read_1')).toBeUndefined(); + expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); }); it('hydrates todo and background snapshot state from resumed main agent', async () => { From 0e69a0a3ab740300416d30e16d5eafdfb1027c15 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 20:06:15 +0800 Subject: [PATCH 24/28] refactor(tui): extract EditorKeyboardController from KimiTUI Move editor callback wiring, pending-exit state, clipboard media handling, and external editor logic into a dedicated controller. KimiTUI drops from 2070 to 1814 lines. --- .../src/tui/controllers/editor-keyboard.ts | 289 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 288 +---------------- 2 files changed, 305 insertions(+), 272 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/editor-keyboard.ts diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts new file mode 100644 index 0000000000..5f0a2cfb18 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -0,0 +1,289 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; + +import { + CTRL_C_HINT, + CTRL_D_HINT, + EXIT_CONFIRM_WINDOW_MS, + LLM_NOT_SET_MESSAGE, + NO_ACTIVE_SESSION_MESSAGE, +} from '../constant/kimi-tui'; +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'; + +export interface EditorKeyboardHost { + state: TUIState; + session: Session | undefined; + cancelInFlight: (() => void) | undefined; + + handleUserInput(text: string): void; + 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; + toggleToolOutputExpansion(): void; + togglePlanExpansion(): boolean; + hideSessionPicker(): void; + stop(exitCode?: number): Promise; + handlePlanToggle(next: boolean): void; +} + +export class EditorKeyboardController { + private pendingExit: PendingExit | null = null; + + constructor( + private readonly host: EditorKeyboardHost, + private readonly imageStore: ImageAttachmentStore, + ) {} + + install(): void { + const { host } = this; + const editor = host.state.editor; + + editor.onSubmit = (text: string) => { + host.handleUserInput(text); + }; + + editor.onChange = (text: string) => { + if (this.pendingExit) this.clearPendingExit(); + host.updateEditorBorderHighlight(text); + }; + + editor.onCtrlC = () => { + if (host.cancelInFlight !== undefined) { + const cancel = host.cancelInFlight; + host.cancelInFlight = undefined; + this.clearPendingExit(); + cancel(); + return; + } + + if (host.state.appState.isCompacting) { + this.clearPendingExit(); + this.cancelCurrentCompaction(); + return; + } + + if (host.state.appState.streamingPhase !== 'idle') { + this.clearPendingExit(); + this.cancelCurrentStream(); + return; + } + + if (this.pendingExit?.kind === 'ctrl-c') { + this.clearPendingExit(); + void host.stop(); + return; + } + + if (editor.getText().length > 0) { + editor.setText(''); + } + this.armPendingExit('ctrl-c', CTRL_C_HINT); + }; + + editor.onCtrlD = () => { + if (this.pendingExit?.kind === 'ctrl-d') { + this.clearPendingExit(); + void host.stop(); + return; + } + this.armPendingExit('ctrl-d', CTRL_D_HINT); + }; + + editor.onEscape = () => { + if (this.pendingExit) this.clearPendingExit(); + if (host.state.activeDialog === 'session-picker') { + host.hideSessionPicker(); + return; + } + if (host.state.appState.isCompacting) { + this.cancelCurrentCompaction(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { + this.cancelCurrentStream(); + } + }; + + editor.onShiftTab = () => { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const next = !host.state.appState.planMode; + host.track('shortcut_plan_toggle', { enabled: next }); + host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + host.handlePlanToggle(next); + }; + + editor.onOpenExternalEditor = () => { + host.track('shortcut_editor'); + void this.openExternalEditor(); + }; + + editor.onToggleToolExpand = () => { + host.track('shortcut_expand'); + host.toggleToolOutputExpansion(); + }; + + editor.onTogglePlanExpand = () => host.togglePlanExpansion(); + + editor.onCtrlS = () => { + if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + const text = editor.getText().trim(); + const queuedTexts = host.state.queuedMessages.map((m) => m.text); + host.state.queuedMessages = []; + + const parts: string[] = []; + for (const q of queuedTexts) { + const trimmed = q.trim(); + if (trimmed.length > 0) parts.push(trimmed); + } + if (text.length > 0) parts.push(text); + + if (parts.length > 0) { + editor.setText(''); + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + } else { + host.steerMessage(session, parts); + } + } + host.updateQueueDisplay(); + host.state.ui.requestRender(); + }; + + editor.onUndo = () => { + host.track('undo'); + }; + + editor.onInsertNewline = () => { + host.track('shortcut_newline'); + }; + + editor.onTextPaste = () => { + host.track('shortcut_paste', { kind: 'text' }); + }; + + editor.onUpArrowEmpty = () => { + if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; + const recalled = host.recallLastQueued(); + if (recalled !== undefined) { + editor.setText(recalled); + host.updateQueueDisplay(); + host.state.ui.requestRender(); + return true; + } + return false; + }; + + editor.onPasteImage = async () => this.handleClipboardImagePaste(); + } + + clearPendingExit(): void { + if (!this.pendingExit) return; + clearTimeout(this.pendingExit.timer); + this.host.state.footer.setTransientHint(null); + this.pendingExit = null; + } + + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { + this.clearPendingExit(); + this.host.state.footer.setTransientHint(hint); + + const timer = setTimeout(() => { + if (this.pendingExit?.timer === timer) { + this.clearPendingExit(); + this.host.state.ui.requestRender(); + } + }, EXIT_CONFIRM_WINDOW_MS); + + this.pendingExit = { kind, timer }; + this.host.state.ui.requestRender(); + } + + private cancelCurrentStream(): void { + this.host.session?.cancel(); + } + + private cancelCurrentCompaction(): void { + const session = this.host.session; + if (session === undefined) return; + void session.cancelCompaction().catch((error: unknown) => { + const message = formatErrorMessage(error); + this.host.showError(`Failed to cancel compaction: ${message}`); + }); + } + + private async handleClipboardImagePaste(): Promise { + let media; + try { + media = await readClipboardMedia(); + } catch (error) { + if (error instanceof ClipboardMediaError) { + this.host.showError(error.message); + return true; + } + return false; + } + if (media === null) return false; + + if (media.kind === 'video') { + const attachment = this.imageStore.addVideo(media.mimeType, media.sourcePath, media.filename); + this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.host.state.ui.requestRender(); + this.host.track('shortcut_paste', { kind: 'video' }); + return true; + } + + const meta = parseImageMeta(media.bytes); + if (meta === null) return false; + const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); + this.host.state.ui.requestRender(); + this.host.track('shortcut_paste', { kind: 'image' }); + return true; + } + + private async openExternalEditor(): Promise { + const { state } = this.host; + if (state.externalEditorRunning) return; + const cmd = resolveEditorCommand(state.appState.editorCommand); + if (cmd === undefined) { + this.host.showError('No editor configured. Set $VISUAL / $EDITOR, or run /editor .'); + return; + } + state.externalEditorRunning = true; + const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); + state.ui.stop(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + try { + const result = await editInExternalEditor(seed, cmd); + if (result !== undefined) { + state.editor.setText(result.replaceAll('\r\n', '\n').replace(/\n$/, '')); + } + } catch (error) { + const msg = formatErrorMessage(error); + this.host.showError(`External editor failed: ${msg}`); + } finally { + if (typeof process.stdin.pause === 'function') { + process.stdin.pause(); + } + state.ui.start(); + state.ui.setFocus(state.editor); + state.ui.requestRender(true); + state.externalEditorRunning = false; + } + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b214bedb05..13ffcacac6 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -34,13 +34,10 @@ import chalk from 'chalk'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; -import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import type { GitLsFilesCache } from '#/utils/git/git-ls-files'; import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; -import { parseImageMeta } from '#/utils/image/image-mime'; import { getInputHistoryFile } from '#/utils/paths'; -import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; import { detectFdPath } from '#/utils/process/fd-detect'; import { @@ -64,6 +61,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 { EditorKeyboardController } from './controllers/editor-keyboard'; import { SessionEventHandler } from './controllers/session-event-handler'; import * as slashCommands from './commands/dispatch'; import { SessionReplayRenderer } from './controllers/session-replay'; @@ -84,9 +82,6 @@ import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; import { - CTRL_C_HINT, - CTRL_D_HINT, - EXIT_CONFIRM_WINDOW_MS, LLM_NOT_SET_MESSAGE, MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, @@ -108,7 +103,6 @@ import { type KimiTUIOptions, type LivePaneState, type LoginProgressSpinnerHandle, - type PendingExit, type QueuedMessage, type TranscriptEntry, type TUIStartupOptions, @@ -137,7 +131,6 @@ export { createTUIState } from './tui-state'; export type { KimiTUIOptions, LoginProgressSpinnerHandle, - PendingExit, TUIStartupOptions, TUIStartupState, } from './types'; @@ -203,7 +196,6 @@ export class KimiTUI { private readonly fdPath: string | null = detectFdPath(); private readonly gitLsFilesCache: GitLsFilesCache; sessionEventUnsubscribe: (() => void) | undefined; - private pendingExit: PendingExit | null = null; cancelInFlight: (() => void) | undefined; // Queues editor messages instead of sending or steering them. Used by /init. deferUserMessages = false; @@ -229,6 +221,7 @@ export class KimiTUI { readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; + readonly editorKeyboard: EditorKeyboardController; public onExit?: (exitCode?: number) => Promise; @@ -283,7 +276,8 @@ export class KimiTUI { this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); - this.setupEditorHandlers(); + this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); + this.editorKeyboard.install(); this.buildLayout(); } @@ -557,10 +551,7 @@ export class KimiTUI { this.unregisterSignalHandlers(); this.aborted = true; this.streamingUI.discardPending(); - if (this.pendingExit) { - clearTimeout(this.pendingExit.timer); - this.pendingExit = null; - } + this.editorKeyboard.clearPendingExit(); for (const dispose of this.reverseRpcDisposers) { dispose(); } @@ -718,246 +709,15 @@ export class KimiTUI { ui.addChild(footerWrap); } - // Wires editor shortcuts, submission, paste, and navigation callbacks. - private setupEditorHandlers(): void { - const editor = this.state.editor; - - editor.onSubmit = (text: string) => { - this.handleUserInput(text); - }; - - editor.onChange = (text: string) => { - if (this.pendingExit) this.clearPendingExit(); - this.updateEditorBorderHighlight(text); - }; - - editor.onCtrlC = () => { - if (this.cancelInFlight !== undefined) { - const cancel = this.cancelInFlight; - this.cancelInFlight = undefined; - this.clearPendingExit(); - cancel(); - return; - } - - if (this.state.appState.isCompacting) { - this.clearPendingExit(); - this.cancelCurrentCompaction(); - return; - } - - if (this.state.appState.streamingPhase !== 'idle') { - this.clearPendingExit(); - this.cancelCurrentStream(); - return; - } - - if (this.pendingExit?.kind === 'ctrl-c') { - this.clearPendingExit(); - void this.stop(); - return; - } - - if (editor.getText().length > 0) { - editor.setText(''); - } - this.armPendingExit('ctrl-c', CTRL_C_HINT); - }; - - editor.onCtrlD = () => { - if (this.pendingExit?.kind === 'ctrl-d') { - this.clearPendingExit(); - void this.stop(); - return; - } - this.armPendingExit('ctrl-d', CTRL_D_HINT); - }; - - editor.onEscape = () => { - if (this.pendingExit) this.clearPendingExit(); - if (this.state.activeDialog === 'session-picker') { - this.hideSessionPicker(); - return; - } - if (this.state.appState.isCompacting) { - this.cancelCurrentCompaction(); - return; - } - if (this.state.appState.streamingPhase !== 'idle') { - this.cancelCurrentStream(); - } - }; - - editor.onShiftTab = () => { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - const next = !this.state.appState.planMode; - this.track('shortcut_plan_toggle', { enabled: next }); - this.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); - }; - - editor.onOpenExternalEditor = () => { - this.track('shortcut_editor'); - void this.openExternalEditor(); - }; - - editor.onToggleToolExpand = () => { - this.track('shortcut_expand'); - this.toggleToolOutputExpansion(); - }; - - editor.onTogglePlanExpand = () => this.togglePlanExpansion(); - - editor.onCtrlS = () => { - if (this.state.appState.streamingPhase === 'idle' || this.state.appState.isCompacting) return; - const text = editor.getText().trim(); - const queuedTexts = this.state.queuedMessages.map((m) => m.text); - this.state.queuedMessages = []; - - const parts: string[] = []; - for (const q of queuedTexts) { - const trimmed = q.trim(); - if (trimmed.length > 0) parts.push(trimmed); - } - if (text.length > 0) parts.push(text); - - if (parts.length > 0) { - editor.setText(''); - const session = this.session; - if (this.state.appState.model.trim().length === 0 || session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - } else { - this.steerMessage(session, parts); - } - } - this.updateQueueDisplay(); - this.state.ui.requestRender(); - }; - - editor.onUndo = () => { - this.track('undo'); - }; - - editor.onInsertNewline = () => { - this.track('shortcut_newline'); - }; - - editor.onTextPaste = () => { - this.track('shortcut_paste', { kind: 'text' }); - }; - - editor.onUpArrowEmpty = () => { - if (this.state.appState.streamingPhase === 'idle' && !this.state.appState.isCompacting) return false; - const recalled = this.recallLastQueued(); - if (recalled !== undefined) { - editor.setText(recalled); - this.updateQueueDisplay(); - this.state.ui.requestRender(); - return true; - } - return false; - }; - - editor.onPasteImage = async () => this.handleClipboardImagePaste(); - } - - // Cancels the pending double-key exit prompt. - private clearPendingExit(): void { - if (!this.pendingExit) return; - clearTimeout(this.pendingExit.timer); - this.state.footer.setTransientHint(null); - this.pendingExit = null; - } - - // Starts a timed confirmation window for Ctrl-C or Ctrl-D exit. - private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { - this.clearPendingExit(); - this.state.footer.setTransientHint(hint); - - const timer = setTimeout(() => { - if (this.pendingExit?.timer === timer) { - this.clearPendingExit(); - this.state.ui.requestRender(); - } - }, EXIT_CONFIRM_WINDOW_MS); - - this.pendingExit = { kind, timer }; - this.state.ui.requestRender(); - } - - // Reads image or video data from the clipboard and inserts an attachment placeholder. - private async handleClipboardImagePaste(): Promise { - let media; - try { - media = await readClipboardMedia(); - } catch (error) { - if (error instanceof ClipboardMediaError) { - this.showError(error.message); - return true; - } - return false; - } - if (media === null) return false; - - if (media.kind === 'video') { - const attachment = this.imageStore.addVideo(media.mimeType, media.sourcePath, media.filename); - this.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); - this.state.ui.requestRender(); - this.track('shortcut_paste', { kind: 'video' }); - return true; - } - - const meta = parseImageMeta(media.bytes); - if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); - this.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); - this.state.ui.requestRender(); - this.track('shortcut_paste', { kind: 'image' }); - return true; - } - - // Opens the configured external editor and writes the edited text back. - private async openExternalEditor(): Promise { - if (this.state.externalEditorRunning) return; - const cmd = resolveEditorCommand(this.state.appState.editorCommand); - if (cmd === undefined) { - this.showError('No editor configured. Set $VISUAL / $EDITOR, or run /editor .'); - return; - } - this.state.externalEditorRunning = true; - const seed = this.state.editor.getExpandedText?.() ?? this.state.editor.getText(); - this.state.ui.stop(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - try { - const result = await editInExternalEditor(seed, cmd); - if (result !== undefined) { - this.state.editor.setText(result.replaceAll('\r\n', '\n').replace(/\n$/, '')); - } - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`External editor failed: ${msg}`); - } finally { - if (typeof process.stdin.pause === 'function') { - process.stdin.pause(); - } - this.state.ui.start(); - this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(true); - this.state.externalEditorRunning = false; - } - } - // ========================================================================= // Input Dispatch // ========================================================================= - private handleUserInput(text: string): void { + handlePlanToggle(next: boolean): void { + void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); + } + + handleUserInput(text: string): void { if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); @@ -1039,7 +799,7 @@ export class KimiTUI { } // Pops the most recent queued message back into the editor. - private recallLastQueued(): string | undefined { + recallLastQueued(): string | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); @@ -1145,7 +905,7 @@ export class KimiTUI { } // Sends steering input into an active stream or falls back to normal prompts. - private steerMessage(session: Session, input: string[]): void { + steerMessage(session: Session, input: string[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { for (const part of input) { this.enqueueMessage(part); @@ -1175,22 +935,6 @@ export class KimiTUI { }); } - // Requests cancellation of the active session stream. - private cancelCurrentStream(): void { - const session = this.session; - if (session === undefined) return; - void session.cancel(); - } - - private cancelCurrentCompaction(): void { - const session = this.session; - if (session === undefined) return; - void session.cancelCompaction().catch((error: unknown) => { - const message = formatErrorMessage(error); - this.showError(`Failed to cancel compaction: ${message}`); - }); - } - // ========================================================================= // State Helpers // ========================================================================= @@ -1759,7 +1503,7 @@ export class KimiTUI { } // Toggles expansion for all expandable tool-output components. - private toggleToolOutputExpansion(): void { + toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; for (const child of this.state.transcriptContainer.children) { if (isExpandable(child)) { @@ -1772,7 +1516,7 @@ export class KimiTUI { // Toggles expansion for plan-preview cards (ExitPlanMode). Returns true // iff at least one plan card was actually toggled so the caller can decide // whether to consume the keystroke vs. let pi-tui's default end-of-line run. - private togglePlanExpansion(): boolean { + togglePlanExpansion(): boolean { const next = !this.state.planExpanded; let toggled = false; for (const child of this.state.transcriptContainer.children) { @@ -1787,7 +1531,7 @@ export class KimiTUI { } // Updates the editor border color for slash command and plan-mode context. - private updateEditorBorderHighlight(text?: string): void { + updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); const colorToken = this.state.appState.planMode || trimmed.startsWith('/') @@ -1978,7 +1722,7 @@ export class KimiTUI { } // Hides the session picker and restores the editor. - private hideSessionPicker(): void { + hideSessionPicker(): void { this.state.activeDialog = null; this.restoreEditor(); } From 0d3fdfd7aad434d5ae194b140631a4c16d4d5611 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 20:23:14 +0800 Subject: [PATCH 25/28] =?UTF-8?q?refactor(tui):=20clean=20up=20kimi-tui.ts?= =?UTF-8?q?=20=E2=80=94=20strip=20noise=20comments,=20reorganize=20section?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ~70 single-line comments that merely restated the method name. Condense multi-paragraph inline comments (signal handlers, start()) to one-liners that capture the WHY. Reorganize sections: merge the one- method "Layout" section into Lifecycle, rename "Startup Helpers" to "Autocomplete & Skill Commands", move stray accessors into "State & Accessors", delete the empty trailing section, relocate input-history helpers next to each other. 1814 → 1639 lines. --- apps/kimi-code/src/tui/kimi-tui.ts | 281 ++++++----------------------- 1 file changed, 53 insertions(+), 228 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 13ffcacac6..dec022d7cc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,12 +1,3 @@ -/** - * KimiTUI owns the terminal UI shell for a Kimi Code session. - * - * It builds the pi-tui layout, tracks view state, wires editor shortcuts and - * slash commands, drives session startup/switching, renders SDK events into the - * transcript and live panes, and bridges approval, question, auth, and config - * flows back to the harness. - */ - import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -111,9 +102,7 @@ import { import { createTUIState, type TUIState } from './tui-state'; import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; -import { - formatErrorMessage, -} from './utils/event-payload'; +import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; @@ -149,7 +138,6 @@ export interface KimiTUIStartupInput { type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; -// Builds the app-state snapshot used before a session is attached. function createInitialAppState(input: KimiTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.yolo ? 'yolo' : 'manual'; return { @@ -197,21 +185,13 @@ export class KimiTUI { private readonly gitLsFilesCache: GitLsFilesCache; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; - // Queues editor messages instead of sending or steering them. Used by /init. deferUserMessages = false; aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; - // Cleanup callbacks for SIGHUP/SIGTERM listeners and stdout/stderr 'error' - // listeners installed by `registerSignalHandlers()`. Drained on shutdown so - // we never leave dangling listeners on the host `process`. private signalCleanupHandlers: Array<() => void> = []; - // Guards `stop()` and `emergencyTerminalExit()` so a signal arriving mid- - // shutdown does not race with itself. private isShuttingDown = false; - // First-launch migration plan detected pre-TUI; null when nothing to migrate. private readonly migrationPlan: MigrationPlan | null; - // When true, the migration screen is the whole session: run it, then exit. private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; @@ -232,7 +212,6 @@ export class KimiTUI { this.harness.track(event, properties); } - // Initializes state, reverse-RPC handlers, editor callbacks, and layout. constructor(harness: KimiHarness, startupInput: KimiTUIStartupInput) { this.harness = harness; const tuiOptions: KimiTUIOptions = { @@ -254,7 +233,6 @@ export class KimiTUI { this.state = createTUIState(tuiOptions); this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir); - // Register approval / question UI controllers before SDK handlers. this.reverseRpcDisposers.push( ...registerReverseRPCHandlers(this.approvalController, this.questionController, { showApprovalPanel: (payload) => { @@ -282,15 +260,13 @@ export class KimiTUI { } // ========================================================================= - // Startup Helpers + // Autocomplete & Skill Commands // ========================================================================= - // Returns built-in and dynamically loaded slash commands in display order. private getSlashCommands(): readonly KimiSlashCommand[] { return [...sortSlashCommands(BUILTIN_SLASH_COMMANDS), ...this.skillCommands]; } - // Rebuilds editor autocomplete from slash commands and file mentions. private setupAutocomplete(): void { const slashCommands: SlashCommand[] = this.getSlashCommands().map((cmd) => ({ name: cmd.name, @@ -305,7 +281,6 @@ export class KimiTUI { this.state.editor.setAutocompleteProvider(provider); } - // Loads skill-backed slash commands from the active session. async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; @@ -329,53 +304,23 @@ export class KimiTUI { this.setupAutocomplete(); } - // Restores persisted input history for the current working directory. - private async loadPersistedInputHistory(): Promise { - try { - const file = getInputHistoryFile(this.state.appState.workDir); - const entries = await loadInputHistory(file); - for (const entry of entries) { - this.state.editor.addToHistory(entry.content); - } - this.lastHistoryContent = entries.at(-1)?.content; - } catch { - /* history is best-effort */ - } - } - // ========================================================================= // Lifecycle // ========================================================================= - // Starts the TUI, performs startup routing, and begins session event handling. async start(): Promise { - // Arm SIGHUP/SIGTERM and stdout/stderr 'error' handlers before touching the - // terminal: once raw mode is on and timers start firing, a dying parent - // shell can pin a CPU core on EIO write retries unless we can self-exit. + // Signal handlers must be installed before raw mode to avoid EIO loops. this.registerSignalHandlers(); - // Outer try ensures the signal handlers are rolled back if any startup - // path throws. Without this, callers that retry `start()` in the same - // Node process (tests, embedded use) would accumulate listeners on - // `process` and trip `MaxListenersExceededWarning`. Inner catch blocks - // still own their UI/focus cleanup; this only handles the listener half. + // Outer try rolls back signal listeners on startup failure. try { - // Migration path: the migration screen is a pi-tui component, so the - // event loop must run first. It then renders as the very first thing on - // screen, before the session is created and the Welcome banner is drawn. if (this.migrationPlan !== null) { + // Migration needs the event loop running first (pi-tui component). this.startEventLoop(); try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { - // Explicit `kimi migrate`: the screen is the whole command — exit - // instead of continuing into the chat TUI. A migration that ran - // but failed exits non-zero so scripted callers can detect it. const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; - // Restore the terminal before `onExit` calls `process.exit`: dispose - // the focus/theme tracking `startEventLoop()` installed, then stop - // the pi-tui loop. Skipping either leaves the terminal in raw mode - // or still emitting focus/OSC sequences after the command finishes. this.disposeTerminalTracking(); this.state.ui.stop(); await this.onExit?.(failed ? 1 : 0); @@ -384,10 +329,6 @@ export class KimiTUI { const shouldReplayHistory = await this.initMainTui(); await this.finishStartup(shouldReplayHistory); } catch (error) { - // The pi-tui loop is running and startEventLoop() installed focus/ - // theme tracking; a startup failure must tear all of it down before - // the exception propagates, otherwise the terminal is left in raw - // mode or still emitting focus/OSC sequences. this.disposeTerminalTracking(); this.state.ui.stop(); throw error; @@ -395,15 +336,11 @@ export class KimiTUI { return; } - // No-migration path: ordering is identical to the original `start()`. const shouldReplayHistory = await this.initMainTui(); this.startEventLoop(); try { await this.finishStartup(shouldReplayHistory); } catch (error) { - // The pi-tui loop is running and startEventLoop() installed focus/theme - // tracking; tear all of it down so a finishStartup failure does not - // leave the terminal in raw mode or emitting focus/OSC sequences. this.disposeTerminalTracking(); this.state.ui.stop(); throw error; @@ -414,9 +351,6 @@ export class KimiTUI { } } - // Creates/resumes the session, renders the Welcome banner, configures - // autocomplete and input history, and mounts the editor. Returns whether - // transcript history should be replayed. private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); @@ -429,15 +363,12 @@ export class KimiTUI { return shouldReplayHistory; } - // Starts the pi-tui event loop and installs terminal focus/theme tracking. private startEventLoop(): void { this.state.ui.start(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); } - // Runs post-init startup tasks: startup notice, picker bootstrap, transcript - // replay, and session event subscriptions. private async finishStartup(shouldReplayHistory: boolean): Promise { if (this.startupNotice !== undefined) { this.showStatus(this.startupNotice); @@ -446,8 +377,6 @@ export class KimiTUI { void this.showTmuxKeyboardWarningIfNeeded(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); - // resumeSession (fired on picker select) owns post-pick init; nothing - // else to do here until the user makes a choice. return; } if (shouldReplayHistory) { @@ -467,14 +396,12 @@ export class KimiTUI { void this.refreshSkillCommands(this.session); } - // Warns tmux users when modified Enter shortcuts are likely to be swallowed. private async showTmuxKeyboardWarningIfNeeded(): Promise { const warning = await detectTmuxKeyboardWarning(); if (warning === undefined || this.aborted) return; this.showStatus(warning, this.state.theme.colors.warning); } - // Creates or resumes the startup session and reports whether history should replay. private async init(): Promise { await this.authFlow.refreshAvailableModels(); @@ -540,11 +467,6 @@ export class KimiTUI { return shouldReplayHistory; } - // Stops UI resources, active sessions, reverse-RPC handlers, and the harness. - // `exitCode` is forwarded to `onExit`; it defaults to the conventional 0 for - // user-initiated exits (e.g. `/exit`). Signal-driven shutdown paths pass the - // POSIX 128 + signum value so supervisors can tell signal exits from clean - // exits. async stop(exitCode?: number): Promise { if (this.isShuttingDown) return; this.isShuttingDown = true; @@ -566,20 +488,8 @@ export class KimiTUI { } } - // Installs SIGHUP/SIGTERM signal handlers and stdout/stderr 'error' listeners - // so the process can self-terminate when the controlling terminal goes away. - // - // SIGHUP and EIO/EPIPE/ENOTCONN on stdout/stderr both mean "the terminal is - // gone". Running the normal `stop()` path in that state writes restore - // sequences (cursor show, bracketed paste off, Kitty protocol off) which - // re-trigger EIO and have been observed to pin a CPU core for days. - // `emergencyTerminalExit()` is the safe response: it bypasses cleanup. - // - // SIGTERM is treated as a graceful shutdown request and routes through the - // normal `stop()` path so telemetry and session state get flushed. - // - // `prependListener` ensures we run before any subsequent listener a feature - // might register later in startup, since responsiveness here is critical. + // SIGHUP / dead-terminal EIO → emergencyTerminalExit (no cleanup, avoids + // EIO write-loop that can pin a CPU core). SIGTERM → normal stop(). private registerSignalHandlers(): void { this.unregisterSignalHandlers(); @@ -594,14 +504,8 @@ export class KimiTUI { this.emergencyTerminalExit(); return; } - // SIGTERM: preserve the POSIX 128 + SIGTERM(15) = 143 convention so - // supervisors (launchd, systemd, pm2, parent shells) can distinguish - // signal-driven exit from a normal `/exit`. Registering a listener - // disables Node's default 143 termination, so we must reinstate it - // explicitly. Forcing `process.exit(143)` after `stop()` resolves - // also guards the defensive case where `onExit` was never wired up. - // On cleanup failure we exit 143 too — the process must not hang - // on pending I/O once `isShuttingDown` has been latched. + // Registering a SIGTERM listener disables Node's default exit(143), + // so we must reinstate it after stop() or on failure. this.stop(143).then( () => { process.exit(143); @@ -638,62 +542,19 @@ export class KimiTUI { for (const cleanup of handlers) cleanup(); } - // Bails out without running normal shutdown. Reserved for SIGHUP / dead- - // terminal write errors where every additional stdout write risks looping - // on EIO. The default exit code 129 follows the POSIX 128 + SIGHUP(1) - // convention; SIGTERM cleanup failures pass 143 (128 + SIGTERM(15)) so - // supervisors still see signal-conventional exits. + // Exit codes follow POSIX 128+signum: 129 = SIGHUP, 143 = SIGTERM. private emergencyTerminalExit(exitCode = 129): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); process.exit(exitCode); } - // Tears down the terminal focus + theme tracking installed by - // `startEventLoop()`. Every exit path must run this, or the terminal is - // left with focus-reporting / theme-query modes on and emits stray - // focus/OSC sequences after the process exits. private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } - appendStartupNotice(extra: string): void { - this.startupNotice = combineStartupNotice(this.startupNotice, extra); - } - - // Exposes background tasks owned by the event handler for host interfaces. - get backgroundTasks(): ReadonlyMap { - return this.sessionEventHandler.backgroundTasks; - } - - // Returns the currently selected session id shown by the UI. - getCurrentSessionId(): string { - return this.state.appState.sessionId; - } - - // Reports whether the transcript contains user-visible session content. - hasSessionContent(): boolean { - return this.state.transcriptEntries.length > 0; - } - - async getStartupMcpMs(): Promise { - const session = this.session; - if (session === undefined) return 0; - try { - const metrics = await session.getMcpStartupMetrics(); - return metrics.durationMs; - } catch { - return 0; - } - } - - // ========================================================================= - // Layout / Editor Setup - // ========================================================================= - - // Mounts the root TUI containers in their rendering order. private buildLayout(): void { const { ui } = this.state; ui.clear(); @@ -727,7 +588,6 @@ export class KimiTUI { slashCommands.dispatchInput(this, text); } - // Sends regular user input after validating model and media support. sendNormalUserInput(text: string): void { if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); @@ -753,7 +613,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Checks whether the current model can accept attached media. private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -775,7 +634,6 @@ export class KimiTUI { return true; } - // Tests the active model's advertised capability list. private supportsCurrentModelCapability(capability: string): boolean { const capabilities = this.state.appState.availableModels[this.state.appState.model]?.capabilities; @@ -783,7 +641,19 @@ export class KimiTUI { return capabilities.includes(capability); } - // Persists a submitted input line and mirrors it into editor history. + private async loadPersistedInputHistory(): Promise { + try { + const file = getInputHistoryFile(this.state.appState.workDir); + const entries = await loadInputHistory(file); + for (const entry of entries) { + this.state.editor.addToHistory(entry.content); + } + this.lastHistoryContent = entries.at(-1)?.content; + } catch { + // best-effort + } + } + private async persistInputHistory(text: string): Promise { const trimmed = text.trim(); if (trimmed.length === 0) return; @@ -798,7 +668,6 @@ export class KimiTUI { } } - // Pops the most recent queued message back into the editor. recallLastQueued(): string | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; @@ -810,7 +679,6 @@ export class KimiTUI { // Session Requests / Queues // ========================================================================= - // Adds a message to the queue for delivery after current work finishes. private enqueueMessage(text: string, options?: SendMessageOptions): void { this.state.queuedMessages.push({ text, @@ -824,7 +692,6 @@ export class KimiTUI { this.track('input_queue'); } - // Resets request-scoped state before submitting work to the active session. beginSessionRequest(): void { this.streamingUI.setTurnId(undefined); this.streamingUI.resetLiveText(); @@ -842,14 +709,12 @@ export class KimiTUI { }); } - // Ends a failed session request and renders the failure to the transcript. failSessionRequest(message: string): void { this.setAppState({ streamingPhase: 'idle' }); this.resetLivePane(); this.showError(message); } - // Sends a queued message after restoring the agent target captured at enqueue time. sendQueuedMessage(session: Session, item: QueuedMessage): void { this.harness.interactiveAgentId = item.agentId ?? MAIN_AGENT_ID; this.sendMessageInternal(session, item.text, { @@ -858,7 +723,6 @@ export class KimiTUI { }); } - // Appends the user message and sends the prompt to the session immediately. private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { const imageAttachmentIds = options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 @@ -882,7 +746,6 @@ export class KimiTUI { }); } - // Starts a skill activation turn on the session. sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { this.beginSessionRequest(); void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { @@ -891,7 +754,6 @@ export class KimiTUI { }); } - // Sends a message now or queues it when the session is busy. private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { if ( this.deferUserMessages || @@ -904,7 +766,6 @@ export class KimiTUI { this.sendMessageInternal(session, input, options); } - // Sends steering input into an active stream or falls back to normal prompts. steerMessage(session: Session, input: string[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { for (const part of input) { @@ -936,10 +797,36 @@ export class KimiTUI { } // ========================================================================= - // State Helpers + // State & Accessors // ========================================================================= - // Applies app-state changes and refreshes dependent UI surfaces. + appendStartupNotice(extra: string): void { + this.startupNotice = combineStartupNotice(this.startupNotice, extra); + } + + get backgroundTasks(): ReadonlyMap { + return this.sessionEventHandler.backgroundTasks; + } + + getCurrentSessionId(): string { + return this.state.appState.sessionId; + } + + hasSessionContent(): boolean { + return this.state.transcriptEntries.length > 0; + } + + async getStartupMcpMs(): Promise { + const session = this.session; + if (session === undefined) return 0; + try { + const metrics = await session.getMcpStartupMetrics(); + return metrics.durationMs; + } catch { + return 0; + } + } + setAppState(patch: Partial): void { if (!hasPatchChanges(this.state.appState, patch)) return; const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; @@ -951,7 +838,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Applies live-pane changes and refreshes activity presentation. patchLivePane(patch: Partial): void { if (!hasPatchChanges(this.state.livePane, patch)) return; Object.assign(this.state.livePane, patch); @@ -959,7 +845,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Restores the live pane to its initial idle state. resetLivePane(): void { this.state.livePane = { ...INITIAL_LIVE_PANE }; this.updateActivityPane(); @@ -970,7 +855,6 @@ export class KimiTUI { // Session Runtime // ========================================================================= - // Returns the active session or raises the standard no-session error. requireSession(): Session { if (this.session === undefined) { throw new Error(NO_ACTIVE_SESSION_MESSAGE); @@ -978,7 +862,6 @@ export class KimiTUI { return this.session; } - // Creates a session using the current model, known session runtime, permission, and plan state. private async createSessionFromCurrentState(): Promise { const model = this.state.appState.model.trim(); if (model.length === 0) { @@ -994,7 +877,6 @@ export class KimiTUI { }); } - // Replaces the active session and installs approval/question handlers. async setSession(session: Session): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); @@ -1003,7 +885,6 @@ export class KimiTUI { this.registerSessionHandlers(session); } - // Pulls runtime session status into the app state. async syncRuntimeState(session: Session = this.requireSession()): Promise { const status = await session.getStatus(); this.setAppState({ @@ -1019,21 +900,18 @@ export class KimiTUI { }); } - // Applies current permission to the active session. Plan mode is applied by - // createSession when requested, so post-create setup must not enter it again. + // Plan mode is set by createSession — do not re-enter it here. private async activateRuntime(): Promise { const session = this.requireSession(); await session.setPermission(this.state.appState.permissionMode); await this.syncRuntimeState(session); } - // Detaches and closes the current session. async closeSession(reason: string): Promise { const previous = this.unloadCurrentSession(reason); await previous?.close(); } - // Detaches session subscriptions and cancels pending interactive requests. private unloadCurrentSession(reason: string): Session | undefined { const previous = this.session; this.sessionEventUnsubscribe?.(); @@ -1054,7 +932,6 @@ export class KimiTUI { } } - // Connects session approval and question requests to local controllers. private registerSessionHandlers(session: Session): void { session.setApprovalHandler( createApprovalRequestHandler(this.approvalController, (request, response) => { @@ -1064,7 +941,6 @@ export class KimiTUI { session.setQuestionHandler(createQuestionAskHandler(this.questionController)); } - // Loads session picker rows for the current working directory. async fetchSessions(): Promise { this.state.loadingSessions = true; try { @@ -1081,12 +957,10 @@ export class KimiTUI { } } - // Syncs the process title with the current session title and id. refreshSessionTitle(): void { setProcessTitle(this.state.appState.sessionTitle, this.state.appState.sessionId); } - // Resets turn, tool, queue, and background-agent state for a session switch. resetSessionRuntime(): void { this.aborted = false; this.streamingUI.discardPending(); @@ -1104,7 +978,6 @@ export class KimiTUI { this.updateQueueDisplay(); } - // Switches to an existing session and replays its transcript. private async resumeSession(targetSessionId: string): Promise { if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); @@ -1132,7 +1005,6 @@ export class KimiTUI { return true; } - // Switches to a provided session and replays its transcript. async switchToSession(session: Session, statusMessage: string): Promise { this.resetSessionRuntime(); await this.setSession(session); @@ -1159,7 +1031,6 @@ export class KimiTUI { this.showStatus(statusMessage); } - // Creates a fresh session from current UI settings and resets the transcript. async createNewSession(): Promise { if (this.state.appState.isReplaying) { this.showError('Cannot start a new session while history is replaying.'); @@ -1201,7 +1072,6 @@ export class KimiTUI { // Transcript Rendering // ========================================================================= - // Creates the pi-tui component that renders a transcript entry. private createTranscriptComponent(entry: TranscriptEntry): Component | null { if (entry.compactionData !== undefined) { const data = entry.compactionData; @@ -1280,7 +1150,6 @@ export class KimiTUI { } } - // Stores a transcript entry and mounts its component if renderable. appendTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); const component = this.createTranscriptComponent(entry); @@ -1290,7 +1159,6 @@ export class KimiTUI { } } - // Appends an approval-result entry to the transcript. private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; const parts: string[] = []; @@ -1317,7 +1185,6 @@ export class KimiTUI { }); } - // Adds the welcome component to the transcript. private renderWelcome(): void { const welcome = new WelcomeComponent(this.state.appState, this.state.theme.colors); this.state.transcriptContainer.addChild(welcome); @@ -1328,7 +1195,6 @@ export class KimiTUI { this.state.terminal.write(deleteAllKittyImages()); } - // Clears transcript-related state and redraws the welcome view. private clearTranscriptAndRedraw(): void { this.streamingUI.discardPending(); this.state.transcriptEntries = []; @@ -1344,7 +1210,6 @@ export class KimiTUI { this.renderWelcome(); } - // Appends a status message to the transcript. showStatus(message: string, color?: string): void { this.state.transcriptContainer.addChild( new StatusMessageComponent(message, this.state.theme.colors, color), @@ -1352,7 +1217,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Appends a notice message to the transcript. showNotice(title: string, detail?: string): void { this.state.transcriptContainer.addChild( new NoticeMessageComponent(title, detail, this.state.theme.colors), @@ -1360,12 +1224,10 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Appends an error status message to the transcript. showError(message: string): void { this.showStatus(`Error: ${message}`, this.state.theme.colors.error); } - // Adds an animated login progress row to the transcript. showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { const tint = (s: string): string => chalk.hex(this.state.theme.colors.primary)(s); const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); @@ -1383,7 +1245,6 @@ export class KimiTUI { }; } - // Opens the device-code URL and renders the login authorization prompt. showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { openUrl(auth.verificationUriComplete); this.state.transcriptContainer.addChild( @@ -1403,7 +1264,6 @@ export class KimiTUI { // Panes / Presentation State // ========================================================================= - // Rebuilds the activity pane for the current live and streaming state. updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); @@ -1468,7 +1328,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Computes the effective activity-pane mode from modal and streaming state. private resolveActivityPaneMode(): EffectiveActivityPaneMode { if (this.state.activeDialog === 'session-picker') return 'hidden'; if (this.state.livePane.pendingApproval !== null) return 'hidden'; @@ -1485,7 +1344,6 @@ export class KimiTUI { return this.state.livePane.mode; } - // Re-renders the queued-message pane. updateQueueDisplay(): void { this.state.queueContainer.clear(); const queued = this.state.queuedMessages; @@ -1502,7 +1360,6 @@ export class KimiTUI { ); } - // Toggles expansion for all expandable tool-output components. toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; for (const child of this.state.transcriptContainer.children) { @@ -1513,9 +1370,7 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Toggles expansion for plan-preview cards (ExitPlanMode). Returns true - // iff at least one plan card was actually toggled so the caller can decide - // whether to consume the keystroke vs. let pi-tui's default end-of-line run. + // Returns true when at least one card toggled, so the caller can consume the keystroke. togglePlanExpansion(): boolean { const next = !this.state.planExpanded; let toggled = false; @@ -1530,7 +1385,6 @@ export class KimiTUI { return true; } - // Updates the editor border color for slash command and plan-mode context. updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); const colorToken = @@ -1541,7 +1395,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Applies a theme bundle to all stateful UI theme references. applyTheme(theme: Theme, resolved?: ResolvedTheme): void { const nextTheme = createKimiTUIThemeBundle(theme, resolved); Object.assign(this.state.theme.colors, nextTheme.colors); @@ -1553,7 +1406,6 @@ export class KimiTUI { this.state.ui.requestRender(true); } - // Starts or stops terminal theme notifications according to the user preference. refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); if (this.state.appState.theme !== 'auto') return; @@ -1563,20 +1415,17 @@ export class KimiTUI { }); } - // Stops terminal theme notifications if they were enabled for auto mode. private stopTerminalThemeTracking(): void { this.terminalThemeTrackingDispose?.(); this.terminalThemeTrackingDispose = undefined; } - // Applies a concrete terminal-reported theme while keeping the preference as auto. private applyResolvedAutoTheme(resolved: ResolvedTheme): void { if (this.state.appState.theme !== 'auto') return; if (this.state.theme.resolvedTheme === resolved) return; this.applyTheme('auto', resolved); } - // Determines whether the terminal should expose progress state. private shouldShowTerminalProgress(effectiveMode: EffectiveActivityPaneMode): boolean { if (this.state.appState.isCompacting) return true; return ( @@ -1587,14 +1436,12 @@ export class KimiTUI { ); } - // Syncs terminal progress only when the active flag changes. private syncTerminalProgress(active: boolean): void { if (this.state.terminalState.progressActive === active) return; this.state.terminal.setProgress(active); this.state.terminalState.progressActive = active; } - // Returns an activity spinner with the requested style and presentation. private ensureActivitySpinner( style: SpinnerStyle, label = '', @@ -1617,7 +1464,6 @@ export class KimiTUI { return this.state.activitySpinner.instance; } - // Stops and clears the activity spinner. private stopActivitySpinner(): void { if (this.state.activitySpinner !== null) { this.state.activitySpinner.instance.stop(); @@ -1629,7 +1475,6 @@ export class KimiTUI { // Dialogs / Selectors // ========================================================================= - // Replaces the editor with a focusable dialog or selector panel. mountEditorReplacement(panel: Component & Focusable): void { this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); @@ -1637,7 +1482,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Restores the main editor after a dialog or selector closes. restoreEditor(): void { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); @@ -1645,15 +1489,10 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Runs the first-launch migration screen, if a plan was detected pre-TUI. - // Resolves with the screen's result when the user dismisses it; the editor - // is then restored. private async runMigrationScreen(plan: MigrationPlan): Promise { const result = await new Promise((resolve) => { const screen = new MigrationScreenComponent({ plan, - // Reuse the source path detection already resolved — the single source - // of truth — rather than re-deriving it here. sourceHome: plan.sourceHome, targetHome: this.harness.homeDir, colors: this.state.theme.colors, @@ -1684,7 +1523,6 @@ export class KimiTUI { return result; } - // Shows the help panel with the current slash command list. showHelpPanel(): void { this.state.activeDialog = 'help'; this.mountEditorReplacement( @@ -1698,13 +1536,11 @@ export class KimiTUI { ); } - // Hides the help panel and returns focus to the editor. private hideHelpPanel(): void { this.state.activeDialog = null; this.restoreEditor(); } - // Loads sessions and shows the session picker. async showSessionPicker(): Promise { await this.fetchSessions(); this.mountSessionPicker(() => { @@ -1712,7 +1548,6 @@ export class KimiTUI { }); } - // Shows the startup session picker and exits when it is cancelled. private async bootstrapFromPicker(): Promise { await this.fetchSessions(); this.mountSessionPicker(() => { @@ -1721,13 +1556,11 @@ export class KimiTUI { }); } - // Hides the session picker and restores the editor. hideSessionPicker(): void { this.state.activeDialog = null; this.restoreEditor(); } - // Mounts a session picker with shared selection behavior. private mountSessionPicker(onCancel: () => void): void { this.state.activeDialog = 'session-picker'; this.mountEditorReplacement( @@ -1748,7 +1581,6 @@ export class KimiTUI { ); } - // Shows an approval panel and connects its response callback. private showApprovalPanel(payload: ApprovalPanelData): void { this.patchLivePane({ pendingApproval: { data: payload } }); notifyTerminalOnce(this.state, `approval:${payload.id}`, { @@ -1771,13 +1603,11 @@ export class KimiTUI { this.mountEditorReplacement(panel); } - // Hides the active approval panel. private hideApprovalPanel(): void { this.patchLivePane({ pendingApproval: null }); this.restoreEditor(); } - // Shows a question dialog and connects its response callback. private showQuestionDialog(payload: QuestionPanelData): void { this.patchLivePane({ pendingQuestion: { data: payload } }); notifyTerminalOnce(this.state, `question:${payload.id}`, { @@ -1801,14 +1631,9 @@ export class KimiTUI { this.mountEditorReplacement(dialog); } - // Hides the active question dialog. private hideQuestionDialog(): void { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - // ========================================================================= - // Slash Command Handlers — delegated to controllers/slash-commands.ts - // ========================================================================= - } From 69953f561e00ebf8473223f9d61f1af885b7b1f7 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 27 May 2026 20:47:35 +0800 Subject: [PATCH 26/28] refactor(tui): route TUIState field mutations through host methods Controllers may still read host.state freely, but all direct field assignments now go through setter methods on the host: setStartupReady, clearQueuedMessages, shiftQueuedMessage, pushTranscriptEntry, setExternalEditorRunning, setTasksBrowser. This prevents controllers from silently mutating shared state without KimiTUI's knowledge. --- .../src/tui/controllers/auth-flow.ts | 3 ++- .../src/tui/controllers/editor-keyboard.ts | 8 +++--- .../tui/controllers/session-event-handler.ts | 14 +++++----- .../src/tui/controllers/streaming-ui.ts | 17 ++++++------ .../src/tui/controllers/tasks-browser.ts | 9 ++++--- apps/kimi-code/src/tui/kimi-tui.ts | 27 +++++++++++++++++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 48cadf45a5..d19e6008fc 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -13,6 +13,7 @@ export interface AuthFlowHost { readonly options: KimiTUIOptions; setAppState(patch: Partial): void; + setStartupReady(): void; resetSessionRuntime(): void; setSession(session: Session): Promise; syncRuntimeState(session?: Session): Promise; @@ -47,7 +48,7 @@ export class AuthFlowController { sessionTitle: null, }); this.host.appendStartupNotice(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); - this.host.state.startupState = 'ready'; + this.host.setStartupReady(); } async activateModelAfterLogin(model: string, thinking?: boolean): Promise { diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 5f0a2cfb18..6ec35f1781 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -33,6 +33,8 @@ export interface EditorKeyboardHost { hideSessionPicker(): void; stop(exitCode?: number): Promise; handlePlanToggle(next: boolean): void; + clearQueuedMessages(): void; + setExternalEditorRunning(running: boolean): void; } export class EditorKeyboardController { @@ -140,7 +142,7 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; const text = editor.getText().trim(); const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.state.queuedMessages = []; + host.clearQueuedMessages(); const parts: string[] = []; for (const q of queuedTexts) { @@ -262,7 +264,7 @@ export class EditorKeyboardController { this.host.showError('No editor configured. Set $VISUAL / $EDITOR, or run /editor .'); return; } - state.externalEditorRunning = true; + this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); state.ui.stop(); await new Promise((resolve) => { @@ -283,7 +285,7 @@ export class EditorKeyboardController { state.ui.start(); state.ui.setFocus(state.editor); state.ui.requestRender(true); - state.externalEditorRunning = false; + this.host.setExternalEditorRunning(false); } } } 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 6080cff976..b3212ebbe0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -87,6 +87,7 @@ export interface SessionEventHost { showNotice(title: string, detail?: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; + shiftQueuedMessage(): QueuedMessage | undefined; readonly tasksBrowserController: TasksBrowserController; } @@ -669,14 +670,11 @@ export class SessionEventHandler { streamingPhase: 'idle', }); this.host.resetLivePane(); - if (state.queuedMessages.length > 0) { - const [next, ...rest] = state.queuedMessages; - state.queuedMessages = rest; - if (next !== undefined) { - setTimeout(() => { - sendQueued(next); - }, 0); - } + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); } } else { this.host.setAppState({ isCompacting: false }); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index c3dfffad9d..da81cf5ae7 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -32,6 +32,8 @@ export interface StreamingUIHost { updateQueueDisplay(): void; requireSession(): Session; deferUserMessages: boolean; + shiftQueuedMessage(): QueuedMessage | undefined; + pushTranscriptEntry(entry: TranscriptEntry): void; } export class StreamingUIController { @@ -414,16 +416,13 @@ export class StreamingUIController { this.resetToolCallState(); this._currentTurnId = undefined; - if (state.queuedMessages.length > 0) { - const [next, ...rest] = state.queuedMessages; - state.queuedMessages = rest; + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); - if (next !== undefined) { - setTimeout(() => { - sendQueued(next); - }, 0); - } + setTimeout(() => { + sendQueued(next); + }, 0); return; } @@ -455,7 +454,7 @@ export class StreamingUIController { state.theme.colors, ); this._streamingBlock = { component, entry }; - state.transcriptEntries.push(entry); + this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 03eaf58038..0da21d4be5 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -8,7 +8,7 @@ import type { CustomEditor } from '../components/editor/custom-editor'; export interface TasksBrowserHost { readonly state: { - tasksBrowser: TasksBrowserState | undefined; + readonly tasksBrowser: TasksBrowserState | undefined; readonly theme: { readonly colors: ColorPalette }; readonly terminal: ProcessTerminal; readonly ui: TUI; @@ -17,6 +17,7 @@ export interface TasksBrowserHost { readonly backgroundTasks: ReadonlyMap; readonly session: Session | undefined; showError(msg: string): void; + setTasksBrowser(value: TasksBrowserState | undefined): void; } export type TasksBrowserState = { @@ -92,7 +93,7 @@ export class TasksBrowserController { void this.refresh({ silent: true }); }, 1000); - state.tasksBrowser = { + this.host.setTasksBrowser({ component, savedChildren, filter, @@ -104,7 +105,7 @@ export class TasksBrowserController { flashTimer: undefined, pollTimer, viewer: undefined, - }; + }); if (selectedTaskId !== undefined) { this.loadTail(selectedTaskId); @@ -123,7 +124,7 @@ export class TasksBrowserController { for (const child of browser.savedChildren) { state.ui.addChild(child); } - state.tasksBrowser = undefined; + this.host.setTasksBrowser(undefined); state.ui.setFocus(state.editor); state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dec022d7cc..0d82e3fb8d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -800,6 +800,33 @@ export class KimiTUI { // State & Accessors // ========================================================================= + setStartupReady(): void { + this.state.startupState = 'ready'; + } + + clearQueuedMessages(): void { + this.state.queuedMessages = []; + } + + shiftQueuedMessage(): QueuedMessage | undefined { + if (this.state.queuedMessages.length === 0) return undefined; + const [first, ...rest] = this.state.queuedMessages; + this.state.queuedMessages = rest; + return first; + } + + pushTranscriptEntry(entry: TranscriptEntry): void { + this.state.transcriptEntries.push(entry); + } + + setExternalEditorRunning(running: boolean): void { + this.state.externalEditorRunning = running; + } + + setTasksBrowser(value: TUIState['tasksBrowser']): void { + this.state.tasksBrowser = value; + } + appendStartupNotice(extra: string): void { this.startupNotice = combineStartupNotice(this.startupNotice, extra); } From ae1eebaf28836486d3412beabae5e7767bfd697c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 14:06:32 +0800 Subject: [PATCH 27/28] chore(tui): drop merge analysis docs, add changeset, fix 2 lint errors - delete docs/refactor-kimi-tui-{analysis,merge-plan}.md (working notes) - add changeset for the kimi-tui split refactor - session-event-handler.ts: drop unused `state` destructure in finishCompaction - editor-keyboard.ts: void-mark fire-and-forget session.cancel() promise --- .changeset/refactor-tui-kimi-tui-split.md | 5 + .../src/tui/controllers/editor-keyboard.ts | 2 +- .../tui/controllers/session-event-handler.ts | 1 - docs/refactor-kimi-tui-analysis.md | 155 ------------------ docs/refactor-kimi-tui-merge-plan.md | 141 ---------------- 5 files changed, 6 insertions(+), 298 deletions(-) create mode 100644 .changeset/refactor-tui-kimi-tui-split.md delete mode 100644 docs/refactor-kimi-tui-analysis.md delete mode 100644 docs/refactor-kimi-tui-merge-plan.md diff --git a/.changeset/refactor-tui-kimi-tui-split.md b/.changeset/refactor-tui-kimi-tui-split.md new file mode 100644 index 0000000000..54e0ca6b71 --- /dev/null +++ b/.changeset/refactor-tui-kimi-tui-split.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refactor TUI: split the `kimi-tui.ts` God-class into a thin host plus focused controllers (auth-flow, editor-keyboard, session-event-handler, session-replay, streaming-ui, tasks-browser) and per-domain slash-command modules (auth, config, info, plugins, prompts, session). Pure internal refactor — no user-visible behavior changes. diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 6ec35f1781..75473d7ae2 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -214,7 +214,7 @@ export class EditorKeyboardController { } private cancelCurrentStream(): void { - this.host.session?.cancel(); + void this.host.session?.cancel(); } private cancelCurrentCompaction(): 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 f6a5780eff..0e310ba912 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -670,7 +670,6 @@ export class SessionEventHandler { } private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { - const { state } = this.host; const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { this.host.setAppState({ diff --git a/docs/refactor-kimi-tui-analysis.md b/docs/refactor-kimi-tui-analysis.md deleted file mode 100644 index 783efca785..0000000000 --- a/docs/refactor-kimi-tui-analysis.md +++ /dev/null @@ -1,155 +0,0 @@ -# KimiTUI 重构分支 — 合并 main 分析 - -> 分支:`refactor-kimi-tui`(26 commits) -> 分叉点:`ce420bf` (refactor(tui): unify resume replay rendering #88) -> 分析日期:2026-05-27 - ---- - -## 一、分支概况 - -本分支将 `kimi-tui.ts` 从 6160 行的 God-class 拆分为 1666 行协调器 + 6 个 Controller + 8 个 Command 模块。详见下方「架构变化」章节。 - -## 二、main 上的新增变更(分叉后 12 个 commit) - -### 涉及 TUI 的 6 个 commit(核心冲突来源) - -| Commit | 功能 | 改动位置(基于 main 原始结构) | -|---|---|---| -| `73c4232` fix: show original session command after fork (#103) | fork 后提示返回原 session 的命令 | `kimi-tui.ts` — `handleForkCommand` | -| `2e8c417` fix(tui): stop thinking spinner leaking (#97) | 修复空 delta 导致 thinking spinner 泄漏 | `kimi-tui.ts` — `flushThinkingToTranscript` + `onThinkingUpdate` | -| `5587061` feat: expose LLM stream timing events (#101) | debug 模式显示 step timing | `kimi-tui.ts` — 新增 `maybeShowDebugTiming` + `handleStepCompleted` | -| `d03f6f4` feat(tui): add /export-debug-zip (#112) | 新增 `/export-debug-zip` 命令 | `kimi-tui.ts` — 新增 `handleExportDebugZipCommand` + slash dispatch | -| `028d069` feat(tui): add /export-md (#113) | 新增 `/export-md` 命令 + `utils/export-markdown.ts` | `kimi-tui.ts` — 新增 `handleExportMdCommand` + slash dispatch | -| `2c7a8cc` feat(tui): expand paste markers on second paste (#116) | 编辑器二次粘贴展开 paste marker | `components/editor/custom-editor.ts`(无冲突) | - -### 不涉及 TUI 的 6 个 commit(自动合并,无冲突) - -| Commit | 范围 | -|---|---| -| `6f55f1d` fix(agent-core): route session logs exclusively to session sink | agent-core | -| `d599183` feat(export): record install source and shell environment in manifest | session export | -| `d1c381f` test(agent-core): consolidate test helpers into AgentTestContext | agent-core tests | -| `8b5065c` ci: add pkg.pr.new previews | CI | -| `b7e7404` docs: document /export-md and /export-debug-zip | docs | -| `2b74025` feat: rework permission decision policies (#26) | agent-core permission 系统重写 | - ---- - -## 三、合并冲突预判 - -运行 `git merge-tree` 结果: - -| 文件 | 状态 | -|---|---| -| `apps/kimi-code/src/tui/kimi-tui.ts` | **CONFLICT** — 内容冲突 | -| `apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts` | auto-merge(无冲突) | -| 其余 141 个文件 | auto-merge(无冲突) | - -### kimi-tui.ts 冲突详解 - -main 上在旧 `kimi-tui.ts` 中添加了以下内容,但重构分支已将对应代码搬到了不同文件: - -| main 上的改动 | 原始位置(main) | 重构后应归属的文件 | 处理方式 | -|---|---|---|---| -| `/export-md` + `/export-debug-zip` slash dispatch case | `kimi-tui.ts` L1592 (slash switch) | `commands/dispatch.ts` | 手动移入 dispatch + 新建 `commands/export.ts` 或放入 `commands/session.ts` | -| `handleExportMdCommand` 方法体 (~40 行) | `kimi-tui.ts` L5526+ | `commands/session.ts` 或新建 `commands/export.ts` | 手动搬迁 | -| `handleExportDebugZipCommand` 方法体 (~25 行) | `kimi-tui.ts` L5526+ | 同上 | 手动搬迁 | -| `flushThinkingToTranscript` bug fix (移除 early return) | `kimi-tui.ts` L1937 | `controllers/streaming-ui.ts` | 手动 apply 修复逻辑 | -| `onThinkingUpdate` guard (空 text + 无组件时 skip) | `kimi-tui.ts` L3728 | `controllers/streaming-ui.ts` | 手动 apply | -| `maybeShowDebugTiming` 新方法 + `handleStepCompleted` 调用 | `kimi-tui.ts` L3003/3041 | `controllers/session-event-handler.ts` | 手动搬迁 | -| `handleForkCommand` 返回提示文案改动 | `kimi-tui.ts` L5526 | `commands/session.ts` | 手动 apply 文案变更 | -| 新 import (`mkdir`, `writeFile`, `pathToFileURL`, `toTerminalHyperlink`, `detectInstallSource`, `detectShellEnvironment`, `buildExportMarkdown`, `formatStepDebugTiming`) | `kimi-tui.ts` 顶部 | 分别加到对应目标文件的 import 区 | 手动处理 | -| `commands/registry.ts` 新增 export-md / export-debug-zip 注册 | `commands/registry.ts` | 同文件(自动合并成功) | 无需处理 | -| `components/editor/custom-editor.ts` paste marker 扩展 | `custom-editor.ts` | 同文件(不涉及重构) | 无需处理 | -| `components/messages/plan-box.ts` 提取 `toTerminalHyperlink` | `plan-box.ts` | 同文件(不涉及重构) | 无需处理 | -| `utils/export-markdown.ts` 新文件 (241 行) | 新文件 | 新文件(自动合并) | 无需处理 | - -### 非 TUI 的重大变更(无冲突但需注意) - -- **Permission policies 重写** (`2b74025`):agent-core 的 permission 系统从旧的 monolithic `check-rules.ts` + `policy.ts` 重写为 12 个独立 policy 模块。TUI 层不直接依赖这些内部类型,但 `PermissionMode` 行为可能有细微变化,合并后需跑完整测试。 - ---- - -## 四、合并策略建议 - -### 推荐:merge origin/main → 手动解决 kimi-tui.ts 冲突 - -1. **`git merge origin/main`** — 触发合并 -2. **解决 `kimi-tui.ts` 冲突** — 这个文件的冲突本质是:main 往旧文件加了新功能,重构分支把旧文件拆碎了。git 无法自动匹配。具体操作: - - 接受重构分支侧的 kimi-tui.ts 结构(丢弃 main 在旧结构上的 hunks) - - 将 main 的 6 个功能逐一搬入重构后的正确位置(见上表) -3. **处理新文件** — `utils/export-markdown.ts` 会自动带入,无需手动 -4. **跑测试** — 确保 export-md / export-debug-zip / thinking spinner fix / debug timing / fork 提示 / paste marker 全部正常 - -### 工作量估算 - -| 任务 | 预计耗时 | -|---|---| -| 冲突解决(kimi-tui.ts 接受 ours + 手动搬迁 main 功能) | 30-60 min | -| 新增 export 命令搬入 commands/ | 15-20 min | -| thinking/debug-timing fix 应用到 controllers/ | 10-15 min | -| fork 提示文案 + 测试修复 | 5-10 min | -| 全量测试验证 | 10-20 min | -| **合计** | **~1.5-2 小时** | - ---- - -## 五、重构架构概览(供合并时参考) - -### 文件结构 - -``` -kimi-tui.ts (1666 行, 协调器 / Host) -├── controllers/ -│ ├── session-event-handler.ts (921 行) — SDK Event 分发与处理 -│ ├── streaming-ui.ts (749 行) — 流式消息渲染管线 -│ ├── session-replay.ts (467 行) — 历史会话回放 -│ ├── tasks-browser.ts (440 行) — 任务面板 -│ ├── editor-keyboard.ts (291 行) — 编辑器键盘控制 -│ └── auth-flow.ts (133 行) — 认证流程 -├── commands/ -│ ├── dispatch.ts (261 行) — 输入分发(slash vs 普通消息) -│ ├── config.ts (383 行) — /model, /yolo, /theme 等配置命令 -│ ├── auth.ts (349 行) — /login, /logout, /connect 命令 -│ ├── info.ts (185 行) — /usage, /status, /mcp 信息命令 -│ ├── prompts.ts (183 行) — /init, /feedback 等提示命令 -│ ├── session.ts (105 行) — /fork, /title 会话命令 -│ └── index.ts / registry.ts / parse.ts / resolve.ts / types.ts / skills.ts -├── tui-state.ts (100 行) — TUIState 接口 + 工厂函数 -├── types.ts (+30 行) — 新增启动相关类型 -└── utils/startup.ts (15 行) -``` - -### Host 接口通信 - -每个 Controller 定义 `*Host` 接口,`KimiTUI` 以鸭子类型实现: - -| Controller | Host 接口 | 关键依赖 | -|---|---|---| -| StreamingUIController | `StreamingUIHost` | state, session, setAppState, patchLivePane, pushTranscriptEntry | -| SessionEventHandler | `SessionEventHost` | state, session, streamingUI, sessionReplay, updateActivityPane | -| SessionReplayRenderer | `SessionReplayHost` | state, session, streamingUI, pushTranscriptEntry | -| TasksBrowserController | `TasksBrowserHost` | state, requestRender | -| AuthFlowController | `AuthFlowHost` | harness, state, startupNotice | -| EditorKeyboardController | `EditorKeyboardHost` | state, session, sendMessage, cancelInFlight | -| SlashCommand dispatch | `SlashCommandHost` | state, harness, session, 各 controller 引用 | - -### AppState 字段变化 - -重构分支移除了 `yolo`(用 `permissionMode` 推导)和 `isStreaming`(用 `streamingPhase` 推导)。合并 main 时注意 main 的新代码是否引用了这两个字段。 - ---- - -## 六、合并后 Checklist - -- [ ] `/export-md` 命令正常工作 -- [ ] `/export-debug-zip` 命令正常工作 -- [ ] thinking spinner 不再泄漏(空 delta 场景) -- [ ] debug timing 在 `KIMI_CODE_DEBUG=1` 下显示 -- [ ] fork 后显示返回原 session 的提示 -- [ ] 二次粘贴 paste marker 展开正常 -- [ ] permission policies 重写后权限行为正常 -- [ ] `AppState` 无 `yolo` / `isStreaming` 引用残留 -- [ ] 全量 `pnpm test` 通过 -- [ ] TypeScript 编译无错误 diff --git a/docs/refactor-kimi-tui-merge-plan.md b/docs/refactor-kimi-tui-merge-plan.md deleted file mode 100644 index 3ce9c1e982..0000000000 --- a/docs/refactor-kimi-tui-merge-plan.md +++ /dev/null @@ -1,141 +0,0 @@ -# KimiTUI 重构分支 — 合并 main 计划(2026-05-28) - -> 分支:`refactor-kimi-tui` -> 上次合并点:`27749de`(已合并 main 至 `2c7a8cc`) -> 目标合并点:`50251a1`(main 当前 HEAD) -> 新增待合并 commits:4 个 - ---- - -## 一、分支结构差异(重构后 vs main) - -| 模块 | main 结构 | refactor 结构 | -|---|---|---| -| `tui/kimi-tui.ts` | 6695 行 God-class(含全部 controller / 命令逻辑) | 1666 行薄协调器,仅做依赖编织和 host 接口 | -| `tui/controllers/` | 不存在 | 6 个 controller:`auth-flow.ts`、`editor-keyboard.ts`、`session-event-handler.ts`、`session-replay.ts`、`streaming-ui.ts`、`tasks-browser.ts` | -| `tui/commands/` | 6 个文件(`index/parse/registry/resolve/skills/types`) | 12 个文件,新增 6 个领域命令模块:`auth.ts`、`config.ts`、`dispatch.ts`、`info.ts`、`prompts.ts`、`session.ts` | -| `tui/tui-state.ts` | 顶层 mutable 字段(`yolo`、`isStreaming` 等散落) | 字段精简,可变状态收敛到对应 controller,host 提供 mutation 方法 | -| `tui/types.ts` | 含 `TUIStartupOptions`、`KimiTUIOptions`、`PendingExit`、`LoginProgressSpinnerHandle` 等启动期类型 | 已移除(迁到对应模块) | -| `tui/utils/startup.ts` | 包含 `combineStartupNotice`、`isOAuthLoginRequiredError` | 已删除(去重后内联) | - -合并的核心矛盾:main 上的所有 TUI 修改都直接打在 `kimi-tui.ts` 的对应方法上,而这些方法在重构分支已被搬到 controller / command 模块。所以 cherry-pick 必然冲突,需要逐个把 patch「翻译」到新位置。 - ---- - -## 二、main 新增的 4 个 commit 与影响范围 - -### 2.1 `ebf6e81` feat: add plugin manager and official plugins (#119) - -**功能**:插件管理器(manager + manifest + archive + store + 远端 marketplace),TUI `/plugins` 命令,官方 datasource 插件。 - -**改动文件量**:约 50 个文件,3500+ 行新增。 - -| 类别 | 文件 | 重构分支位置/动作 | -|---|---|---| -| TUI 入口 `kimi-tui.ts` | 新增 import、`/plugins` case 分发、`showPluginsPicker`、`handlePluginsCommand`、`showPluginMarketplace`、`showPluginRemoveConfirm` 等 ~10 个私有方法(共 +381 行) | **拆**:分发入口写到 `commands/dispatch.ts`(参考已有 `/mcp`、`/editor` 模式);选择器/弹窗逻辑作为一个新 command 模块 `commands/plugins.ts`(独立成文件,避免堆回 `kimi-tui.ts`) | -| `commands/registry.ts` | 注册 `plugins` 内建 slash command(+7 行) | **直接 apply**:refactor 分支的 registry 同位置可吃 | -| `components/dialogs/plugins-selector.ts` | 新建,4 个 Component(Overview / Marketplace / Mcp / RemoveConfirm,603 行) | **直接 apply**:纯新增文件,无冲突 | -| `components/messages/plugins-status-panel.ts` | 新建,`buildPluginsInfoLines` / `buildPluginsListLines`(128 行) | **直接 apply**:纯新增 | -| `components/dialogs/choice-picker.ts` | 新增 `notice` 字段;`onSelect` 同时接受空格键 | **直接 apply**:重构分支未改动该文件 | -| `utils/plugin-marketplace.ts` | 新建(213 行) | **直接 apply** | -| `cli/commands.ts` + `cli/sub/plugin-run-node.ts` + `main.ts` | 新增隐藏命令 `__plugin_run_node`,用于插件子进程入口 | **直接 apply**:与 TUI 重构无关 | -| `constant/app.ts` | 新增 `KIMI_CODE_PLUGIN_MARKETPLACE_URL*` 常量 | **直接 apply** | -| `package.json` + `scripts/dev*.mjs` | `dev` 改用 `scripts/dev.mjs`(并跑 marketplace server);新增 build/dev marketplace 脚本 | **直接 apply** | -| `packages/agent-core/src/plugin/**` | manager / manifest / archive / store / source / types(约 1200 行) | **直接 apply**:纯新增 | -| `packages/agent-core/src/agent/injection/plugin-session-start.ts` | 新建,session 启动时注入 plugin info | **直接 apply** | -| `packages/agent-core/src/rpc/core-api.ts` + `core-impl.ts` | 暴露 plugin RPC | **直接 apply** | -| `packages/agent-core/src/skill/{registry,scanner,types}.ts` | skill scanner 支持 plugin 源 | **直接 apply** | -| `packages/agent-core/src/agent/{index,tool/index}.ts` + `errors/codes.ts` | plugin capability 注入接线 | **直接 apply** | -| `packages/node-sdk/src/{rpc,session,types}.ts` | listPlugins / 等 SDK 方法(+88 行) | **直接 apply**:refactor 分支的 `kimi-tui` 通过 `this.requireSession().listPlugins()` 拉数据,依赖此 SDK | -| `plugins/official/kimi-datasource/**` + `plugins/marketplace.json` | 官方插件源码与索引 | **直接 apply** | -| 测试:`test/plugin/**`、`test/utils/plugin-marketplace.test.ts`、`test/tui/components/dialogs/plugins-selector.test.ts`、`test/tui/kimi-tui-message-flow.test.ts`(+290 行) | | message-flow 测试可能要按新 host 拆分调整;其余直接 apply | -| 文档:`docs/{en,zh}/customization/plugins.md` + `docs/.vitepress/config.ts` 等 | | **直接 apply** | - -**关键迁移工作(不能机械 cherry-pick 的部分)**: -1. `kimi-tui.ts` 中的 `/plugins` 分发:refactor 已经把 dispatch 表交给 `commands/dispatch.ts`,必须接入到那里。 -2. 10 个 `showPlugins*` / `handlePlugins*` 私有方法:建议**新建 `commands/plugins.ts`**,按照 `commands/auth.ts`、`commands/info.ts` 同款签名(接受 host 接口、返回 Promise),并通过 host 暴露 `mountEditorReplacement` / `restoreEditor` / `requireSession` / `showError` 等已有方法。 -3. host 接口(在 `kimi-tui.ts` 中的 `private buildCommandsHost()` 之类位置)需要补充供 plugins.ts 使用的方法(如果还没有)。 - ---- - -### 2.2 `50251a1` fix(approval): show file content/diff and open full-screen preview on ctrl+e (#139) - -**功能**: -- approval-panel 显示 file IO 的内容 / diff; -- ctrl+e 切换为全屏 `ApprovalPreviewViewer`(不再就地展开)。 - -**改动文件量**:7 个文件,~700 行。 - -| 类别 | 文件 | 重构分支位置/动作 | -|---|---|---| -| `components/dialogs/approval-preview.ts` | 新建(250 行),独立 Viewer 组件 | **直接 apply**:纯新增 | -| `components/dialogs/approval-panel.ts` | 移除内部 `expanded` toggle;`onExpand` 回调签名增加 `block` 参数 | **直接 apply**:refactor 分支未触碰该组件 | -| `reverse-rpc/approval/adapter.ts` | `file_io` display 提升 content/before/after 为 `file_content` / `diff` block | **直接 apply**:refactor 分支未触碰 | -| `kimi-tui.ts` — `activeApprovalPanel`、`approvalPreview` 两个新字段;`showApprovalPanel` 多传一个 `onPreview` 回调;新增 `openApprovalPreview` / `closeApprovalPreview`;`hideApprovalPanel` 先收起 preview(+63 行) | **手工迁移**:refactor 分支的 `showApprovalPanel` / `hideApprovalPanel` 仍在 `kimi-tui.ts:1611-1636`,**位置正确**,可以原样补 patch;只需注意:
① 两个新私有字段;
② `state.ui.children` / `state.ui.clear` / `state.ui.addChild` / `state.ui.setFocus` / `state.ui.requestRender` 调用必须走 host 方法(按本分支「TUIState mutation 走 host」的约束,参见 commit `69953f5`)—— 检查这些方法是否已存在,缺则补 host 方法。 | -| 测试:`test/tui/components/dialogs/approval-panel.test.ts`(覆盖更新)+ `approval-preview.test.ts`(新建)+ `test/tui/reverse-rpc/approval-adapter.test.ts`(新建) | | **直接 apply** | - ---- - -### 2.3 `16e881e` docs(changelog): sync 0.4.0 from apps/kimi-code/CHANGELOG.md (#125) - -只动 `docs/{en,zh}/release-notes/changelog.md`。**直接 apply**,无冲突。 - -### 2.4 `fa114c1` ci: release packages (#93) - -changeset 自动 release:删除 13 个 `.changeset/*.md`、更新各 package 的 `CHANGELOG.md` / `package.json` 版本号。 -- **直接 apply**:均在 `packages/*` 和 `apps/kimi-code` 根目录,与 TUI 重构无关。 -- ⚠️ 注意:refactor 分支自分叉以来不应有自己新增的 changeset(确认下;如有,要保留)。 - ---- - -## 三、合并策略推荐 - -**方案 A — 单个 merge commit(推荐)** - -``` -git checkout refactor-kimi-tui -git merge main -# 解决 kimi-tui.ts 等冲突,按上文规则把 plugin / approval-preview patch 翻译到新位置 -git merge --continue -``` - -理由:本分支已用过这个方式合并 12 个 commit(`27749de`),团队习惯一致;4 个 commit 一次性解决,避免反复打开同一文件。 - -**方案 B — 拆成 2 个 merge 阶段** - -适合需要把 plugin 大改动单独 review 的场景: - -1. 先合 `ebf6e81`(plugin 主体),单独提 PR; -2. 再合剩余 3 个 commit。 - -不推荐,理由:CHANGELOG/版本号 commit 与 plugin commit 互相引用,拆开反而要解二次冲突。 - ---- - -## 四、冲突点逐文件清单 - -合并执行后,预计 `git status` 中的 UU 文件: - -| 文件 | 冲突原因 | 处理 | -|---|---|---| -| `apps/kimi-code/src/tui/kimi-tui.ts` | plugin 入口 +381 行、approval-preview +63 行;refactor 分支结构已变 | 手工迁移(见 §2.1、§2.2) | -| `apps/kimi-code/src/tui/commands/registry.ts` | 同位置新增 `plugins` 条目 | 简单 take both | -| `apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts` | main 新增 290 行 plugin 相关测试;refactor 重写过 host fixture | 逐 case 迁移,对齐新 host 形态 | - -预计**纯新增、零冲突**的文件:约 45 个(plugin 子系统、approval-preview、docs、changelog、官方插件)。 - ---- - -## 五、执行检查清单 - -- [ ] `git merge main` 并解决上述 3 处冲突 -- [ ] 新建 `apps/kimi-code/src/tui/commands/plugins.ts`,把 plugin UI 方法搬过去 -- [ ] 在 `kimi-tui.ts` 的 host 接口里补 `mountEditorReplacement`/`restoreEditor`/... 暴露给 plugins.ts(如缺) -- [ ] 把 approval-preview 的两个新字段和两个新方法补回 `kimi-tui.ts`,确认 `state.ui` mutation 已走 host 方法(commit `69953f5` 的约束) -- [ ] `pnpm -F kimi-code typecheck` -- [ ] `pnpm -F kimi-code test` -- [ ] `pnpm -F agent-core test`(plugin 子系统的 4 个 test 套件) -- [ ] 手动验:`/plugins` 打开,浏览 marketplace、安装、卸载 -- [ ] 手动验:approval-panel 上 Write / Edit 显示内容/diff,ctrl+e 打开全屏 viewer,ESC 返回保留选中态 -- [ ] 检查 refactor 分支是否有自己的 changeset,若有保留 -- [ ] lint / format / test 变动文件 From 39e6289088be0a0a09e7ce2e33d3a0447edd8caa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 14:10:44 +0800 Subject: [PATCH 28/28] chore(changeset): simplify wording --- .changeset/refactor-tui-kimi-tui-split.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/refactor-tui-kimi-tui-split.md b/.changeset/refactor-tui-kimi-tui-split.md index 54e0ca6b71..e35e8038b8 100644 --- a/.changeset/refactor-tui-kimi-tui-split.md +++ b/.changeset/refactor-tui-kimi-tui-split.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Refactor TUI: split the `kimi-tui.ts` God-class into a thin host plus focused controllers (auth-flow, editor-keyboard, session-event-handler, session-replay, streaming-ui, tasks-browser) and per-domain slash-command modules (auth, config, info, plugins, prompts, session). Pure internal refactor — no user-visible behavior changes. +Refactor TUI code structure.