From dad2b87ceeb054204027709751f72baadf04b708 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 28 May 2026 14:14:20 +0800 Subject: [PATCH] refactor(tui): split kimi-tui God-class into controllers and command modules (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(tui): extract TasksBrowserController from KimiTUI 将 /tasks 浏览器相关的 16 个方法(~450 行)从 KimiTUI 提取到独立的 TasksBrowserController。KimiTUI 通过 Host 接口传 this 给 controller, 保留 4 个一行委托方法供外部调用点使用。 kimi-tui.ts: 6160 → 5711 行 (-449) * refactor(tui): extract StreamingUIController from KimiTUI 将 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) * refactor(tui): extract SessionEventHandler from KimiTUI 将 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) * refactor(tui): extract SessionReplayRenderer from KimiTUI 将 Session Replay 的 21 个方法(~410 行)从 KimiTUI 提取到 SessionReplayRenderer。包含会话历史回放水合(snapshot hydration)、 replay record 渲染、thinking/assistant/tool call 回放、skill 激活、 权限变更、审批结果、background task 通知等逻辑。 kimi-tui.ts: 4316 → 3911 行 (-405) * refactor(tui): extract slash command handlers from KimiTUI 将 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) * refactor(tui): extract AuthFlowController from KimiTUI 将 Auth / Model Bootstrap 的 6 个方法(~115 行)从 KimiTUI 提取到 AuthFlowController。包含 refreshAvailableModels、 enterLoginRequiredStartupState、activateModelAfterLogin、 clearActiveSessionAfterLogout、refreshConfigAfterLogin、 refreshConfigAfterLogout。 kimi-tui.ts: 3163 → 3063 行 (-100) * refactor(tui): clean up dead imports after controller extraction 移除 kimi-tui.ts 和 controller 文件中因代码提取而残留的 55+ 个 无用 import(SDK event types、OAuth 工具函数、catalog 类型等)。 kimi-tui.ts: 3063 → 3005 行 (-58) * refactor(tui): remove delegate methods and direct controller references Phase A: 删除 14 个零调用者的纯委托方法 Phase B: 内联 8 个少量调用者委托(调用处直接引用 controller) Phase C: controller 之间通过 host 上的 controller 引用直接协作, 不再绕 kimi-tui 中转(SessionEventHandler → tasksBrowserController, SlashCommands → authFlow) kimi-tui.ts: 3005 → 2956 行 (-49) * refactor(tui): move pickers, config apply, info commands to slash-commands controller 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) * refactor(tui): final cleanup — remove last delegates and dead imports 删除 finalizeTurn 和 activateModelAfterLogin 委托方法, slash-commands 直接通过 host.streamingUI / host.authFlow 调用。 清理 15 个因搬运产生的 dead import。 kimi-tui.ts: 2566 → 2545 行 (-21) * refactor(tui): clean up dead imports and empty import blocks 清理第二轮搬运后残留的 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) * refactor(tui): inline last auth delegate methods 将 refreshAvailableModels 和 enterLoginRequiredStartupState 的 调用方改为直接访问 this.authFlow.*,删除最后两个委托方法。 kimi-tui.ts: 2517 → 2510 行 (-7) * refactor(tui): move slash command dispatch to slash-commands controller 将 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) * refactor(tui): clean up 38 dead imports and 3 dead interfaces 移除因前几轮搬运残留的 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) * refactor(tui): deduplicate combineStartupNotice and isOAuthLoginRequiredError 将 combineStartupNotice(kimi-tui.ts + auth-flow.ts 各一份)和 isOAuthLoginRequiredError 统一到 constant/kimi-tui.ts,两边改为 import。 kimi-tui.ts: 2311 → 2296 行 (-15) * refactor(tui): move startup utils out of constant/kimi-tui combineStartupNotice 和 isOAuthLoginRequiredError 是工具函数不是常量, 从 constant/kimi-tui.ts 移到 utils/startup.ts。 * refactor(tui): remove last session event/replay delegate methods 删除 handleEvent、startSessionEventSubscription、 hydrateTranscriptFromReplay 三个委托方法。 kimi-tui 内部和 auth-flow controller 改为直接访问 sessionEventHandler.startSubscription() 和 sessionReplay.hydrateFromReplay()。 测试改为通过 driver.sessionEventHandler 直接调用。 kimi-tui.ts: 2296 → 2278 行 (-18) * refactor(tui): reduce TUIState fields by removing redundancy and merging pairs 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. * 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. * 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. * refactor(tui): move command handlers from controllers/ to commands/ 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. * 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. * 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(). * 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. * refactor(tui): clean up kimi-tui.ts — strip noise comments, reorganize sections 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. * 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. * 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 * chore(changeset): simplify wording --- .changeset/refactor-tui-kimi-tui-split.md | 5 + apps/kimi-code/src/tui/commands/auth.ts | 349 ++ apps/kimi-code/src/tui/commands/config.ts | 383 ++ apps/kimi-code/src/tui/commands/dispatch.ts | 280 + apps/kimi-code/src/tui/commands/index.ts | 40 + apps/kimi-code/src/tui/commands/info.ts | 185 + apps/kimi-code/src/tui/commands/plugins.ts | 391 ++ apps/kimi-code/src/tui/commands/prompts.ts | 183 + apps/kimi-code/src/tui/commands/session.ts | 186 + .../src/tui/controllers/auth-flow.ts | 133 + .../src/tui/controllers/editor-keyboard.ts | 291 + .../tui/controllers/session-event-handler.ts | 928 +++ .../src/tui/controllers/session-replay.ts | 467 ++ .../src/tui/controllers/streaming-ui.ts | 750 +++ .../src/tui/controllers/tasks-browser.ts | 440 ++ apps/kimi-code/src/tui/index.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 5460 +---------------- apps/kimi-code/src/tui/tui-state.ts | 100 + apps/kimi-code/src/tui/types.ts | 33 +- .../kimi-code/src/tui/utils/message-replay.ts | 6 +- apps/kimi-code/src/tui/utils/startup.ts | 15 + .../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 +- 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 | 27 +- .../test/tui/kimi-tui-message-flow.test.ts | 153 +- .../test/tui/kimi-tui-startup.test.ts | 51 +- .../kimi-code/test/tui/message-replay.test.ts | 24 +- 31 files changed, 5537 insertions(+), 5359 deletions(-) create mode 100644 .changeset/refactor-tui-kimi-tui-split.md create mode 100644 apps/kimi-code/src/tui/commands/auth.ts create mode 100644 apps/kimi-code/src/tui/commands/config.ts create mode 100644 apps/kimi-code/src/tui/commands/dispatch.ts create mode 100644 apps/kimi-code/src/tui/commands/info.ts create mode 100644 apps/kimi-code/src/tui/commands/plugins.ts create mode 100644 apps/kimi-code/src/tui/commands/prompts.ts create mode 100644 apps/kimi-code/src/tui/commands/session.ts create mode 100644 apps/kimi-code/src/tui/controllers/auth-flow.ts create mode 100644 apps/kimi-code/src/tui/controllers/editor-keyboard.ts create mode 100644 apps/kimi-code/src/tui/controllers/session-event-handler.ts create mode 100644 apps/kimi-code/src/tui/controllers/session-replay.ts create mode 100644 apps/kimi-code/src/tui/controllers/streaming-ui.ts create mode 100644 apps/kimi-code/src/tui/controllers/tasks-browser.ts create mode 100644 apps/kimi-code/src/tui/tui-state.ts create mode 100644 apps/kimi-code/src/tui/utils/startup.ts diff --git a/.changeset/refactor-tui-kimi-tui-split.md b/.changeset/refactor-tui-kimi-tui-split.md new file mode 100644 index 000000000..e35e8038b --- /dev/null +++ b/.changeset/refactor-tui-kimi-tui-split.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refactor TUI code structure. diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts new file mode 100644 index 000000000..e9e8304cc --- /dev/null +++ b/apps/kimi-code/src/tui/commands/auth.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 './prompts'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// 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/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts new file mode 100644 index 000000000..dc20cb96d --- /dev/null +++ b/apps/kimi-code/src/tui/commands/config.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'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// 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/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts new file mode 100644 index 000000000..20e1be7f5 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -0,0 +1,280 @@ +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 type { Theme } from '../theme'; +import type { ResolvedTheme } from '../theme/colors'; +import { + LLM_NOT_SET_MESSAGE, +} from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { parseSlashInput } from './parse'; +import { + resolveSlashCommandInput, + slashBusyMessage, +} 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'; +import { + handleCompactCommand, + handleEditorCommand, + handleModelCommand, + handlePlanCommand, + handleThemeCommand, + handleYoloCommand, + showModelPicker, + showPermissionPicker, + showSettingsSelector, +} from './config'; +import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handlePluginsCommand } from './plugins'; +import { + handleExportDebugZipCommand, + handleExportMdCommand, + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session'; + +// --------------------------------------------------------------------------- +// Re-exports — keep existing consumers working +// --------------------------------------------------------------------------- + +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 { handlePluginsCommand } from './plugins'; +export { + handleExportDebugZipCommand, + handleExportMdCommand, + handleForkCommand, + handleInitCommand, + handleTitleCommand, +} from './session'; + +// --------------------------------------------------------------------------- +// Host interface +// --------------------------------------------------------------------------- + +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; + sendQueuedMessage(session: Session, item: QueuedMessage): void; + + // UI + showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; + + // Theme + 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; +} + +// --------------------------------------------------------------------------- +// Dispatch — entry point from handleUserInput +// --------------------------------------------------------------------------- + +export function dispatchInput(host: SlashCommandHost, text: string): void { + if (parseSlashInput(text) !== null) { + void executeSlashCommand(host, text); + return; + } + host.sendNormalUserInput(text); +} + +async function executeSlashCommand(host: SlashCommandHost, input: string): Promise { + const parsedCommand = parseSlashInput(input); + const intent = resolveSlashCommandInput({ + input, + skillCommandMap: host.skillCommandMap, + isStreaming: host.state.appState.streamingPhase !== 'idle', + 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 'plugins': + void handlePluginsCommand(host, args); + 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 'export-md': + await handleExportMdCommand(host, args); + return; + case 'export-debug-zip': + await handleExportDebugZipCommand(host); + 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/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index fab43f75c..2ee577850 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -3,3 +3,43 @@ 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 { handlePluginsCommand } from './plugins'; +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/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts new file mode 100644 index 000000000..a5dd47959 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/info.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 './prompts'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// 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/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts new file mode 100644 index 000000000..d465b268d --- /dev/null +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -0,0 +1,391 @@ +import { homedir as osHomedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { + PluginMcpSelectorComponent, + PluginMarketplaceSelectorComponent, + PluginRemoveConfirmComponent, + PluginsOverviewSelectorComponent, + type PluginMcpSelection, + type PluginMarketplaceSelection, + type PluginRemoveConfirmResult, + type PluginsOverviewSelection, +} from '../components/dialogs/plugins-selector'; +import { + buildPluginsInfoLines, + buildPluginsListLines, +} from '../components/messages/plugins-status-panel'; +import { UsagePanelComponent } from '../components/messages/usage-panel'; +import { formatErrorMessage } from '../utils/event-payload'; +import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import type { SlashCommandHost } from './dispatch'; + +interface ShowPluginsPickerOptions { + readonly selectedId?: string; + readonly pluginHint?: { + readonly id: string; + readonly text: string; + }; +} + +export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise { + const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); + const sub = args[0]; + const rest = args.slice(1); + const session = host.requireSession(); + + try { + if (sub === undefined) { + await showPluginsPicker(host); + return; + } + if (sub === 'list') { + await renderPluginsList(host); + return; + } + if (sub === 'install') { + const source = rest.join(' ').trim(); + if (source.length === 0) { + host.showError('Usage: /plugins install '); + return; + } + await installPluginFromSource(host, source); + return; + } + if (sub === 'marketplace') { + await showPluginMarketplacePicker(host, rest.join(' ').trim() || undefined); + return; + } + if (sub === 'info') { + const id = rest[0]; + if (id === undefined) { + await showPluginsPicker(host); + return; + } + await renderPluginInfo(host, id); + return; + } + if (sub === 'mcp') { + const action = rest[0]; + const id = rest[1]; + const server = rest[2]; + if ((action !== 'enable' && action !== 'disable') || id === undefined || server === undefined) { + host.showError('Usage: /plugins mcp enable|disable '); + return; + } + await session.setPluginMcpServerEnabled(id, server, action === 'enable'); + host.showStatus( + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + ); + return; + } + if (sub === 'enable' || sub === 'disable') { + const id = rest[0]; + if (id === undefined) { + await showPluginsPicker(host); + return; + } + await applyPluginEnabled(host, id, sub === 'enable'); + return; + } + if (sub === 'remove') { + const id = rest[0]; + if (id === undefined) { + host.showError('Usage: /plugins remove '); + return; + } + if (!(await confirmRemovePlugin(host, id))) { + host.showStatus(`Remove cancelled: ${id}.`); + return; + } + await session.removePlugin(id); + host.showStatus(`Removed ${id} (plugin files left in place).`); + return; + } + if (sub === 'reload') { + await reloadPlugins(host); + return; + } + const plugins = await session.listPlugins(); + if (plugins.some((plugin) => plugin.id === sub)) { + await renderPluginInfo(host, sub); + return; + } + host.showError(`Unknown /plugins action: ${sub}. Run /plugins to choose interactively.`); + } catch (error) { + host.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`); + } +} + +async function showPluginsPicker( + host: SlashCommandHost, + options?: ShowPluginsPickerOptions, +): Promise { + let plugins: readonly PluginSummary[]; + try { + plugins = await host.requireSession().listPlugins(); + } catch (error) { + host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); + return; + } + + host.mountEditorReplacement( + new PluginsOverviewSelectorComponent({ + plugins, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + colors: host.state.theme.colors, + onSelect: (selection) => { + host.restoreEditor(); + void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +async function showPluginMarketplacePicker(host: SlashCommandHost, source?: string): Promise { + try { + const [marketplace, installed] = await Promise.all([ + loadPluginMarketplace({ workDir: host.state.appState.workDir, source }), + host.requireSession().listPlugins(), + ]); + host.mountEditorReplacement( + new PluginMarketplaceSelectorComponent({ + entries: marketplace.plugins, + installedIds: new Set(installed.map((plugin) => plugin.id)), + source: marketplace.source, + colors: host.state.theme.colors, + onSelect: (selection) => { + host.restoreEditor(); + void handlePluginMarketplaceSelection(host, selection).catch((error: unknown) => { + host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + void showPluginsPicker(host); + }, + }), + ); + } catch (error) { + host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); + } +} + +async function showPluginMcpPicker(host: SlashCommandHost, id: string): Promise { + let info: PluginInfo; + try { + info = await host.requireSession().getPluginInfo(id); + } catch (error) { + host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); + return; + } + + host.mountEditorReplacement( + new PluginMcpSelectorComponent({ + info, + colors: host.state.theme.colors, + onSelect: (selection) => { + host.restoreEditor(); + void handlePluginMcpSelection(host, selection).catch((error: unknown) => { + host.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + void showPluginsPicker(host, { selectedId: id }); + }, + }), + ); +} + +async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise { + let displayName = id; + try { + displayName = (await host.requireSession().getPluginInfo(id)).displayName; + } catch { + // Keep the confirmation available even when plugin details cannot be loaded. + } + + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginRemoveConfirmComponent({ + id, + displayName, + colors: host.state.theme.colors, + onDone: (result: PluginRemoveConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +async function applyPluginEnabled(host: SlashCommandHost, id: string, enabled: boolean): Promise { + const session = host.requireSession(); + await session.setPluginEnabled(id, enabled); + let info: PluginInfo | undefined; + try { + info = await session.getPluginInfo(id); + } catch { + info = undefined; + } + const mcpHint = + enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount + ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` + : ''; + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); +} + +async function handlePluginsOverviewSelection( + host: SlashCommandHost, + selection: PluginsOverviewSelection, +): Promise { + const session = host.requireSession(); + switch (selection.kind) { + case 'marketplace': + await showPluginMarketplacePicker(host); + return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host); + return; + case 'show-list': + await renderPluginsList(host); + return; + case 'toggle': + await applyPluginEnabled(host, selection.id, selection.enabled); + await showPluginsPicker(host, { + selectedId: selection.id, + pluginHint: { id: selection.id, text: 'saved · /new to apply' }, + }); + return; + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'remove': + if (!(await confirmRemovePlugin(host, selection.id))) { + host.showStatus(`Remove cancelled: ${selection.id}.`); + await showPluginsPicker(host, { selectedId: selection.id }); + return; + } + await session.removePlugin(selection.id); + host.showStatus(`Removed ${selection.id} (plugin files left in place).`); + await showPluginsPicker(host); + return; + case 'info': + await renderPluginInfo(host, selection.id); + return; + } +} + +async function handlePluginMcpSelection( + host: SlashCommandHost, + selection: PluginMcpSelection, +): Promise { + switch (selection.kind) { + case 'toggle': + await host.requireSession().setPluginMcpServerEnabled( + selection.pluginId, + selection.server, + selection.enabled, + ); + host.showStatus( + `${selection.enabled ? 'Enabled' : 'Disabled'} MCP server ${selection.server} for ${selection.pluginId}. Run /new to apply.`, + ); + await showPluginMcpPicker(host, selection.pluginId); + return; + case 'back': + await showPluginsPicker(host, { selectedId: selection.pluginId }); + return; + } +} + +async function handlePluginMarketplaceSelection( + host: SlashCommandHost, + selection: PluginMarketplaceSelection, +): Promise { + switch (selection.kind) { + case 'install': + host.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); + await installPluginFromSource(host, selection.entry.source, { + successNotice: 'marketplace', + }); + await showPluginsPicker(host, { selectedId: selection.entry.id }); + return; + case 'back': + await showPluginsPicker(host); + return; + } +} + +async function renderPluginsList( + host: SlashCommandHost, + plugins?: readonly PluginSummary[], +): Promise { + const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); + const lines = buildPluginsListLines({ + colors: host.state.theme.colors, + plugins: currentPlugins, + }); + const title = ` Plugins (${currentPlugins.length}) `; + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, title); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function renderPluginInfo(host: SlashCommandHost, id: string): Promise { + const info = await host.requireSession().getPluginInfo(id); + const lines = buildPluginsInfoLines({ colors: host.state.theme.colors, info }); + const panel = new UsagePanelComponent(lines, host.state.theme.colors.primary, ` ${info.id} `); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); +} + +async function installPluginFromSource( + host: SlashCommandHost, + source: string, + options?: { readonly successNotice?: 'marketplace' }, +): Promise { + const summary = await host.requireSession().installPlugin( + resolvePluginInstallSource(source, host.state.appState.workDir), + ); + const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; + const mcpHint = + summary.mcpServerCount > 0 + ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` + : ''; + const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed'; + host.showStatus( + `${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, + ); + if (options?.successNotice === 'marketplace') { + host.showNotice( + `Installed or updated ${summary.displayName}`, + `Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`, + ); + } +} + +async function reloadPlugins(host: SlashCommandHost): Promise { + const summary = await host.requireSession().reloadPlugins(); + const line = `Reload: +${summary.added.length} -${summary.removed.length}` + + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); + host.showStatus(line); +} + +function resolvePluginInstallSource(source: string, workDir: string): string { + const trimmed = source.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; + if (trimmed === '~') return osHomedir(); + if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed); +} diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts new file mode 100644 index 000000000..dbc86a25a --- /dev/null +++ b/apps/kimi-code/src/tui/commands/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 './dispatch'; + +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/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts new file mode 100644 index 000000000..2f0870db0 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -0,0 +1,186 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +import { detectInstallSource } from '#/cli/update/source'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; +import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +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 { buildExportMarkdown } from '../utils/export-markdown'; +import type { SlashCommandHost } from './dispatch'; + +// --------------------------------------------------------------------------- +// 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}). To return to the original session: kimi -r ${session.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 handleExportMdCommand(host: SlashCommandHost, args: string): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.showStatus('Exporting session as Markdown…'); + try { + const context = await session.getContext(); + if (context.history.length === 0) { + host.showError('No messages to export.'); + return; + } + + const now = new Date(); + const shortId = session.id.slice(0, 8); + const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); + const defaultName = `kimi-export-${shortId}-${timestamp}.md`; + + const trimmedArgs = args.trim(); + const outputPath = trimmedArgs.length > 0 + ? resolve(trimmedArgs) + : resolve(host.state.appState.workDir, defaultName); + + const md = buildExportMarkdown({ + sessionId: session.id, + workDir: host.state.appState.workDir, + history: context.history, + tokenCount: context.tokenCount, + now, + }); + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, md, 'utf-8'); + + const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href); + host.showNotice(`Exported ${String(context.history.length)} messages`, linked); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to export session: ${msg}`); + } +} + +export async function handleExportDebugZipCommand(host: SlashCommandHost): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + host.showStatus('Exporting session…'); + try { + const installSource = await detectInstallSource(); + const shellEnv = detectShellEnvironment(); + const result = await host.harness.exportSession({ + id: session.id, + version: host.state.appState.version, + installSource, + shellEnv, + includeGlobalLog: true, + }); + const linked = toTerminalHyperlink(result.zipPath, pathToFileURL(result.zipPath).href); + host.showNotice('Export complete', linked); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to export session: ${msg}`); + } +} + +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/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts new file mode 100644 index 000000000..d19e6008f --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -0,0 +1,133 @@ +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 type { AppState, KimiTUIOptions } from '../types'; +import type { TUIState } from '../tui-state'; + +export interface AuthFlowHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + readonly options: KimiTUIOptions; + + setAppState(patch: Partial): void; + setStartupReady(): void; + resetSessionRuntime(): void; + setSession(session: Session): Promise; + syncRuntimeState(session?: Session): Promise; + closeSession(reason: string): Promise; + appendStartupNotice(extra: string): void; + readonly sessionEventHandler: SessionEventHandler; + 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.appendStartupNotice(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); + this.host.setStartupReady(); + } + + 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.sessionEventHandler.startSubscription(); + 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/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts new file mode 100644 index 000000000..75473d7ae --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -0,0 +1,291 @@ +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; + clearQueuedMessages(): void; + setExternalEditorRunning(running: 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.clearQueuedMessages(); + + 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 { + 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; + } + this.host.setExternalEditorRunning(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); + 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 new file mode 100644 index 000000000..0e310ba91 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -0,0 +1,928 @@ +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 { + 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 { formatStepDebugTiming } from '#/utils/usage/debug-timing'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { StreamingUIController } from './streaming-ui'; +import type { TasksBrowserController } from './tasks-browser'; +import type { + AppState, + BackgroundAgentMetadata, + LivePaneState, + QueuedMessage, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import type { TUIState } from '../tui-state'; + +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; + shiftQueuedMessage(): QueuedMessage | undefined; + readonly tasksBrowserController: TasksBrowserController; +} + +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(); + 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 (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderMcpServerStatus(server); + } + + const hidden: McpServerStatusSnapshot[] = []; + for (const server of servers) { + if (visibleNames.has(server.name)) continue; + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.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.streamingUI.setTurnId(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.mcpServerStatusSpinners.values()) { + spinner.stop(); + } + this.mcpServerStatusSpinners.clear(); + } + + // --------------------------------------------------------------------------- + // Private handlers + // --------------------------------------------------------------------------- + + private routeSubagentEvent(event: Event): boolean { + const subagentId = event.agentId; + if (subagentId === MAIN_AGENT_ID) return false; + + 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; + const toolCall = streamingUI.getToolComponent(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.streamingUI.setStep(0); + this.host.patchLivePane({ + mode: 'waiting', + pendingApproval: null, + pendingQuestion: null, + }); + this.host.setAppState({ + 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.streamingUI.setStep(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(); + this.maybeShowDebugTiming(event); + if (event.finishReason !== 'max_tokens') return; + + const truncatedCount = this.host.streamingUI.markStepTruncated( + String(event.turnId), + event.step, + ); + + 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 maybeShowDebugTiming(event: TurnStepCompletedEvent): void { + if (process.env['KIMI_CODE_DEBUG'] !== '1') return; + const text = formatStepDebugTiming(event); + if (text !== undefined) this.host.showStatus(text); + } + + 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, streamingUI } = this.host; + streamingUI.appendThinkingDelta(event.delta); + this.host.patchLivePane({ mode: 'idle' }); + if (state.appState.streamingPhase !== 'thinking') { + this.host.setAppState({ streamingPhase: 'thinking', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleAssistantDelta(event: AssistantDeltaEvent): void { + const { state, streamingUI } = this.host; + if (streamingUI.hasThinkingDraft()) { + streamingUI.flushThinkingToTranscript('idle'); + } + + streamingUI.appendAssistantDelta(event.delta); + + this.host.patchLivePane({ + mode: 'idle', + pendingApproval: null, + pendingQuestion: null, + }); + if (state.appState.streamingPhase !== 'composing') { + this.host.setAppState({ streamingPhase: 'composing', streamingStartTime: Date.now() }); + } + streamingUI.scheduleFlush(); + } + + private handleHookResult(event: HookResultEvent): void { + this.host.streamingUI.flushNow(); + if (this.host.streamingUI.hasThinkingDraft()) { + 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 { 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, + turnId, + }; + streamingUI.registerToolCall(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; + streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + + 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.streamingUI.getToolComponent(event.toolCallId); + if (tc === undefined) return; + tc.appendProgress(text); + } + + private handleToolResult(event: ToolResultEvent): void { + const { streamingUI } = this.host; + streamingUI.flushNow(); + const resultData: ToolResultBlockData = { + tool_call_id: event.toolCallId, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + synthetic: event.synthetic, + }; + 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); + } + } + 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; + } + 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 (this.renderedMcpServerStatusKeys.get(server.name) === key) return; + this.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 = this.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); + this.mcpServerStatusSpinners.set(name, spinner); + state.ui.requestRender(); + } + + private finalizeMcpServerStatusRow(name: string, message: string, color: string): void { + const { state } = this.host; + const spinner = this.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); + } + this.mcpServerStatusSpinners.delete(name); + state.ui.requestRender(); + } + + private handleSkillActivated(event: SkillActivatedEvent): void { + if (this.renderedSkillActivationIds.has(event.activationId)) return; + this.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 hasActiveTurn = this.host.streamingUI.hasActiveTurn(); + if (!hasActiveTurn) { + this.host.setAppState({ + isCompacting: false, + streamingPhase: 'idle', + }); + this.host.resetLivePane(); + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + setTimeout(() => { + sendQueued(next); + }, 0); + } + } else { + this.host.setAppState({ isCompacting: false }); + } + } + + private handleSubagentSpawned(event: SubagentSpawnedEvent): void { + const { streamingUI } = this.host; + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + }); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + this.backgroundAgentMetadata.set(event.subagentId, meta); + this.appendBackgroundAgentEntry('started', meta); + this.syncBackgroundAgentBadge(); + return; + } + + let tc = streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) { + const toolCall = streamingUI.getActiveToolCall(event.parentToolCallId); + if (toolCall !== undefined) { + streamingUI.onToolCallStart(toolCall); + tc = streamingUI.getToolComponent(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 { streamingUI } = this.host; + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + this.backgroundAgentMetadata.delete(event.subagentId); + this.syncBackgroundAgentBadge(); + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + const tc = streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + } + + private handleSubagentFailed(event: SubagentFailedEvent): void { + const { streamingUI } = this.host; + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + this.backgroundAgentMetadata.delete(event.subagentId); + this.syncBackgroundAgentBadge(); + const taskId = this.findAgentTaskId(event.subagentId); + if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + const tc = streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + 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', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step, + turnId, + }; + streamingUI.onToolCallStart(toolCall); + return streamingUI.getToolComponent(event.parentToolCallId); + } + + private findAgentTaskId(subagentId: string): string | undefined { + 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 this.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.streamingUI.getActiveToolCall(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.streamingUI.getTurnContext().turnId, + 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 = this.backgroundTasks.get(info.taskId); + this.backgroundTasks.set(info.taskId, info); + + const viewer = state.tasksBrowser?.viewer; + if (viewer !== undefined && viewer.taskId === info.taskId) { + void this.host.tasksBrowserController.refreshOutputViewer({ 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.tasksBrowserController.repaint(); + return; + } + this.appendBackgroundTaskEntry(info); + this.syncBackgroundTaskBadge(); + this.host.tasksBrowserController.repaint(); + return; + } + + if (event.type === 'background.task.terminated' && isTerminal) { + if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { + if (info.taskId.startsWith('bash-')) { + this.appendBackgroundTaskEntry(info); + } + this.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + this.syncBackgroundTaskBadge(); + this.host.tasksBrowserController.repaint(); + return; + } + + if (previous?.status !== info.status) { + this.syncBackgroundTaskBadge(); + } + this.host.tasksBrowserController.repaint(); + } + + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + const status = formatBackgroundTaskTranscript(info); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.streamingUI.getTurnContext().turnId, + 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 this.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/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts new file mode 100644 index 000000000..106b384bf --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -0,0 +1,467 @@ +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, + 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 { 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; +} + +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, sessionEventHandler } = this.host; + const projection = replayBackgroundProjection(agent.background); + sessionEventHandler.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); + sessionEventHandler.backgroundTasks = new Map( + agent.background.map((info) => [info.taskId, info]), + ); + sessionEventHandler.backgroundTaskTranscriptedTerminal.clear(); + for (const info of agent.background) { + if (isTerminalBackgroundTask(info)) { + sessionEventHandler.backgroundTaskTranscriptedTerminal.add(info.taskId); + } + } + state.footer.setBackgroundCounts(countActiveBackgroundTasks(sessionEventHandler.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 { 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); + streamingUI.setActiveToolCall(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.streamingUI.removeActiveToolCall(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.streamingUI.setTurnId(context.currentTurnId); + this.host.streamingUI.setStep(context.stepIndex); + } + + private flushAssistant(context: ReplayRenderContext): void { + const { 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) { + streamingUI.onStreamingTextStart(); + streamingUI.onStreamingTextUpdate(text); + streamingUI.onStreamingTextEnd(); + streamingUI.clearAssistantDraft(); + } + } + + private cleanupRuntime(context: ReplayRenderContext): void { + this.flushAssistant(context); + this.host.streamingUI.cleanupAfterReplay(context.completedToolCallIds); + } + + // --------------------------------------------------------------------------- + // Special content renderers + // --------------------------------------------------------------------------- + + private renderSkillActivation( + context: ReplayRenderContext, + skill: SkillActivationProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.skillActivationIds.has(skill.activationId)) return; + if (sessionEventHandler.renderedSkillActivationIds.has(skill.activationId)) return; + context.skillActivationIds.add(skill.activationId); + sessionEventHandler.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, streamingUI } = this.host; + streamingUI.removeActiveToolCall(toolCallId); + streamingUI.removeToolComponent(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 { 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({ + ...replayEntry(context, 'status', status.headline, 'plain'), + detail: status.detail, + backgroundAgentStatus: status, + }); + sessionEventHandler.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, + }); + sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId); + } +} 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 000000000..769df3767 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -0,0 +1,750 @@ +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 { hasDispose } from '../utils/component-capabilities'; +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'; +import type { + AppState, + LivePaneState, + QueuedMessage, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import type { TUIState } from '../tui-state'; + +export interface StreamingUIHost { + state: TUIState; + session: Session | undefined; + setAppState(patch: Partial): void; + patchLivePane(patch: Partial): void; + resetLivePane(): void; + updateActivityPane(): void; + updateQueueDisplay(): void; + requireSession(): Session; + deferUserMessages: boolean; + shiftQueuedMessage(): QueuedMessage | undefined; + pushTranscriptEntry(entry: TranscriptEntry): void; +} + +export class StreamingUIController { + private flushTimer: ReturnType | undefined; + private lastFlushAt: number | undefined; + private pendingAssistantFlush = false; + private pendingThinkingFlush = false; + readonly pendingToolCallFlushIds = new Set(); + + // --------------------------------------------------------------------------- + // Streaming runtime state (private — accessed via semantic methods below) + // --------------------------------------------------------------------------- + + 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 } + >(); + private _pendingToolComponents = new Map(); + private _pendingAgentGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: AgentGroupComponent; + } | null = null; + private _pendingReadGroup: { + readonly turnId: string | undefined; + readonly step: number; + solo?: ToolCallComponent; + group?: ReadGroupComponent; + } | null = null; + + 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; + } + + hasActiveThinkingComponent(): boolean { + return this._activeThinkingComponent !== undefined; + } + + 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; + } + } + + 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 + // --------------------------------------------------------------------------- + + 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._thinkingDraft.length > 0) { + this.onThinkingUpdate(this._thinkingDraft); + } + if (shouldFlushAssistant) { + this.onStreamingTextUpdate(this._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(); + this._thinkingDraft = ''; + this.onThinkingEnd(); + this.host.patchLivePane({ mode: nextMode }); + } + + finalizeAssistantStream(): void { + this.flushNow(); + if (this._streamingBlock !== null) { + this.onStreamingTextEnd(); + } + this._assistantDraft = ''; + this.host.updateActivityPane(); + this.host.state.ui.requestRender(); + } + + resetLiveText(): void { + this.pendingAssistantFlush = false; + this.pendingThinkingFlush = false; + this.clearFlushTimerIfIdle(); + this._assistantDraft = ''; + this._streamingBlock = null; + this._thinkingDraft = ''; + this.disposeActiveThinkingComponent(); + } + + resetToolUi(): void { + this.pendingToolCallFlushIds.clear(); + this.clearFlushTimerIfIdle(); + this._streamingToolCallArguments.clear(); + this.disposeAndClearPendingToolComponents(); + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + } + + resetToolCallState(): void { + this._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.streamingPhase === 'idle') return; + this.host.deferUserMessages = false; + const completedTurnKey = + this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; + this.finalizeLiveTextBuffers('idle'); + this.resetToolCallState(); + this._currentTurnId = undefined; + + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + this.host.setAppState({ streamingPhase: 'idle' }); + this.host.resetLivePane(); + setTimeout(() => { + sendQueued(next); + }, 0); + return; + } + + this.host.setAppState({ 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; + this._pendingAgentGroup = null; + this._pendingReadGroup = null; + const entry = { + id: nextTranscriptId(), + kind: 'assistant' as const, + turnId: this._currentTurnId, + renderMode: 'markdown' as const, + content: '', + }; + const component = new AssistantMessageComponent( + state.theme.markdownTheme, + state.theme.colors, + ); + this._streamingBlock = { component, entry }; + this.host.pushTranscriptEntry(entry); + state.transcriptContainer.addChild(component); + state.ui.requestRender(); + } + + onStreamingTextUpdate(fullText: string): void { + const block = this._streamingBlock; + if (block !== null) { + block.entry.content = fullText; + block.component.updateContent(fullText); + this.host.state.ui.requestRender(); + } + } + + onStreamingTextEnd(): void { + this._streamingBlock = null; + } + + onThinkingUpdate(fullText: string): void { + if (fullText.length === 0 && this._activeThinkingComponent === undefined) return; + const { state } = this.host; + 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); + } else { + this._activeThinkingComponent.setText(fullText); + } + state.ui.requestRender(); + } + + onThinkingEnd(): void { + if (this._activeThinkingComponent === undefined) return; + this._activeThinkingComponent.finalize(); + this._activeThinkingComponent = undefined; + this.host.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); + this._pendingToolComponents.set(toolCall.id, tc); + + 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); + 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 = this._activeToolCalls.get(toolCallId); + const tc = this._pendingToolComponents.get(toolCallId); + if (tc) { + tc.setResult(result); + this._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 (this._activeCompactionBlock !== undefined) { + this._activeCompactionBlock.markDone(); + this._activeCompactionBlock = undefined; + } + const block = new CompactionComponent(state.theme.colors, state.ui, instruction); + this._activeCompactionBlock = block; + state.transcriptContainer.addChild(block); + state.ui.requestRender(); + } + + endCompaction(tokensBefore?: number, tokensAfter?: number): void { + const block = this._activeCompactionBlock; + if (block === undefined) return; + block.markDone(tokensBefore, tokensAfter); + this._activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + cancelCompaction(): void { + const block = this._activeCompactionBlock; + if (block === undefined) return; + block.markCanceled(); + this._activeCompactionBlock = undefined; + this.host.state.ui.requestRender(); + } + + // --------------------------------------------------------------------------- + // Tool call grouping + // --------------------------------------------------------------------------- + + private flushToolCallPreview(id: string): void { + const streaming = this._streamingToolCallArguments.get(id); + if (streaming === undefined) return; + const toolCall: ToolCallBlockData = { + id, + 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, + }; + this._activeToolCalls.set(id, toolCall); + + if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) { + this.finalizeLiveTextBuffers('tool'); + } + + const existingComponent = this._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') { + this._pendingAgentGroup = null; + return false; + } + + 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; + } + + const cur = this._pendingAgentGroup; + if (cur === null) { + this._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) { + 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 }; + 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') { + this._pendingReadGroup = null; + return false; + } + + 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; + } + + const cur = this._pendingReadGroup; + if (cur === null) { + this._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) { + 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 }; + 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/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts new file mode 100644 index 000000000..0da21d4be --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -0,0 +1,440 @@ +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: { + readonly tasksBrowser: TasksBrowserState | undefined; + 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; + setTasksBrowser(value: TasksBrowserState | undefined): 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); + + this.host.setTasksBrowser({ + 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); + } + this.host.setTasksBrowser(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.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 = this.host.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 = this.host.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/index.ts b/apps/kimi-code/src/tui/index.ts index e4581aa39..06af194ec 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 f364d05b3..878e50374 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1,139 +1,47 @@ -/** - * 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 { mkdir, writeFile } from 'node:fs/promises'; -import { homedir as osHomedir, release as osRelease, type as osType } from 'node:os'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +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 { - 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, - PluginInfo, - PluginSummary, - 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'; import type { CLIOptions } from '#/cli/options'; -import { detectInstallSource } from '#/cli/update/source'; -import { detectShellEnvironment } from '#/utils/process/shell-env'; -import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; 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 { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import { getInputHistoryFile } from '#/utils/paths'; -import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; import { detectFdPath } from '#/utils/process/fd-detect'; -import { buildExportMarkdown } from './utils/export-markdown'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, - parseSlashInput, - resolveSlashCommandInput, - slashBusyMessage, sortSlashCommands, - type BuiltinSlashCommandName, type KimiSlashCommand, 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, type TodoItem } from './components/chrome/todo-panel'; import { WelcomeComponent } from './components/chrome/welcome'; import { ApprovalPanelComponent, @@ -143,94 +51,37 @@ import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from './components/dialogs/approval-preview'; -import { - ApiKeyInputDialogComponent, - 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 { - PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, - PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, - type PluginMcpSelection, - type PluginMarketplaceSelection, - type PluginRemoveConfirmResult, - type PluginsOverviewSelection, -} from './components/dialogs/plugins-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 { - SettingsSelectorComponent, - type SettingsSelection, -} from './components/dialogs/settings-selector'; -import { ThemeSelectorComponent } from './components/dialogs/theme-selector'; -import { CustomEditor } from './components/editor/custom-editor'; +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'; +import { StreamingUIController } from './controllers/streaming-ui'; +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 { 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 { - buildPluginsInfoLines, - buildPluginsListLines, -} from './components/messages/plugins-status-panel'; -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, - 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, - isManagedUsageProvider, 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 { 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'; @@ -238,74 +89,44 @@ 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 { isTheme, type Theme } from './theme/index'; +import type { Theme } from './theme/index'; import { INITIAL_LIVE_PANE, type AppState, - type BackgroundAgentMetadata, + type KimiTUIOptions, type LivePaneState, + type LoginProgressSpinnerHandle, type QueuedMessage, - type ToolCallBlockData, - type ToolResultBlockData, type TranscriptEntry, + type TUIStartupOptions, + type TUIStartupState, } 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 { createTUIState, type TUIState } from './tui-state'; +import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; -import { - appendStreamingArgsPreview, - argsRecord, - formatErrorMessage, - isTodoItemShape, - parseStreamingArgs, - serializeToolResultOutput, - stringValue, -} from './utils/event-payload'; -import { isAbortError } from './utils/errors'; -import { formatHookResultMarkdown, formatHookResultPlain } from './utils/hook-result-format'; +import { formatErrorMessage } from './utils/event-payload'; 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'; 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'; -import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; + +export type { TUIState } from './tui-state'; +export { createTUIState } from './tui-state'; +export type { + KimiTUIOptions, + LoginProgressSpinnerHandle, + TUIStartupOptions, + TUIStartupState, +} from './types'; export interface KimiTUIStartupInput { readonly cliOptions: CLIOptions; @@ -319,162 +140,20 @@ 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; - startupNotice: string | undefined; - livePane: LivePaneState; - transcriptEntries: TranscriptEntry[]; - terminalState: TerminalState; - activitySpinner: MoonLoader | undefined; - activitySpinnerStyle: SpinnerStyle | undefined; - activeThinkingComponent: ThinkingComponent | undefined; - streamingComponent: AssistantMessageComponent | undefined; - streamingTranscriptEntry: TranscriptEntry | undefined; - activeCompactionBlock: CompactionComponent | undefined; - toolOutputExpanded: boolean; - planExpanded: boolean; - lastActivityMode: string | undefined; - lastHistoryContent: string | undefined; - 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; - backgroundAgents: Set; - 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; - subagentParentToolCallIds: Map; - subagentNames: Map; - sessions: SessionRow[]; - 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; - externalEditorRunning: boolean; - currentTurnId: string | undefined; - currentStep: number; - assistantDraft: string; - assistantStreamActive: boolean; - 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'; return { 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', @@ -489,167 +168,44 @@ 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', - startupNotice: options.startup.startupNotice, - livePane: { ...INITIAL_LIVE_PANE }, - transcriptEntries: [], - terminalState: createTerminalState(), - activitySpinner: undefined, - activitySpinnerStyle: undefined, - activeThinkingComponent: undefined, - streamingComponent: undefined, - streamingTranscriptEntry: undefined, - 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(), - sessions: [], - loadingSessions: false, - showingSessionPicker: false, - showingHelpPanel: false, - tasksBrowser: undefined, - externalEditorRunning: false, - currentTurnId: undefined, - currentStep: 0, - assistantDraft: '', - assistantStreamActive: false, - thinkingDraft: '', - activeToolCalls: new Map(), - streamingToolCallArguments: new Map(), - queuedMessages: [], - }; -} - -// 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 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[]; readonly hasMedia?: boolean; } -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; - private readonly options: KimiTUIOptions; - private session: Session | undefined; - private state: TUIState; + readonly harness: KimiHarness; + readonly options: KimiTUIOptions; + session: Session | undefined; + state: TUIState; private readonly approvalController = new ApprovalController(); 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; - private sessionEventUnsubscribe: (() => void) | undefined; - private pendingExit: PendingExit | null = null; - private cancelInFlight: (() => void) | undefined; - // Queues editor messages instead of sending or steering them. Used by /init. - private deferUserMessages = false; - private aborted = false; + sessionEventUnsubscribe: (() => void) | undefined; + cancelInFlight: (() => void) | undefined; + 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; - // 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(); + private startupNotice: string | undefined; + private lastActivityMode: string | undefined; + private lastHistoryContent: string | undefined; + readonly streamingUI: StreamingUIController; + readonly authFlow: AuthFlowController; + readonly sessionEventHandler: SessionEventHandler; + readonly sessionReplay: SessionReplayRenderer; + readonly tasksBrowserController: TasksBrowserController; + readonly editorKeyboard: EditorKeyboardController; // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its @@ -667,14 +223,13 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; - private track( + track( event: string, properties?: Parameters[1], ): void { 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 = { @@ -692,10 +247,10 @@ 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); - // Register approval / question UI controllers before SDK handlers. this.reverseRpcDisposers.push( ...registerReverseRPCHandlers(this.approvalController, this.questionController, { showApprovalPanel: (payload) => { @@ -712,20 +267,24 @@ export class KimiTUI { }, }), ); - this.setupEditorHandlers(); + 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); + this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); + this.editorKeyboard.install(); this.buildLayout(); } // ========================================================================= - // 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, @@ -740,8 +299,7 @@ export class KimiTUI { this.state.editor.setAutocompleteProvider(provider); } - // 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(); @@ -764,53 +322,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.state.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); @@ -819,10 +347,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; @@ -830,15 +354,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; @@ -849,9 +369,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(); @@ -864,36 +381,31 @@ 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.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') { 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) { - 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) { @@ -902,16 +414,14 @@ 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.refreshAvailableModels(); + await this.authFlow.refreshAvailableModels(); const { startup } = this.options; const { workDir } = this.state.appState; @@ -948,8 +458,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.`, ); } @@ -962,7 +472,7 @@ export class KimiTUI { } } catch (error) { if (!isOAuthLoginRequiredError(error)) throw error; - this.enterLoginRequiredStartupState(); + this.authFlow.enterLoginRequiredStartupState(); return false; } @@ -975,21 +485,13 @@ 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; this.unregisterSignalHandlers(); this.aborted = true; - this.discardPendingStreamingUiUpdates(); - if (this.pendingExit) { - clearTimeout(this.pendingExit.timer); - this.pendingExit = null; - } + this.streamingUI.discardPending(); + this.editorKeyboard.clearPendingExit(); for (const dispose of this.reverseRpcDisposers) { dispose(); } @@ -997,27 +499,15 @@ 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); } } - // 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(); @@ -1032,14 +522,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); @@ -1076,169 +560,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; } - // 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; - } - } - - // ========================================================================= - // Auth / Model Bootstrap - // ========================================================================= - - // 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 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. - private 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. - private 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. - private 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, - }); - } - - // ========================================================================= - // Layout / Editor Setup - // ========================================================================= - - // Mounts the root TUI containers in their rendering order. private buildLayout(): void { const { ui } = this.state; ui.clear(); @@ -1254,405 +588,25 @@ 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.isStreaming) { - this.clearPendingExit(); - this.cancelCurrentStream(); - return; - } - - if (this.state.appState.isCompacting) { - this.clearPendingExit(); - this.cancelCurrentCompaction(); - 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.showingSessionPicker) { - this.hideSessionPicker(); - return; - } - if (this.state.appState.isStreaming) { - this.cancelCurrentStream(); - return; - } - if (this.state.appState.isCompacting) { - this.cancelCurrentCompaction(); - } - }; - - 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 this.applyPlanMode(session, next); - }; - - 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.isStreaming || 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.isStreaming && !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 // ========================================================================= - // Routes submitted editor text to slash command handling or normal prompting. - 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.'); return; } void this.persistInputHistory(text); - if (parseSlashInput(text) !== null) { - void this.executeSlashCommand(text); - return; - } - - this.sendNormalUserInput(text); + slashCommands.dispatchInput(this, 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.showTasksBrowser(); - return; - case 'mcp': - void this.showMcpServers(); - return; - case 'plugins': - void this.handlePluginsCommand(args); - return; - case 'editor': - await this.handleEditorCommand(args, {}); - return; - case 'theme': - await this.handleThemeCommand(args); - return; - case 'model': - this.handleModelCommand(args); - return; - case 'permission': - this.showPermissionPicker(); - return; - case 'settings': - this.showSettingsSelector(); - return; - case 'usage': - void this.showUsage(); - return; - case 'status': - void this.showStatusReport(); - return; - case 'feedback': - await this.handleFeedbackCommand(); - return; - case 'title': - await this.handleTitleCommand(args); - return; - case 'yolo': - await this.handleYoloCommand(args); - return; - case 'plan': - await this.handlePlanCommand(args); - return; - case 'compact': - await this.handleCompactCommand(args); - return; - case 'init': - await this.handleInitCommand(); - return; - case 'fork': - await this.handleForkCommand(args); - return; - case 'export-md': - await this.handleExportMdCommand(args); - return; - case 'export-debug-zip': - await this.handleExportDebugZipCommand(); - return; - case 'login': - await this.handleLoginCommand(); - return; - case 'connect': - await this.handleConnectCommand(args); - return; - case 'logout': - await this.handleLogoutCommand(); - return; - default: - this.showError(`Unknown slash command: /${String(name)}`); - return; - } - } - - // 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; @@ -1677,7 +631,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - // Checks whether the current model can accept attached media. private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -1699,7 +652,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; @@ -1707,23 +659,34 @@ export class KimiTUI { return capabilities.includes(capability); } - // Persists a submitted input line and mirrors it into editor history. - private async persistInputHistory(text: string): Promise { - const trimmed = text.trim(); - if (trimmed.length === 0) return; - if (trimmed === this.state.lastHistoryContent) return; - this.state.editor.addToHistory(trimmed); + private async loadPersistedInputHistory(): Promise { try { const file = getInputHistoryFile(this.state.appState.workDir); - const written = await appendInputHistory(file, trimmed, this.state.lastHistoryContent); - if (written) this.state.lastHistoryContent = trimmed; + const entries = await loadInputHistory(file); + for (const entry of entries) { + this.state.editor.addToHistory(entry.content); + } + this.lastHistoryContent = entries.at(-1)?.content; } catch { - this.state.lastHistoryContent = trimmed; + // best-effort } } - // Pops the most recent queued message back into the editor. - private recallLastQueued(): string | undefined { + private async persistInputHistory(text: string): Promise { + const trimmed = text.trim(); + if (trimmed.length === 0) 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.lastHistoryContent); + if (written) this.lastHistoryContent = trimmed; + } catch { + this.lastHistoryContent = trimmed; + } + } + + 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); @@ -1734,7 +697,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, @@ -1748,12 +710,11 @@ export class KimiTUI { this.track('input_queue'); } - // Resets request-scoped state before submitting work to the active session. - private beginSessionRequest(): void { - this.state.currentTurnId = undefined; - this.resetLiveTextRuntime(); - this.resetLiveToolUiState(); - this.resetToolCallState(); + beginSessionRequest(): void { + this.streamingUI.setTurnId(undefined); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.streamingUI.resetToolCallState(); this.patchLivePane({ mode: 'waiting', @@ -1761,21 +722,18 @@ export class KimiTUI { pendingQuestion: null, }); this.setAppState({ - isStreaming: true, streamingPhase: 'waiting', streamingStartTime: Date.now(), }); } - // Ends a failed session request and renders the failure to the transcript. - private failSessionRequest(message: string): void { - this.setAppState({ isStreaming: false, streamingPhase: 'idle' }); + 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. - 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, @@ -1783,7 +741,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 @@ -1807,8 +764,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); @@ -1816,11 +772,10 @@ 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 || - this.state.appState.isStreaming || + this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting ) { this.enqueueMessage(input, options); @@ -1829,15 +784,14 @@ export class KimiTUI { this.sendMessageInternal(session, input, options); } - // 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); } return; } - if (!this.state.appState.isStreaming) { + if (this.state.appState.streamingPhase === 'idle') { for (const part of input) { this.sendMessageInternal(session, part); } @@ -1848,7 +802,7 @@ export class KimiTUI { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', - turnId: this.state.currentTurnId, + turnId: this.streamingUI.getTurnContext().turnId, renderMode: 'plain', content: part, }); @@ -1860,216 +814,67 @@ 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}`); - }); - } - - 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); - } - } - - // Finalizes live thinking output and moves the live pane to the next mode. - // onThinkingEnd() is safe to call even when no component exists (it no-ops - // when activeThinkingComponent is undefined), and clearing an already-empty - // thinkingDraft is harmless, so we can unconditionally clean up. - private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { - this.flushStreamingUiUpdatesNow(); - this.state.thinkingDraft = ''; - this.onThinkingEnd(); - this.patchLivePane({ mode: 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 finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void { - this.flushThinkingToTranscript(nextMode); - this.finalizeAssistantStream(); - } - - // 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, - }); - } - // ========================================================================= - // State Helpers + // State & Accessors // ========================================================================= - // Applies app-state changes and refreshes dependent UI surfaces. - private setAppState(patch: Partial): void { + 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); + } + + 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 = '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); @@ -2078,16 +883,14 @@ export class KimiTUI { this.state.ui.requestRender(); } - // 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(); this.state.ui.requestRender(); } - // 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(); @@ -2097,15 +900,13 @@ export class KimiTUI { // Session Runtime // ========================================================================= - // 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); } 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) { @@ -2121,8 +922,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; @@ -2130,15 +930,13 @@ export class KimiTUI { this.registerSessionHandlers(session); } - // 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, model: status.model ?? '', thinking: status.thinkingLevel !== 'off', permissionMode: status.permission, - yolo: status.permission === 'yolo', planMode: status.planMode, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, @@ -2147,21 +945,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. - private async closeSession(reason: string): Promise { + 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?.(); @@ -2182,7 +977,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) => { @@ -2192,8 +986,7 @@ export class KimiTUI { session.setQuestionHandler(createQuestionAskHandler(this.questionController)); } - // 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 }); @@ -2209,44 +1002,33 @@ 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.streamingUI.discardPending(); this.state.queuedMessages = []; this.harness.interactiveAgentId = MAIN_AGENT_ID; - this.resetToolCallState(); - this.resetLiveToolUiState(); - this.state.backgroundAgents.clear(); - this.state.backgroundAgentMetadata.clear(); - this.state.backgroundTasks.clear(); - this.state.backgroundTaskTranscriptedTerminal.clear(); - this.closeTasksBrowser(); - this.state.subagentParentToolCallIds.clear(); - this.state.subagentNames.clear(); - this.state.renderedSkillActivationIds.clear(); - this.state.renderedMcpServerStatusKeys.clear(); - this.stopAllMcpServerStatusSpinners(); + this.streamingUI.resetToolCallState(); + this.streamingUI.resetToolUi(); + this.sessionEventHandler.resetRuntimeState(); + this.tasksBrowserController.close(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); - this.setTodoList([]); - this.state.currentTurnId = undefined; - this.state.currentStep = 0; - this.resetLiveTextRuntime(); + this.streamingUI.setTodoList([]); + this.streamingUI.setTurnId(undefined); + this.streamingUI.setStep(0); + this.streamingUI.resetLiveText(); 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.'); 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; } @@ -2268,8 +1050,7 @@ export class KimiTUI { return true; } - // 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); @@ -2281,12 +1062,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) { @@ -2295,8 +1076,7 @@ export class KimiTUI { this.showStatus(statusMessage); } - // 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; @@ -2318,7 +1098,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; @@ -2328,1697 +1108,15 @@ 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 - // ========================================================================= - - 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.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); - } - - // ========================================================================= - // Session Events - // ========================================================================= - - 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; - - 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(); - this.maybeShowDebugTiming(event); - 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 maybeShowDebugTiming(event: TurnStepCompletedEvent): void { - if (process.env['KIMI_CODE_DEBUG'] !== '1') return; - const text = formatStepDebugTiming(event); - if (text !== undefined) this.showStatus(text); - } - - 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.pendingThinkingFlush = true; - 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.pendingAssistantFlush = true; - - 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.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.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(); - } - - // ========================================================================= - // Live Render Hooks - // ========================================================================= - - // 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 { - // Avoid creating a component whose spinner will never stop when the text - // is empty and there is no existing component to update. - if (fullText.length === 0 && this.state.activeThinkingComponent === undefined) return; - 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; - } - // ========================================================================= // 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; @@ -4097,8 +1195,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) { @@ -4107,7 +1204,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[] = []; @@ -4134,49 +1230,23 @@ 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); } - // 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. - private disposeActiveThinkingComponent(): void { - if (this.state.activeThinkingComponent !== undefined) { - this.state.activeThinkingComponent.dispose(); - this.state.activeThinkingComponent = undefined; - } - } - - // Disposes and forgets all pending live tool-call components. - private 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()); } - // 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.disposeActiveCompactionBlock(); + this.streamingUI.resetLiveText(); + this.streamingUI.resetToolUi(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); @@ -4185,29 +1255,25 @@ export class KimiTUI { this.renderWelcome(); } - // 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), ); this.state.ui.requestRender(); } - // 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), ); this.state.ui.requestRender(); } - // 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); } - // 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)); @@ -4224,8 +1290,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({ @@ -4244,19 +1309,18 @@ export class KimiTUI { // Panes / Presentation State // ========================================================================= - // 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)); 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) { @@ -4309,9 +1373,8 @@ export class KimiTUI { this.state.ui.requestRender(); } - // 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'; @@ -4326,8 +1389,7 @@ export class KimiTUI { return this.state.livePane.mode; } - // 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; @@ -4337,14 +1399,13 @@ 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, }), ); } - // 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)) { @@ -4354,10 +1415,8 @@ 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. - private togglePlanExpansion(): boolean { + // 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; for (const child of this.state.transcriptContainer.children) { @@ -4371,8 +1430,7 @@ export class KimiTUI { return true; } - // 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('/') @@ -4382,8 +1440,7 @@ export class KimiTUI { this.state.ui.requestRender(); } - // 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; @@ -4394,8 +1451,7 @@ export class KimiTUI { this.state.ui.requestRender(true); } - // 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; @@ -4404,20 +1460,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 ( @@ -4428,74 +1481,63 @@ 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 = '', 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; } // ========================================================================= // Dialogs / Selectors // ========================================================================= - // 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); this.state.ui.requestRender(); } - // 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); 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, @@ -4526,9 +1568,8 @@ export class KimiTUI { return result; } - // Shows the help panel with the current slash command list. - private showHelpPanel(): void { - this.state.showingHelpPanel = true; + showHelpPanel(): void { + this.state.activeDialog = 'help'; this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), @@ -4540,21 +1581,18 @@ 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(); } - // Loads sessions and shows the session picker. - private async showSessionPicker(): Promise { + async showSessionPicker(): Promise { await this.fetchSessions(); this.mountSessionPicker(() => { this.hideSessionPicker(); }); } - // Shows the startup session picker and exits when it is cancelled. private async bootstrapFromPicker(): Promise { await this.fetchSessions(); this.mountSessionPicker(() => { @@ -4563,15 +1601,13 @@ export class KimiTUI { }); } - // Hides the session picker and restores the editor. - private hideSessionPicker(): void { - this.state.showingSessionPicker = false; + hideSessionPicker(): void { + 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, @@ -4590,1155 +1626,6 @@ export class KimiTUI { ); } - // ========================================================================= - // Background tasks browser (`/tasks`) - // ========================================================================= - - /** - * 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); - } - - // Shows the editor command selector. - private 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. - private 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. - private 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. - private 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. - private 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. - private 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. - private 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. - private 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. - private 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; - } - } - - // Shows the plugin manager entry point. - private async showPluginsPicker(options?: { - readonly selectedId?: string; - readonly pluginHint?: { - readonly id: string; - readonly text: string; - }; - }): Promise { - let plugins: readonly PluginSummary[]; - try { - plugins = await this.requireSession().listPlugins(); - } catch (error) { - this.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); - return; - } - - this.mountEditorReplacement( - new PluginsOverviewSelectorComponent({ - plugins, - selectedId: options?.selectedId, - pluginHint: options?.pluginHint, - colors: this.state.theme.colors, - onSelect: (selection) => { - this.restoreEditor(); - void this.handlePluginsOverviewSelection(selection).catch((error: unknown) => { - this.showError(`/plugins failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); - } - - // Loads official marketplace metadata and shows installable plugins. - private async showPluginMarketplacePicker(source?: string): Promise { - try { - const [marketplace, installed] = await Promise.all([ - loadPluginMarketplace({ workDir: this.state.appState.workDir, source }), - this.requireSession().listPlugins(), - ]); - this.mountEditorReplacement( - new PluginMarketplaceSelectorComponent({ - entries: marketplace.plugins, - installedIds: new Set(installed.map((plugin) => plugin.id)), - source: marketplace.source, - colors: this.state.theme.colors, - onSelect: (selection) => { - this.restoreEditor(); - void this.handlePluginMarketplaceSelection(selection).catch((error: unknown) => { - this.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - this.restoreEditor(); - void this.showPluginsPicker(); - }, - }), - ); - } catch (error) { - this.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); - } - } - - private async showPluginMcpPicker(id: string): Promise { - let info: PluginInfo; - try { - info = await this.requireSession().getPluginInfo(id); - } catch (error) { - this.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); - return; - } - - this.mountEditorReplacement( - new PluginMcpSelectorComponent({ - info, - colors: this.state.theme.colors, - onSelect: (selection) => { - this.restoreEditor(); - void this.handlePluginMcpSelection(selection).catch((error: unknown) => { - this.showError(`/plugins mcp failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - this.restoreEditor(); - void this.showPluginsPicker({ selectedId: id }); - }, - }), - ); - } - - private async confirmRemovePlugin(id: string): Promise { - let displayName = id; - try { - displayName = (await this.requireSession().getPluginInfo(id)).displayName; - } catch { - // Keep the confirmation available even when plugin details cannot be loaded. - } - - return new Promise((resolveConfirmed) => { - this.mountEditorReplacement( - new PluginRemoveConfirmComponent({ - id, - displayName, - colors: this.state.theme.colors, - onDone: (result: PluginRemoveConfirmResult) => { - this.restoreEditor(); - resolveConfirmed(result.kind === 'confirm'); - }, - }), - ); - }); - } - - // Applies a permission mode choice to the active session and app state. - private 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. - private 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(); - } - - private async handlePluginsCommand(rawArgs: string): Promise { - const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); - const sub = args[0]; - const rest = args.slice(1); - const session = this.requireSession(); - - try { - if (sub === undefined) { - await this.showPluginsPicker(); - return; - } - if (sub === 'list') { - await this.renderPluginsList(); - return; - } - if (sub === 'install') { - const source = rest.join(' ').trim(); - if (source.length === 0) { - this.showError('Usage: /plugins install '); - return; - } - await this.installPluginFromSource(source); - return; - } - if (sub === 'marketplace') { - await this.showPluginMarketplacePicker(rest.join(' ').trim() || undefined); - return; - } - if (sub === 'info') { - const id = rest[0]; - if (id === undefined) { - await this.showPluginsPicker(); - return; - } - await this.renderPluginInfo(id); - return; - } - if (sub === 'mcp') { - const action = rest[0]; - const id = rest[1]; - const server = rest[2]; - if ((action !== 'enable' && action !== 'disable') || id === undefined || server === undefined) { - this.showError('Usage: /plugins mcp enable|disable '); - return; - } - await session.setPluginMcpServerEnabled(id, server, action === 'enable'); - this.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, - ); - return; - } - if (sub === 'enable' || sub === 'disable') { - const id = rest[0]; - if (id === undefined) { - await this.showPluginsPicker(); - return; - } - await this.applyPluginEnabled(id, sub === 'enable'); - return; - } - if (sub === 'remove') { - const id = rest[0]; - if (id === undefined) { - this.showError('Usage: /plugins remove '); - return; - } - if (!(await this.confirmRemovePlugin(id))) { - this.showStatus(`Remove cancelled: ${id}.`); - return; - } - await session.removePlugin(id); - this.showStatus(`Removed ${id} (plugin files left in place).`); - return; - } - if (sub === 'reload') { - await this.reloadPlugins(); - return; - } - const plugins = await session.listPlugins(); - if (plugins.some((plugin) => plugin.id === sub)) { - await this.renderPluginInfo(sub); - return; - } - this.showError(`Unknown /plugins action: ${sub}. Run /plugins to choose interactively.`); - } catch (error) { - this.showError(`/plugins ${sub ?? ''} failed: ${formatErrorMessage(error)}`); - } - } - - // Toggles a plugin's enabled state and surfaces the MCP opt-in hint when relevant. - private async applyPluginEnabled(id: string, enabled: boolean): Promise { - const session = this.requireSession(); - await session.setPluginEnabled(id, enabled); - let info: PluginInfo | undefined; - try { - info = await session.getPluginInfo(id); - } catch { - info = undefined; - } - const mcpHint = - enabled && info !== undefined && info.mcpServerCount > info.enabledMcpServerCount - ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} .` - : ''; - this.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); - } - - private async handlePluginsOverviewSelection( - selection: PluginsOverviewSelection, - ): Promise { - const session = this.requireSession(); - switch (selection.kind) { - case 'marketplace': - await this.showPluginMarketplacePicker(); - return; - case 'reload': - await this.reloadPlugins(); - await this.showPluginsPicker(); - return; - case 'show-list': - await this.renderPluginsList(); - return; - case 'toggle': - await this.applyPluginEnabled(selection.id, selection.enabled); - await this.showPluginsPicker({ - selectedId: selection.id, - pluginHint: { id: selection.id, text: 'saved · /new to apply' }, - }); - return; - case 'mcp': - await this.showPluginMcpPicker(selection.id); - return; - case 'remove': - if (!(await this.confirmRemovePlugin(selection.id))) { - this.showStatus(`Remove cancelled: ${selection.id}.`); - await this.showPluginsPicker({ selectedId: selection.id }); - return; - } - await session.removePlugin(selection.id); - this.showStatus(`Removed ${selection.id} (plugin files left in place).`); - await this.showPluginsPicker(); - return; - case 'info': - await this.renderPluginInfo(selection.id); - return; - } - } - - private async handlePluginMcpSelection(selection: PluginMcpSelection): Promise { - switch (selection.kind) { - case 'toggle': - await this.requireSession().setPluginMcpServerEnabled( - selection.pluginId, - selection.server, - selection.enabled, - ); - this.showStatus( - `${selection.enabled ? 'Enabled' : 'Disabled'} MCP server ${selection.server} for ${selection.pluginId}. Run /new to apply.`, - ); - await this.showPluginMcpPicker(selection.pluginId); - return; - case 'back': - await this.showPluginsPicker({ selectedId: selection.pluginId }); - return; - } - } - - private async handlePluginMarketplaceSelection( - selection: PluginMarketplaceSelection, - ): Promise { - switch (selection.kind) { - case 'install': - this.showStatus(`Installing or updating ${selection.entry.displayName} from marketplace...`); - await this.installPluginFromSource(selection.entry.source, { - successNotice: 'marketplace', - }); - await this.showPluginsPicker({ selectedId: selection.entry.id }); - return; - case 'back': - await this.showPluginsPicker(); - return; - } - } - - private async renderPluginsList(plugins?: readonly PluginSummary[]): Promise { - const currentPlugins = plugins ?? (await this.requireSession().listPlugins()); - const lines = buildPluginsListLines({ - colors: this.state.theme.colors, - plugins: currentPlugins, - }); - const title = ` Plugins (${currentPlugins.length}) `; - const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, title); - this.state.transcriptContainer.addChild(panel); - this.state.ui.requestRender(); - } - - private async renderPluginInfo(id: string): Promise { - const info = await this.requireSession().getPluginInfo(id); - const lines = buildPluginsInfoLines({ colors: this.state.theme.colors, info }); - const panel = new UsagePanelComponent(lines, this.state.theme.colors.primary, ` ${info.id} `); - this.state.transcriptContainer.addChild(panel); - this.state.ui.requestRender(); - } - - private async installPluginFromSource( - source: string, - options?: { readonly successNotice?: 'marketplace' }, - ): Promise { - const summary = await this.requireSession().installPlugin( - resolvePluginInstallSource(source, this.state.appState.workDir), - ); - const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; - const mcpHint = - summary.mcpServerCount > 0 - ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` - : ''; - const installVerb = options?.successNotice === 'marketplace' ? 'Installed or updated' : 'Installed'; - this.showStatus( - `${installVerb} ${summary.displayName} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, - ); - if (options?.successNotice === 'marketplace') { - this.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`, - ); - } - } - - private async reloadPlugins(): Promise { - const summary = await this.requireSession().reloadPlugins(); - const line = `Reload: +${summary.added.length} -${summary.removed.length}` + - (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); - this.showStatus(line); - } - - // 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 } }); notifyTerminalOnce(this.state, `approval:${payload.id}`, { @@ -5765,7 +1652,6 @@ export class KimiTUI { this.mountEditorReplacement(panel); } - // Hides the active approval panel. private hideApprovalPanel(): void { // If the full-screen preview is open, fold it back first so the saved- // children stack stays consistent with what mountEditorReplacement set up. @@ -5812,7 +1698,6 @@ export class KimiTUI { this.state.ui.requestRender(true); } - // 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}`, { @@ -5836,860 +1721,9 @@ export class KimiTUI { this.mountEditorReplacement(dialog); } - // Hides the active question dialog. private hideQuestionDialog(): void { this.patchLivePane({ pendingQuestion: null }); this.restoreEditor(); } - // ========================================================================= - // Slash Command Handlers - // ========================================================================= - - // 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}). To return to the original session: kimi -r ${session.id}`, - ); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to switch to forked session: ${msg}`); - } - } - - private async handleExportMdCommand(args: string): Promise { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - this.showStatus('Exporting session as Markdown…'); - try { - const context = await session.getContext(); - if (context.history.length === 0) { - this.showError('No messages to export.'); - return; - } - - const now = new Date(); - const shortId = session.id.slice(0, 8); - const timestamp = now.toISOString().replaceAll(/[-:]/g, '').replace(/T/, '-').slice(0, 15); - const defaultName = `kimi-export-${shortId}-${timestamp}.md`; - - const trimmedArgs = args.trim(); - const outputPath = trimmedArgs.length > 0 - ? resolve(trimmedArgs) - : resolve(this.state.appState.workDir, defaultName); - - const md = buildExportMarkdown({ - sessionId: session.id, - workDir: this.state.appState.workDir, - history: context.history, - tokenCount: context.tokenCount, - now, - }); - - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, md, 'utf-8'); - - const linked = toTerminalHyperlink(outputPath, pathToFileURL(outputPath).href); - this.showNotice(`Exported ${String(context.history.length)} messages`, linked); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to export session: ${msg}`); - } - } - - private async handleExportDebugZipCommand(): Promise { - const session = this.session; - if (session === undefined) { - this.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - this.showStatus('Exporting session…'); - try { - const installSource = await detectInstallSource(); - const shellEnv = detectShellEnvironment(); - const result = await this.harness.exportSession({ - id: session.id, - version: this.state.appState.version, - installSource, - shellEnv, - includeGlobalLog: true, - }); - const linked = toTerminalHyperlink(result.zipPath, pathToFileURL(result.zipPath).href); - this.showNotice('Export complete', linked); - } catch (error) { - const msg = formatErrorMessage(error); - this.showError(`Failed to export 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); - }); - } -} - -function resolvePluginInstallSource(source: string, workDir: string): string { - const trimmed = source.trim(); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed; - if (trimmed === '~') return osHomedir(); - if (trimmed.startsWith('~/')) return join(osHomedir(), trimmed.slice(2)); - return isAbsolute(trimmed) ? trimmed : resolve(workDir, trimmed); } 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 000000000..1fb1f4445 --- /dev/null +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -0,0 +1,100 @@ +import { + Container, + ProcessTerminal, + TUI, +} from '@earendil-works/pi-tui'; + +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 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; + 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, + 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 16be35503..40dbdc3f0 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,19 +9,18 @@ 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; 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'; @@ -147,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/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 5328d6224..510b5769e 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/src/tui/utils/startup.ts b/apps/kimi-code/src/tui/utils/startup.ts new file mode 100644 index 000000000..e29561e9d --- /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; +} diff --git a/apps/kimi-code/src/tui/utils/terminal-focus.ts b/apps/kimi-code/src/tui/utils/terminal-focus.ts index 3a3559e60..bc07e92d3 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 a71da9347..2a0247cc7 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 71c6b9bbf..e66896a73 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/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index d0eef5d08..f0cbc4ac3 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 e66e7f11f..bb846c7eb 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 4861b68cd..7d14815c2 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 4da743841..76fa03f88 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'); @@ -72,32 +69,12 @@ 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.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.activeCompactionBlock).toBeUndefined(); - expect(state.pendingAgentGroup).toBeNull(); - expect(state.pendingReadGroup).toBeNull(); + expect(state.activitySpinner).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 32f090800..9a68b76dc 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 @@ -19,7 +19,18 @@ import { PluginsOverviewSelectorComponent, } from '#/tui/components/dialogs/plugins-selector'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; +import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; +import { handleFeedbackCommand } from '#/tui/commands/info'; +import { + promptFeedbackInput, + runModelSelector, +} from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; + +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'; vi.mock('#/tui/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -35,11 +46,14 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + streamingUI: StreamingUIController; + 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; } @@ -329,11 +343,11 @@ 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(); - await feedbackDriver.handleFeedbackCommand(); + await handleFeedbackCommand(feedbackDriver as any); expect(harness.auth.submitFeedback).toHaveBeenCalledWith( expect.objectContaining({ @@ -362,14 +376,14 @@ 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, message: 'backend says no', }); - await feedbackDriver.handleFeedbackCommand(); + await handleFeedbackCommand(feedbackDriver as any); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('backend says no'); @@ -393,10 +407,10 @@ 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(); + await handleFeedbackCommand(feedbackDriver as any); expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); expect(harness.track).not.toHaveBeenCalledWith('feedback_submitted', undefined); @@ -404,7 +418,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(); @@ -505,7 +519,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' }); @@ -532,7 +545,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); await Promise.resolve(); expect(session.onEvent).toHaveBeenCalledOnce(); @@ -566,7 +579,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); await Promise.resolve(); eventListeners[0]?.({ type: 'mcp.server.status', @@ -624,7 +637,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.startSessionEventSubscription(); + driver.sessionEventHandler.startSubscription(); eventListeners[0]?.({ type: 'mcp.server.status', agentId: 'main', @@ -658,7 +671,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([ @@ -691,7 +704,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'); @@ -705,13 +718,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); @@ -722,12 +735,12 @@ 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.streamingUI.setTurnId('1'); driver.state.queuedMessages = [{ text: 'next' }]; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', agentId: 'main', @@ -741,7 +754,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(); } @@ -753,7 +766,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', @@ -763,11 +776,11 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - const component = driver.state.streamingComponent; + const component = driver.streamingUI.getStreamingBlockComponent(); 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', @@ -777,7 +790,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -803,9 +816,9 @@ describe('KimiTUI message flow', () => { try { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'waiting'; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -815,7 +828,7 @@ describe('KimiTUI message flow', () => { } as Event, sendQueued, ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', agentId: 'main', @@ -836,10 +849,10 @@ describe('KimiTUI message flow', () => { vi.useFakeTimers(); try { const { driver } = await makeDriver(); - driver.state.currentTurnId = '1'; - driver.state.currentStep = 1; + driver.streamingUI.setTurnId('1'); + driver.streamingUI.setStep(1); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.delta', agentId: 'main', @@ -852,13 +865,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.getToolComponent('call_bash')).toBeUndefined(); + expect(driver.streamingUI.hasActiveToolCall('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.getToolComponent('call_bash')).toBeDefined(); + expect(driver.streamingUI.getActiveToolCall('call_bash')?.args).toMatchObject({ command: 'echo hi', }); } finally { @@ -868,7 +881,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', @@ -894,7 +907,7 @@ describe('KimiTUI message flow', () => { try { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'compaction.started', agentId: 'main', @@ -905,7 +918,7 @@ describe('KimiTUI message flow', () => { ); driver.state.queuedMessages = [{ text: 'next' }]; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'compaction.cancelled', agentId: 'main', @@ -956,13 +969,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); @@ -1041,7 +1054,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', @@ -1062,7 +1075,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', @@ -1084,7 +1097,7 @@ describe('KimiTUI message flow', () => { const { driver } = await makeDriver(); driver.state.appState.sessionId = ''; - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'error', agentId: 'main', @@ -1112,7 +1125,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', agentId: 'main', @@ -1166,7 +1179,7 @@ describe('KimiTUI message flow', () => { }); const { driver } = await makeDriver(session); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', agentId: 'main', @@ -1207,7 +1220,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', @@ -1687,8 +1700,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', @@ -1793,13 +1805,10 @@ describe('KimiTUI message flow', () => { it('does not create a thinking component for empty thinking deltas', async () => { const { driver } = await makeDriver(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'thinking'; driver.state.appState.streamingStartTime = 1; - // An empty thinking delta — as emitted by providers like Anthropic - // (signature_delta → think: '') — must not create a ThinkingComponent - // whose spinner would leak past turn end. - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', agentId: 'main', @@ -1809,28 +1818,28 @@ describe('KimiTUI message flow', () => { vi.fn(), ); - expect(driver.state.activeThinkingComponent).toBeUndefined(); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); it('finalizes an orphaned thinking component on turn end', async () => { const { driver } = await makeDriver(); - driver.state.appState.isStreaming = true; + driver.state.appState.streamingPhase = 'thinking'; driver.state.appState.streamingStartTime = 1; const sendQueued = vi.fn(); - // Simulate a ThinkingComponent that leaked into state without - // corresponding thinkingDraft content (the exact scenario that - // could happen without the empty-delta guard above). - const { ThinkingComponent } = await import('../../src/tui/components/messages/thinking'); - driver.state.activeThinkingComponent = new ThinkingComponent( - '', - driver.state.theme.colors, - true, - 'live', - driver.state.ui, + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'leaked', + } as Event, + vi.fn(), ); + driver.streamingUI.flushNow(); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'turn.ended', agentId: 'main', @@ -1841,9 +1850,7 @@ describe('KimiTUI message flow', () => { sendQueued, ); - // flushThinkingToTranscript must finalize the component even when - // thinkingDraft is empty, so the spinner does not outlive the turn. - expect(driver.state.activeThinkingComponent).toBeUndefined(); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); it('renders newly streamed thinking expanded when ctrl+o toggle was already active', async () => { @@ -1851,7 +1858,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', @@ -1860,7 +1867,7 @@ describe('KimiTUI message flow', () => { } as Event, vi.fn(), ); - driver.handleEvent( + driver.sessionEventHandler.handleEvent( { type: 'assistant.delta', agentId: 'main', @@ -1878,7 +1885,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', @@ -1899,7 +1906,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', 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 f8096d6ae..682c6fcd9 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,19 @@ 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/commands/auth"; +import { + promptPlatformSelection, + promptLogoutProviderSelection, +} from "#/tui/commands/prompts"; + +vi.mock("#/tui/commands/prompts", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; +}); import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -185,7 +198,6 @@ describe("KimiTUI startup", () => { sessionId: "ses-1", model: "k2", permissionMode: "yolo", - yolo: true, planMode: true, contextTokens: 25, maxContextTokens: 200, @@ -336,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: "", @@ -382,12 +394,11 @@ describe("KimiTUI startup", () => { sessionId: "", model: "", permissionMode: "yolo", - yolo: true, planMode: true, }); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(1, { workDir: "/tmp/proj-a", @@ -405,7 +416,6 @@ describe("KimiTUI startup", () => { sessionId: "ses-1", model: "k2", permissionMode: "yolo", - yolo: true, planMode: true, }); }); @@ -439,8 +449,8 @@ describe("KimiTUI startup", () => { const driver = makeDriver(harness, makeStartupInput()); await expect(driver.init()).resolves.toBe(false); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); expect(createSession).toHaveBeenNthCalledWith(2, { workDir: "/tmp/proj-a", @@ -451,7 +461,6 @@ describe("KimiTUI startup", () => { }); expect(driver.state.appState).toMatchObject({ permissionMode: "auto", - yolo: false, }); }); @@ -471,8 +480,8 @@ 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'); - await driver.handleLoginCommand(); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); expect(session.setModel).toHaveBeenCalledWith("k2"); expect(session.setThinking).toHaveBeenCalledWith("on"); @@ -504,8 +513,8 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( "managed:kimi-code", @@ -539,8 +548,8 @@ describe("KimiTUI startup", () => { try { await expect(driver.init()).resolves.toBe(false); - vi.spyOn(driver as any, 'promptPlatformSelection').mockResolvedValue('kimi-code'); - await driver.handleLoginCommand(); + vi.mocked(promptPlatformSelection).mockResolvedValue('kimi-code'); + await handleLoginCommand(driver as any); expect(harness.auth.login).toHaveBeenCalledWith( "managed:kimi-code", @@ -588,10 +597,10 @@ 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(); + await handleLogoutCommand(driver as any); expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); expect(session.close).toHaveBeenCalledOnce(); @@ -631,8 +640,8 @@ describe("KimiTUI startup", () => { await expect(driver.init()).resolves.toBe(false); harness.track.mockClear(); - vi.spyOn(driver as any, "promptLogoutProviderSelection").mockResolvedValue("openai"); - await driver.handleLogoutCommand(); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue("openai"); + await handleLogoutCommand(driver as any); expect(removeProvider).toHaveBeenCalledWith("openai"); expect(harness.auth.logout).not.toHaveBeenCalled(); @@ -668,10 +677,10 @@ 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(); + await handleLogoutCommand(driver as any); expect(harness.auth.logout).toHaveBeenCalledWith("managed:kimi-code"); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index c21c8af3e..c4a64fc99 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -11,6 +11,8 @@ 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'; @@ -18,6 +20,8 @@ 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; } @@ -241,9 +245,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.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 () => { @@ -270,9 +274,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.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 () => { @@ -294,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 () => { @@ -388,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 () => {