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 () => {