mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor(tui): split kimi-tui God-class into controllers and command modules (#142)
* refactor(tui): extract TasksBrowserController from KimiTUI
将 /tasks 浏览器相关的 16 个方法(~450 行)从 KimiTUI 提取到独立的
TasksBrowserController。KimiTUI 通过 Host 接口传 this 给 controller,
保留 4 个一行委托方法供外部调用点使用。
kimi-tui.ts: 6160 → 5711 行 (-449)
* refactor(tui): extract StreamingUIController from KimiTUI
将 Streaming Flush(15 个方法)和 Live Render Hooks(15 个方法)
共 ~475 行代码从 KimiTUI 提取到 StreamingUIController。这两个区域
之间紧密耦合(flush 方法直接调用 render hooks),合并提取避免循环依赖。
同时迁移了 5 个实例变量:flushTimer, lastFlushAt, pendingAssistantFlush,
pendingThinkingFlush, pendingToolCallFlushIds。
kimi-tui.ts: 5711 → 5268 行 (-443)
* refactor(tui): extract SessionEventHandler from KimiTUI
将 Session Events(39 个方法)和 Background Task Lifecycle(4 个方法)
共 ~960 行代码从 KimiTUI 提取到 SessionEventHandler。包含事件分发、
turn/step 生命周期、thinking/assistant delta 处理、tool call 管理、
compaction 流程、subagent 管理、MCP 状态渲染、background task 事件。
kimi-tui.ts: 5268 → 4316 行 (-952)
* refactor(tui): extract SessionReplayRenderer from KimiTUI
将 Session Replay 的 21 个方法(~410 行)从 KimiTUI 提取到
SessionReplayRenderer。包含会话历史回放水合(snapshot hydration)、
replay record 渲染、thinking/assistant/tool call 回放、skill 激活、
权限变更、审批结果、background task 通知等逻辑。
kimi-tui.ts: 4316 → 3911 行 (-405)
* refactor(tui): extract slash command handlers from KimiTUI
将 Slash Command Handlers 区域(~765 行)从 KimiTUI 提取到两个文件:
- slash-commands.ts (684 行):命令逻辑(plan/yolo/compact/editor/theme/
model/title/fork/init/login/connect/logout/feedback)
- slash-command-prompts.ts (183 行):UI prompt 函数(platform selection、
logout provider、feedback input、API key、model selector 等)
prompt 函数拆为独立模块避免 ESM 同模块内部调用 mock 不生效的问题。
测试改为 vi.mock 对应的 prompt 模块。
kimi-tui.ts: 3911 → 3163 行 (-748)
* refactor(tui): extract AuthFlowController from KimiTUI
将 Auth / Model Bootstrap 的 6 个方法(~115 行)从 KimiTUI 提取到
AuthFlowController。包含 refreshAvailableModels、
enterLoginRequiredStartupState、activateModelAfterLogin、
clearActiveSessionAfterLogout、refreshConfigAfterLogin、
refreshConfigAfterLogout。
kimi-tui.ts: 3163 → 3063 行 (-100)
* refactor(tui): clean up dead imports after controller extraction
移除 kimi-tui.ts 和 controller 文件中因代码提取而残留的 55+ 个
无用 import(SDK event types、OAuth 工具函数、catalog 类型等)。
kimi-tui.ts: 3063 → 3005 行 (-58)
* refactor(tui): remove delegate methods and direct controller references
Phase A: 删除 14 个零调用者的纯委托方法
Phase B: 内联 8 个少量调用者委托(调用处直接引用 controller)
Phase C: controller 之间通过 host 上的 controller 引用直接协作,
不再绕 kimi-tui 中转(SessionEventHandler → tasksBrowserController,
SlashCommands → authFlow)
kimi-tui.ts: 3005 → 2956 行 (-49)
* refactor(tui): move pickers, config apply, info commands to slash-commands controller
Phase D: 将 pickers(editor/model/theme/permission/settings)、
config apply(applyEditorChoice/applyThemeChoice/applyPermissionChoice/
performModelSwitch/persistModelSelection)、info commands
(showUsage/showStatusReport/showMcpServers + load* helpers)共 ~400 行
从 kimi-tui.ts 搬到 slash-commands controller。
Phase E: handleBuiltInSlashCommand 中所有 case 直接调用 slashCommands.*,
删除全部 13 个 slash command 委托方法。测试改为直接调用 controller 函数。
kimi-tui.ts: 2956 → 2566 行 (-390)
* refactor(tui): final cleanup — remove last delegates and dead imports
删除 finalizeTurn 和 activateModelAfterLogin 委托方法,
slash-commands 直接通过 host.streamingUI / host.authFlow 调用。
清理 15 个因搬运产生的 dead import。
kimi-tui.ts: 2566 → 2545 行 (-21)
* refactor(tui): clean up dead imports and empty import blocks
清理第二轮搬运后残留的 19 个 dead import 和 3 个空 import 块
(api-key-input-dialog、feedback-input-dialog、settings-selector、
feedback constants 等已搬到 slash-commands controller)。
kimi-tui.ts: 2545 → 2517 行 (-28)
* refactor(tui): inline last auth delegate methods
将 refreshAvailableModels 和 enterLoginRequiredStartupState 的
调用方改为直接访问 this.authFlow.*,删除最后两个委托方法。
kimi-tui.ts: 2517 → 2510 行 (-7)
* refactor(tui): move slash command dispatch to slash-commands controller
将 executeSlashCommand(~45 行)和 handleBuiltInSlashCommand(~80 行)
从 kimi-tui.ts 搬到 slash-commands controller。kimi-tui.ts 的
handleUserInput 现在只做空检查、replay guard、历史持久化,
然后调 slashCommands.dispatchInput(this, text)。
kimi-tui.ts: 2510 → 2367 行 (-143)
* refactor(tui): clean up 38 dead imports and 3 dead interfaces
移除因前几轮搬运残留的 import(message-replay、mcp-server-status、
background-*-status、hook-result-format、event-payload utils、
streaming/theme constants 等)和 SessionUsageResult / RuntimeStatusResult /
ManagedUsageResult 接口定义。
kimi-tui.ts: 2367 → 2311 行 (-56)
* refactor(tui): deduplicate combineStartupNotice and isOAuthLoginRequiredError
将 combineStartupNotice(kimi-tui.ts + auth-flow.ts 各一份)和
isOAuthLoginRequiredError 统一到 constant/kimi-tui.ts,两边改为 import。
kimi-tui.ts: 2311 → 2296 行 (-15)
* refactor(tui): move startup utils out of constant/kimi-tui
combineStartupNotice 和 isOAuthLoginRequiredError 是工具函数不是常量,
从 constant/kimi-tui.ts 移到 utils/startup.ts。
* refactor(tui): remove last session event/replay delegate methods
删除 handleEvent、startSessionEventSubscription、
hydrateTranscriptFromReplay 三个委托方法。
kimi-tui 内部和 auth-flow controller 改为直接访问
sessionEventHandler.startSubscription() 和
sessionReplay.hydrateFromReplay()。
测试改为通过 driver.sessionEventHandler 直接调用。
kimi-tui.ts: 2296 → 2278 行 (-18)
* refactor(tui): reduce TUIState fields by removing redundancy and merging pairs
Delete 4 redundant fields (AppState.yolo, AppState.isStreaming,
TUIState.backgroundAgents, TUIState.assistantStreamActive) that were
derivable from existing state. Merge 4 pairs of always-coupled fields
into single objects (streamingBlock, subagentInfo, activitySpinner,
activeDialog). Demote 3 fields only used by KimiTUI itself to private
class fields. Net reduction: AppState 22→20, TUIState 55→48 fields.
* refactor(tui): extract TUIState types and move streaming state into StreamingUIController
Break the type-level circular dependency where controllers imported
TUIState from kimi-tui.ts while kimi-tui.ts imported runtime values
from controllers. Shared types now live in tui-state.ts (TUIState,
createTUIState) and types.ts (KimiTUIOptions, LoginProgressSpinnerHandle,
etc.) so the import graph is strictly unidirectional.
Move 12 streaming-related fields (currentTurnId, currentStep,
assistantDraft, thinkingDraft, streamingBlock, activeThinkingComponent,
activeCompactionBlock, activeToolCalls, streamingToolCallArguments,
pendingToolComponents, pendingAgentGroup, pendingReadGroup) from the
flat TUIState bag into StreamingUIController as instance properties,
along with 3 dispose methods. TUIState shrinks from 40+ to 28 fields.
* refactor(tui): split slash-commands.ts by domain into 4 focused modules
Extract the 1223-line slash-commands.ts into domain-specific files:
- auth-commands.ts (~350 lines): login, logout, connect, OAuth flows
- config-commands.ts (~380 lines): plan, yolo, compact, model/theme/
editor/permission pickers and apply logic
- info-commands.ts (~185 lines): feedback, usage, status, mcp reports
- session-commands.ts (~105 lines): title, fork, init
slash-commands.ts is now a slim routing hub (~210 lines) that owns
SlashCommandHost, dispatchInput, and the builtin command switch. All
public handlers are re-exported so existing consumers are unaffected.
* refactor(tui): move command handlers from controllers/ to commands/
Relocate the 6 command-related files into the existing commands/
directory where parsing and routing logic already lives:
controllers/slash-commands.ts → commands/dispatch.ts
controllers/slash-command-prompts.ts → commands/prompts.ts
controllers/auth-commands.ts → commands/auth.ts
controllers/config-commands.ts → commands/config.ts
controllers/info-commands.ts → commands/info.ts
controllers/session-commands.ts → commands/session.ts
controllers/ now contains only state/lifecycle controllers
(auth-flow, session-event-handler, session-replay, streaming-ui,
tasks-browser). commands/index.ts re-exports all public symbols.
* refactor(tui): move background/render-dedup state into SessionEventHandler
Move 7 fields from TUIState into SessionEventHandler as instance
properties: backgroundAgentMetadata, backgroundTasks,
backgroundTaskTranscriptedTerminal, subagentInfo,
renderedSkillActivationIds, renderedMcpServerStatusKeys,
mcpServerStatusSpinners.
Add resetRuntimeState() to SessionEventHandler that clears all 7
fields in one call. TUIState shrinks from 28 to 21 fields.
TasksBrowserHost.backgroundTasks is now a top-level ReadonlyMap
property instead of being embedded in the state subset, with a
getter on KimiTUI that delegates to sessionEventHandler.
* refactor(tui): encapsulate StreamingUIController internal state behind semantic methods
Make 12 fields private and expose 18 semantic methods instead of letting
SessionEventHandler, SessionReplayRenderer, and KimiTUI directly
manipulate internal maps and counters. Key methods: registerToolCall(),
accumulateToolCallDelta(), completeToolResult(), markStepTruncated(),
appendThinkingDelta(), appendAssistantDelta(), cleanupAfterReplay().
* refactor(tui): extract EditorKeyboardController from KimiTUI
Move editor callback wiring, pending-exit state, clipboard media
handling, and external editor logic into a dedicated controller.
KimiTUI drops from 2070 to 1814 lines.
* refactor(tui): clean up kimi-tui.ts — strip noise comments, reorganize sections
Remove ~70 single-line comments that merely restated the method name.
Condense multi-paragraph inline comments (signal handlers, start()) to
one-liners that capture the WHY. Reorganize sections: merge the one-
method "Layout" section into Lifecycle, rename "Startup Helpers" to
"Autocomplete & Skill Commands", move stray accessors into "State &
Accessors", delete the empty trailing section, relocate input-history
helpers next to each other. 1814 → 1639 lines.
* refactor(tui): route TUIState field mutations through host methods
Controllers may still read host.state freely, but all direct field
assignments now go through setter methods on the host: setStartupReady,
clearQueuedMessages, shiftQueuedMessage, pushTranscriptEntry,
setExternalEditorRunning, setTasksBrowser. This prevents controllers
from silently mutating shared state without KimiTUI's knowledge.
* chore(tui): drop merge analysis docs, add changeset, fix 2 lint errors
- delete docs/refactor-kimi-tui-{analysis,merge-plan}.md (working notes)
- add changeset for the kimi-tui split refactor
- session-event-handler.ts: drop unused `state` destructure in finishCompaction
- editor-keyboard.ts: void-mark fire-and-forget session.cancel() promise
* chore(changeset): simplify wording
This commit is contained in:
parent
50251a1360
commit
dad2b87cee
31 changed files with 5537 additions and 5359 deletions
5
.changeset/refactor-tui-kimi-tui-split.md
Normal file
5
.changeset/refactor-tui-kimi-tui-split.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Refactor TUI code structure.
|
||||
349
apps/kimi-code/src/tui/commands/auth.ts
Normal file
349
apps/kimi-code/src/tui/commands/auth.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}.`);
|
||||
}
|
||||
383
apps/kimi-code/src/tui/commands/config.ts
Normal file
383
apps/kimi-code/src/tui/commands/config.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const command = args.trim();
|
||||
if (command.length === 0) {
|
||||
showEditorPicker(host);
|
||||
return;
|
||||
}
|
||||
await applyEditorChoice(host, command);
|
||||
}
|
||||
|
||||
export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
280
apps/kimi-code/src/tui/commands/dispatch.ts
Normal file
280
apps/kimi-code/src/tui/commands/dispatch.ts
Normal file
|
|
@ -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<AppState>): void;
|
||||
resetLivePane(): void;
|
||||
showError(msg: string): void;
|
||||
showStatus(msg: string, color?: string): void;
|
||||
showNotice(title: string, detail?: string): void;
|
||||
track(event: string, props?: Record<string, unknown>): void;
|
||||
mountEditorReplacement(panel: Component & Focusable): void;
|
||||
restoreEditor(): void;
|
||||
|
||||
// Session
|
||||
requireSession(): Session;
|
||||
switchToSession(session: Session, message: string): Promise<void>;
|
||||
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<void>;
|
||||
showHelpPanel(): void;
|
||||
createNewSession(): Promise<void>;
|
||||
showSessionPicker(): Promise<void>;
|
||||
sendNormalUserInput(text: string): void;
|
||||
sendSkillActivation(session: Session, skillName: string, skillArgs: string): void;
|
||||
readonly skillCommandMap: Map<string, string>;
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
185
apps/kimi-code/src/tui/commands/info.ts
Normal file
185
apps/kimi-code/src/tui/commands/info.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SessionUsageResult> {
|
||||
try {
|
||||
return { usage: await host.requireSession().getUsage() };
|
||||
} catch (error) {
|
||||
return { error: formatErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeStatusResult> {
|
||||
try {
|
||||
return { status: await host.requireSession().getStatus() };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> {
|
||||
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 } };
|
||||
}
|
||||
391
apps/kimi-code/src/tui/commands/plugins.ts
Normal file
391
apps/kimi-code/src/tui/commands/plugins.ts
Normal file
|
|
@ -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<void> {
|
||||
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 <local-path-or-zip-url>');
|
||||
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 <id> <server>');
|
||||
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 <id>');
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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} <server>.`
|
||||
: '';
|
||||
host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`);
|
||||
}
|
||||
|
||||
async function handlePluginsOverviewSelection(
|
||||
host: SlashCommandHost,
|
||||
selection: PluginsOverviewSelection,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
183
apps/kimi-code/src/tui/commands/prompts.ts
Normal file
183
apps/kimi-code/src/tui/commands/prompts.ts
Normal file
|
|
@ -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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string, ModelAlias> = {};
|
||||
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<string, ModelAlias> = {};
|
||||
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<string, ModelAlias>,
|
||||
): 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);
|
||||
});
|
||||
}
|
||||
186
apps/kimi-code/src/tui/commands/session.ts
Normal file
186
apps/kimi-code/src/tui/commands/session.ts
Normal file
|
|
@ -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<void> {
|
||||
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> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
133
apps/kimi-code/src/tui/controllers/auth-flow.ts
Normal file
133
apps/kimi-code/src/tui/controllers/auth-flow.ts
Normal file
|
|
@ -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<AppState>): void;
|
||||
setStartupReady(): void;
|
||||
resetSessionRuntime(): void;
|
||||
setSession(session: Session): Promise<void>;
|
||||
syncRuntimeState(session?: Session): Promise<void>;
|
||||
closeSession(reason: string): Promise<void>;
|
||||
appendStartupNotice(extra: string): void;
|
||||
readonly sessionEventHandler: SessionEventHandler;
|
||||
fetchSessions(): Promise<void>;
|
||||
refreshSessionTitle(): void;
|
||||
refreshSkillCommands(session?: SkillListSession): Promise<void>;
|
||||
}
|
||||
|
||||
export class AuthFlowController {
|
||||
constructor(private readonly host: AuthFlowHost) {}
|
||||
|
||||
async refreshAvailableModels(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.host.closeSession('logged out');
|
||||
this.host.resetSessionRuntime();
|
||||
this.host.setAppState({
|
||||
sessionId: '',
|
||||
model: '',
|
||||
sessionTitle: null,
|
||||
});
|
||||
await this.host.refreshSkillCommands();
|
||||
}
|
||||
|
||||
async refreshConfigAfterLogin(): Promise<void> {
|
||||
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<AppState> = {
|
||||
availableModels,
|
||||
availableProviders,
|
||||
model: defaultModel,
|
||||
maxContextTokens: selected.maxContextSize,
|
||||
};
|
||||
if (config.defaultThinking !== undefined) {
|
||||
appStatePatch.thinking = config.defaultThinking;
|
||||
}
|
||||
host.setAppState(appStatePatch);
|
||||
}
|
||||
|
||||
async refreshConfigAfterLogout(): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
291
apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Normal file
291
apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Normal file
|
|
@ -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<string, unknown>): void;
|
||||
updateEditorBorderHighlight(text?: string): void;
|
||||
updateQueueDisplay(): void;
|
||||
toggleToolOutputExpansion(): void;
|
||||
togglePlanExpansion(): boolean;
|
||||
hideSessionPicker(): void;
|
||||
stop(exitCode?: number): Promise<void>;
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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 <command>.');
|
||||
return;
|
||||
}
|
||||
this.host.setExternalEditorRunning(true);
|
||||
const seed = state.editor.getExpandedText?.() ?? state.editor.getText();
|
||||
state.ui.stop();
|
||||
await new Promise<void>((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);
|
||||
}
|
||||
}
|
||||
}
|
||||
928
apps/kimi-code/src/tui/controllers/session-event-handler.ts
Normal file
928
apps/kimi-code/src/tui/controllers/session-event-handler.ts
Normal file
|
|
@ -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<AppState>): void;
|
||||
patchLivePane(patch: Partial<LivePaneState>): 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<string, BackgroundAgentMetadata> = new Map();
|
||||
backgroundTasks: Map<string, BackgroundTaskInfo> = new Map();
|
||||
backgroundTaskTranscriptedTerminal: Set<string> = new Set();
|
||||
subagentInfo: Map<string, { parentToolCallId: string; name: string }> = new Map();
|
||||
renderedSkillActivationIds: Set<string> = new Set();
|
||||
renderedMcpServerStatusKeys: Map<string, string> = new Map();
|
||||
mcpServerStatusSpinners: Map<string, MoonLoader> = 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<void> {
|
||||
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<AppState> = {};
|
||||
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();
|
||||
}
|
||||
}
|
||||
467
apps/kimi-code/src/tui/controllers/session-replay.ts
Normal file
467
apps/kimi-code/src/tui/controllers/session-replay.ts
Normal file
|
|
@ -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<AppState>): void;
|
||||
showError(msg: string): void;
|
||||
appendTranscriptEntry(entry: TranscriptEntry): void;
|
||||
}
|
||||
|
||||
export class SessionReplayRenderer {
|
||||
constructor(private readonly host: SessionReplayHost) {}
|
||||
|
||||
async hydrateFromReplay(session: Session): Promise<boolean> {
|
||||
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<string, BackgroundTaskInfo>(
|
||||
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<AgentReplayRecord, { type: 'approval_result' }>['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<AgentReplayRecord, { type: 'approval_result' }>['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<PromptOrigin, { kind: 'background_task' }>,
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
750
apps/kimi-code/src/tui/controllers/streaming-ui.ts
Normal file
750
apps/kimi-code/src/tui/controllers/streaming-ui.ts
Normal file
|
|
@ -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<AppState>): void;
|
||||
patchLivePane(patch: Partial<LivePaneState>): void;
|
||||
resetLivePane(): void;
|
||||
updateActivityPane(): void;
|
||||
updateQueueDisplay(): void;
|
||||
requireSession(): Session;
|
||||
deferUserMessages: boolean;
|
||||
shiftQueuedMessage(): QueuedMessage | undefined;
|
||||
pushTranscriptEntry(entry: TranscriptEntry): void;
|
||||
}
|
||||
|
||||
export class StreamingUIController {
|
||||
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private lastFlushAt: number | undefined;
|
||||
private pendingAssistantFlush = false;
|
||||
private pendingThinkingFlush = false;
|
||||
readonly pendingToolCallFlushIds = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, ToolCallBlockData>();
|
||||
private _streamingToolCallArguments = new Map<
|
||||
string,
|
||||
{ name?: string; argumentsText: string; startedAtMs: number }
|
||||
>();
|
||||
private _pendingToolComponents = new Map<string, ToolCallComponent>();
|
||||
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<string>): 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;
|
||||
}
|
||||
}
|
||||
440
apps/kimi-code/src/tui/controllers/tasks-browser.ts
Normal file
440
apps/kimi-code/src/tui/controllers/tasks-browser.ts
Normal file
|
|
@ -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<string, BackgroundTaskInfo>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
100
apps/kimi-code/src/tui/tui-state.ts
Normal file
100
apps/kimi-code/src/tui/tui-state.ts
Normal file
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface LoginProgressSpinnerHandle {
|
||||
stop(opts: { ok: boolean; label: string }): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export interface SkillActivationProjection {
|
|||
}
|
||||
|
||||
export interface ReplayBackgroundProjection {
|
||||
readonly backgroundAgents: ReadonlySet<string>;
|
||||
readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +54,6 @@ export function appStateFromResumeAgent(agent: ResumedAgentState): Partial<AppSt
|
|||
maxContextTokens,
|
||||
contextUsage,
|
||||
planMode: agent.plan !== null,
|
||||
yolo: agent.permission.mode === 'yolo',
|
||||
permissionMode: agent.permission.mode,
|
||||
};
|
||||
}
|
||||
|
|
@ -89,19 +87,17 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap<string, Background
|
|||
export function replayBackgroundProjection(
|
||||
background: readonly BackgroundTaskInfo[],
|
||||
): ReplayBackgroundProjection {
|
||||
const backgroundAgents = new Set<string>();
|
||||
const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>();
|
||||
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 {
|
||||
|
|
|
|||
15
apps/kimi-code/src/tui/utils/startup.ts
Normal file
15
apps/kimi-code/src/tui/utils/startup.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@ function baseState(overrides: Partial<AppState> = {}): 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',
|
||||
|
|
|
|||
|
|
@ -23,14 +23,12 @@ function baseState(overrides: Partial<AppState> = {}): 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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof import('#/tui/commands/prompts')>();
|
||||
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<boolean>;
|
||||
handleUserInput(text: string): void;
|
||||
handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void;
|
||||
persistInputHistory(text: string): Promise<void>;
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<typeof import("#/tui/commands/prompts")>();
|
||||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
switchToSession(session: Session, statusMessage: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue