diff --git a/.changeset/add-langsearch-web-search.md b/.changeset/add-langsearch-web-search.md new file mode 100644 index 0000000000..307f0eebf6 --- /dev/null +++ b/.changeset/add-langsearch-web-search.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add configurable LangSearch web search and optional semantic reranking. Configure it under Settings → Web Search or with `kimi search`. diff --git a/.changeset/dynamic-workflows.md b/.changeset/dynamic-workflows.md new file mode 100644 index 0000000000..14f8991573 --- /dev/null +++ b/.changeset/dynamic-workflows.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental dynamic workflows: multi-phase subagent orchestration from user-approved JS scripts, with background runs, progress tracking, cancellation, and project/user-level reuse. Enable with KIMI_CODE_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1, then run /workflow. + +Also add the `/workflow on|off` mode that instructs the agent to propose dynamic workflows for large tasks, dynamic argument autocomplete listing available workflows after `/workflow run`, a `wf` mode badge in the footer, and workflow visibility in the `/tasks` browser. diff --git a/.gitignore b/.gitignore index e62f42128f..cd8a3089c0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,8 @@ docs/superpowers/ reports/ .superpowers/ /plan/ - +app-kimi/ +app-gpt/ # Agent scratch / throwaway files - do not commit .tmp/ HANDOVER*.md diff --git a/.oxlintrc.json b/.oxlintrc.json index 003359f31d..8ab815f85f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -142,6 +142,14 @@ "vitest/no-standalone-expect": "error", "vitest/warn-todo": "off" } + }, + { + "files": ["scripts/use-personal-build.mjs"], + "rules": { + "eslint/no-console": "off", + "unicorn/prefer-top-level-await": "off", + "unicorn/prefer-import-meta-properties": "off" + } } ], "ignorePatterns": [ diff --git a/PERSONAL_BUILD.md b/PERSONAL_BUILD.md new file mode 100644 index 0000000000..7fe798a39d --- /dev/null +++ b/PERSONAL_BUILD.md @@ -0,0 +1,73 @@ +# Build pessoal do Kimi Code + +Este guia explica como compilar e usar o Kimi Code diretamente da source deste fork, sem depender do binário oficial da Moonshot. + +## Quando usar + +- Você quer rodar sua própria build a partir da branch `personal`. +- Você quer que o comando `kimi` aponte sempre para o executável gerado neste repositório. +- Você quer evitar que o atualizador oficial substitua seu binário personalizado. + +## Pré-requisitos + +- Node.js `>= 24.15.0` +- pnpm `10.33.0` (o repositório usa `corepack`) +- Sistema: Linux x64 ou Windows x64 + +## Comando principal + +Na raiz do repositório, na branch `personal`: + +```bash +node scripts/use-personal-build.mjs +``` + +O script vai: + +1. Validar Node.js, pnpm e plataforma. +2. Instalar dependências (`pnpm install --frozen-lockfile`). +3. Compilar os assets web. +4. Compilar o executável nativo SEA (`build:native:sea`). +5. Rodar o smoke test nativo. +6. Substituir `~/.kimi-code/bin/kimi` por um launcher que aponta para o binário deste clone. +7. Desativar atualizações oficiais via `KIMI_CODE_NO_AUTO_UPDATE=1`. +8. Garantir que `~/.kimi-code/bin` esteja no PATH. + +## Dry run + +Para ver o que seria feito sem alterar nada: + +```bash +node scripts/use-personal-build.mjs --dry-run +``` + +## Atualizar depois de puxar do upstream + +```bash +git fetch upstream +git rebase upstream/main # ou merge, se preferir +node scripts/use-personal-build.mjs +``` + +## Usar em outro computador + +1. Clone este fork e entre na branch `personal`. +2. Instale Node.js `>= 24.15.0` e ative o corepack: + ```bash + corepack enable + corepack prepare pnpm@10.33.0 --activate + ``` +3. Rode: + ```bash + node scripts/use-personal-build.mjs + ``` + +Suas configurações (`~/.kimi-code/config.toml`), credenciais, sessões e plugins serão preservadas. + +## Por que não `pnpm build`? + +O comando `pnpm build` do root compila pacotes e assets, mas **não gera o executável nativo SEA**. Para produzir o binário `kimi`/`kimi.exe` é necessário rodar `build:native:sea`, que é exatamente o que o script `use-personal-build.mjs` faz, seguindo o mesmo fluxo do workflow oficial de release nativa. + +## Windows + +No Windows o script remove `~/.kimi-code/bin/kimi.exe` e cria `~/.kimi-code/bin/kimi.cmd` apontando para o `.exe` gerado no clone. A atualização do PATH é feita via PowerShell. Abra um novo terminal após rodar o script. diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs index df46acc110..0e7c341cce 100644 --- a/apps/kimi-code/scripts/native/01-bundle.mjs +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -1,5 +1,6 @@ import { createRequire } from 'node:module'; import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { run } from './exec.mjs'; @@ -18,6 +19,6 @@ export async function runBundleStep() { await run(process.execPath, [checkBundlePath]); } -if (import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { await runBundleStep(); } diff --git a/apps/kimi-code/scripts/native/03-inject.mjs b/apps/kimi-code/scripts/native/03-inject.mjs index 24cd0ad32d..1e728ef2c8 100644 --- a/apps/kimi-code/scripts/native/03-inject.mjs +++ b/apps/kimi-code/scripts/native/03-inject.mjs @@ -1,4 +1,4 @@ -import { copyFile, mkdir, stat } from 'node:fs/promises'; +import { copyFile, mkdir, rm, stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fail, run, tryRun } from './exec.mjs'; @@ -27,6 +27,10 @@ async function ensureBlobExists() { async function copyNodeExecutable(target) { await mkdir(nativeBinDir(target), { recursive: true }); const out = nativeBinPath(target); + // Unlink first: overwriting a currently-running binary fails with ETXTBSY on + // Linux. Removing the path detaches the running process from it (it keeps + // the old inode), so the fresh copy can be written. + await rm(out, { force: true }); await copyFile(process.execPath, out); if (process.platform !== 'win32') { await run('chmod', ['755', out]); diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 6ed5551939..8088c7dbc7 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerSearchCommand } from './sub/search'; import { registerVisCommand } from './sub/vis'; import { registerWebCommand } from './sub/web'; @@ -115,6 +116,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); + registerSearchCommand(program); registerAcpCommand(program); registerWebCommand(program); registerLoginCommand(program); diff --git a/apps/kimi-code/src/cli/sub/search.ts b/apps/kimi-code/src/cli/sub/search.ts new file mode 100644 index 0000000000..e5c1182823 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/search.ts @@ -0,0 +1,367 @@ +/** + * `kimi search` sub-command — non-interactive web search backend management. + * + * Mirrors the TUI `/settings` → Web Search flow + * (apps/kimi-code/src/tui/commands/web-search.ts) for users who want to + * inspect or change the LangSearch / rerank configuration without launching + * the TUI. + * + * - `status` Show the active web search backend and rerank status. + * - `set langsearch` Write a `[services.langsearch]` section. + * - `clear langsearch` Remove the `[services.langsearch]` section. + * - `set rerank` Write a `[services.rerank]` section. + * - `clear rerank` Remove the `[services.rerank]` section. + * - `limits` Print the LangSearch tier rate-limit table. + */ + +import { + createKimiHarness, + type KimiConfig, + type KimiHarness, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface SearchDeps { + readonly getHarness: () => KimiHarness; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +interface SetLangSearchOptions { + readonly apiKey?: string; + readonly tier?: string; + readonly count?: string; +} + +interface SetRerankOptions { + readonly provider?: string; + readonly apiKey?: string; + readonly enabled?: string; +} + +const LANGSEARCH_TIERS = ['free', 'tier1', 'tier2', 'tier3'] as const; +type LangSearchTier = (typeof LANGSEARCH_TIERS)[number]; + +const RERANK_PROVIDERS = ['langsearch'] as const; +type RerankProvider = (typeof RERANK_PROVIDERS)[number]; + +const LANGSEARCH_EXPERIMENTAL_FLAG = 'langsearch-web-search'; +const LANGSEARCH_EXPERIMENTAL_MESSAGE = + 'LangSearch web search is experimental. Enable it in Settings → Experiments or set [experimental].langsearch-web-search = true.\n'; + +interface TierLimit { + readonly qps: number; + readonly qpm: number; + readonly qpd: number; +} + +// Rate limits reflect LangSearch's published per-tier quotas. +const TIER_LIMITS: Record = { + free: { qps: 1, qpm: 60, qpd: 1_000 }, + tier1: { qps: 5, qpm: 200, qpd: 2_000 }, + tier2: { qps: 10, qpm: 500, qpd: 10_000 }, + tier3: { qps: 30, qpm: 2_000, qpd: 100_000 }, +}; + +function isLangSearchTier(value: string): value is LangSearchTier { + return (LANGSEARCH_TIERS as readonly string[]).includes(value); +} + +function isRerankProvider(value: string): value is RerankProvider { + return (RERANK_PROVIDERS as readonly string[]).includes(value); +} + +export async function handleSearchStatus(deps: SearchDeps): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const [config, features] = await Promise.all([ + harness.getConfig(), + harness.getExperimentalFeatures(), + ]); + const services = config.services ?? {}; + const langSearchEnabled = isExperimentalEnabled(features); + const backend = activeBackend(services, langSearchEnabled); + deps.stdout.write(`Web search backend: ${backend}\n`); + const langsearch = services.langsearch; + if (hasValue(langsearch?.apiKey)) { + deps.stdout.write( + `LangSearch: tier=${langsearch?.tier ?? 'free'} count=${String(langsearch?.count ?? 10)}${langSearchEnabled ? '' : ' status=experimental feature disabled'}\n`, + ); + } + const rerank = services.rerank; + if (rerank?.provider !== undefined) { + const hasApiKey = hasValue(rerank.apiKey) || hasValue(services.langsearch?.apiKey); + const status = !langSearchEnabled + ? 'experimental feature disabled' + : rerank.enabled === false + ? 'disabled' + : hasApiKey + ? 'enabled' + : 'missing API key'; + deps.stdout.write(`Rerank: ${status} (provider: ${rerank.provider})\n`); + } else { + deps.stdout.write('Rerank: not configured\n'); + } +} + +export async function handleSearchSetLangSearch( + deps: SearchDeps, + opts: SetLangSearchOptions, +): Promise { + const apiKey = opts.apiKey; + if (apiKey === undefined || apiKey.length === 0) { + deps.stderr.write('Missing API key. Pass --api-key .\n'); + deps.exit(1); + } + + const tier = opts.tier ?? 'free'; + if (!isLangSearchTier(tier)) { + deps.stderr.write( + `Invalid tier "${opts.tier}". Expected one of: ${LANGSEARCH_TIERS.join(', ')}.\n`, + ); + deps.exit(1); + } + + const count = opts.count === undefined ? 10 : parseCount(opts.count, deps); + if (count === undefined) return; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + await requireLangSearchExperimental(harness, deps); + + await harness.replaceService('langsearch', { apiKey, tier, count }); + + deps.stdout.write( + `LangSearch configured: tier=${tier} count=${String(count)}\n`, + ); +} + +export async function handleSearchSetRerank( + deps: SearchDeps, + opts: SetRerankOptions, +): Promise { + const provider = opts.provider ?? 'langsearch'; + if (!isRerankProvider(provider)) { + deps.stderr.write( + `Unknown rerank provider "${opts.provider}". Only "langsearch" is supported.\n`, + ); + deps.exit(1); + } + + const enabled = opts.enabled === undefined ? true : parseBool(opts.enabled, deps, '--enabled'); + if (enabled === undefined) return; + + const apiKey = opts.apiKey; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + await requireLangSearchExperimental(harness, deps); + const config = await harness.getConfig(); + if (enabled && !hasValue(apiKey) && !hasValue(config.services?.langsearch?.apiKey)) { + deps.stderr.write( + 'Missing API key. Pass --api-key or configure LangSearch web search first.\n', + ); + deps.exit(1); + } + + await harness.replaceService('rerank', { + enabled, + provider, + apiKey: hasValue(apiKey) ? apiKey : undefined, + }); + + deps.stdout.write( + `Rerank configured: provider=${provider} enabled=${String(enabled)}${apiKey && apiKey.length > 0 ? ' api_key=set' : ' api_key=reuse-langsearch'}\n`, + ); +} + +export async function handleSearchClear( + deps: SearchDeps, + provider: string, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + const services = config.services ?? {}; + + if (provider === 'langsearch') { + if (services.langsearch === undefined) { + deps.stdout.write('LangSearch is not configured.\n'); + return; + } + await harness.removeService('langsearch'); + deps.stdout.write('LangSearch web search cleared.\n'); + return; + } + + if (provider === 'rerank') { + if (services.rerank === undefined) { + deps.stdout.write('Rerank is not configured.\n'); + return; + } + await harness.removeService('rerank'); + deps.stdout.write('Rerank configuration cleared.\n'); + return; + } + + deps.stderr.write( + `Unknown provider "${provider}". Use "langsearch" or "rerank".\n`, + ); + deps.exit(1); +} + +export function handleSearchLimits(deps: SearchDeps): void { + deps.stdout.write('LangSearch tier rate limits:\n\n'); + deps.stdout.write(' tier qps qpm qpd\n'); + for (const tier of LANGSEARCH_TIERS) { + const limit = TIER_LIMITS[tier]; + deps.stdout.write( + ` ${tier.padEnd(7)} ${String(limit.qps).padStart(3)} ${String(limit.qpm).padStart(5)} ${String(limit.qpd).padStart(6)}\n`, + ); + } +} + +function activeBackend( + services: NonNullable, + langSearchEnabled: boolean, +): string { + if (langSearchEnabled && hasValue(services.langsearch?.apiKey)) return 'LangSearch'; + if (hasValue(services.moonshotSearch?.baseUrl)) return 'Moonshot'; + if (hasValue(services.langsearch?.apiKey)) { + return 'not configured (LangSearch experimental feature disabled)'; + } + return 'not configured'; +} + +function isExperimentalEnabled( + features: Awaited>, +): boolean { + return features.some( + (feature) => feature.id === LANGSEARCH_EXPERIMENTAL_FLAG && feature.enabled, + ); +} + +async function requireLangSearchExperimental( + harness: KimiHarness, + deps: SearchDeps, +): Promise { + if (isExperimentalEnabled(await harness.getExperimentalFeatures())) return; + deps.stderr.write(LANGSEARCH_EXPERIMENTAL_MESSAGE); + deps.exit(1); +} + +function hasValue(value: string | undefined): boolean { + return value !== undefined && value.trim().length > 0; +} + +function parseCount(value: string, deps: SearchDeps): number | undefined { + const n = Number(value); + if (!Number.isInteger(n) || n < 1 || n > 10) { + deps.stderr.write(`Invalid --count "${value}". Expected an integer between 1 and 10.\n`); + deps.exit(1); + } + return n; +} + +function parseBool( + value: string, + deps: SearchDeps, + flag: string, +): boolean | undefined { + if (value === 'true') return true; + if (value === 'false') return false; + deps.stderr.write(`Invalid ${flag} "${value}". Expected "true" or "false".\n`); + deps.exit(1); +} + +export function registerSearchCommand(parent: Command, deps?: Partial): void { + const search = parent + .command('search') + .description('Manage the web search backend and rerank (LangSearch) non-interactively.'); + + const runAction = async (resolved: SearchDeps, run: () => Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + + search + .command('status') + .description('Show the active web search backend and rerank status.') + .action(async () => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchStatus(resolved)); + }); + + const setCmd = search + .command('set') + .description('Configure a web search provider or rerank.'); + + setCmd + .command('langsearch') + .description('Configure the LangSearch web search backend.') + .requiredOption('--api-key ', 'API key for the provider.') + .option('--tier ', 'LangSearch tier: free | tier1 | tier2 | tier3.', 'free') + .option('--count ', 'Number of results to request (1–10).', '10') + .action(async (options: SetLangSearchOptions) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchSetLangSearch(resolved, options)); + }); + + setCmd + .command('rerank') + .description('Configure the rerank provider.') + .option('--provider ', 'Rerank provider: langsearch.', 'langsearch') + .option('--api-key ', 'API key for rerank. Omit to reuse the LangSearch search key.') + .option('--enabled ', 'Enable rerank: true | false.', 'true') + .action(async (options: SetRerankOptions) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchSetRerank(resolved, options)); + }); + + search + .command('clear ') + .description('Remove a web search provider or rerank config. Use "langsearch" or "rerank".') + .action(async (provider: string) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchClear(resolved, provider)); + }); + + search + .command('limits') + .description('Show the LangSearch tier rate-limit table.') + .action(() => { + const resolved = resolveDeps(deps); + handleSearchLimits(resolved); + }); +} + +function resolveDeps(overrides: Partial = {}): SearchDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + return { + getHarness: + overrides.getHarness ?? + (() => { + harness ??= createKimiHarness({ identity }); + return harness; + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} \ No newline at end of file diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..296c894826 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -27,6 +27,7 @@ import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; +import { showWebSearchConfig } from './web-search'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -786,5 +787,6 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'experiments': void showExperimentsPanel(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; + case 'webSearch': void showWebSearchConfig(host); return; } } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..752316e040 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -53,6 +53,7 @@ import { handleTitleCommand, } from './session'; import { handleSwarmCommand } from './swarm'; +import { handleWorkflowCommand } from './workflow'; import { handleUndoCommand } from './undo'; import { handleWebCommand } from './web'; @@ -348,6 +349,9 @@ async function handleBuiltInSlashCommand( case 'goal': await handleGoalCommand(host, args); return; + case 'workflow': + await handleWorkflowCommand(host, args); + return; case 'init': await handleInitCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 7449dba9bc..1507043856 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -23,6 +23,7 @@ export { showSettingsSelector, } from './config'; export { handleSwarmCommand } from './swarm'; +export { handleWorkflowCommand } from './workflow'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index cbfc33072f..2160d019a1 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -114,6 +114,7 @@ export function promptApiKey( host: SlashCommandHost, platformName: string, subtitleLines: readonly string[] = ['Your key will be saved to ~/.kimi-code/config.toml'], + options: { readonly allowEmpty?: boolean } = {}, ): Promise { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( @@ -123,6 +124,7 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, + options, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..dfa67a7e51 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -5,7 +5,11 @@ import { basename, dirname, join, relative, resolve } from 'pathe'; import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; -import type { KimiSlashCommand, SlashCommandAvailability } from './types'; +import type { + KimiSlashCommand, + SlashCommandAvailability, + SlashCommandCompletionContext, +} from './types'; /** Subcommands offered when autocompleting `/goal <…>`. */ const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ @@ -31,7 +35,10 @@ const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ ]; /** Argument autocompletion for the `/goal` command (subcommands). */ -export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { +export function goalArgumentCompletions( + argumentPrefix: string, + _context?: SlashCommandCompletionContext, +): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); if (nextMatch !== null) { return ( @@ -44,13 +51,92 @@ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteIte return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); } +/** Subcommands offered when autocompleting `/workflow <…>`. */ +const WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'List discovered workflows' }, + { value: 'run', description: 'Run a workflow by name (with confirmation)' }, + { value: 'runs', description: 'Browse workflow runs' }, + { value: 'show', description: 'View a workflow script' }, + { value: 'cancel', description: 'Cancel a running workflow' }, + { value: 'save', description: 'Save a run as a reusable workflow' }, + { value: 'reload', description: 'Rediscover workflows from disk' }, + { value: 'on', description: 'Enable dynamic workflow mode' }, + { value: 'off', description: 'Disable dynamic workflow mode' }, +]; + +const WORKFLOW_NAME_SUBCOMMANDS = new Set(['run', 'show']); + +/** + * Autocompletion for `/workflow`: subcommands as the first token, then for + * `run`/`show`/`cancel`/`save`, workflow names (or run-id prefixes for + * cancel/save) are suggested from the session. The inserted value includes + * the subcommand so the provider replaces the whole argument prefix. + */ +export async function workflowArgumentCompletions( + argumentPrefix: string, + context?: SlashCommandCompletionContext, +): Promise { + // No space yet → static subcommand list. Don't auto-complete when the + // argument is empty so pressing Tab on `/workflows` doesn't select `list`. + if (!argumentPrefix.includes(' ')) { + if (argumentPrefix.length === 0) return null; + return completeLeadingArg(WORKFLOW_ARG_COMPLETIONS, argumentPrefix); + } + + const [subcommand, ...rest] = argumentPrefix.split(/\s+/); + const namePrefix = rest.join(' ').trimStart(); + + // `cancel`/`save` complete on run-id prefixes, which we don't have statically + // (they come from listWorkflowRuns at runtime). Leave them to the user. + if (subcommand === 'cancel' || subcommand === 'save') return null; + + if (!WORKFLOW_NAME_SUBCOMMANDS.has(subcommand ?? '')) return null; + + // After the name is complete and the user starts typing args, stop completing. + if (namePrefix.includes(' ')) return null; + + const session = context?.session; + if (session === undefined) return null; + + let workflows: readonly { name: string; description: string; whenToUse?: string; argumentHint?: string }[]; + try { + workflows = (await session.listWorkflows()).workflows; + } catch { + return null; + } + if (workflows.length === 0) return null; + + const prefix = namePrefix.toLowerCase(); + const matches = workflows.filter((w) => w.name.toLowerCase().startsWith(prefix)); + if (matches.length === 0) return null; + + return matches.map((w) => { + const hint = w.argumentHint !== undefined ? ` ${w.argumentHint}` : ''; + const desc = w.whenToUse ?? w.description; + // When completing the `run` subcommand, add a trailing space so the user + // can immediately type arguments after selecting a workflow name. + const trailingSpace = subcommand === 'run' ? ' ' : ''; + return { + value: `${subcommand} ${w.name}${trailingSpace}`, + label: `${w.name}${hint}`, + description: desc.length > 120 ? desc.slice(0, 117) + '…' : desc, + }; + }); +} + /** Argument autocompletion for the `/swarm` command (subcommands). */ -export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { +export function swarmArgumentCompletions( + argumentPrefix: string, + _context?: SlashCommandCompletionContext, +): AutocompleteItem[] | null { return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); } /** Argument autocompletion for the `/add-dir` command. */ -export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { +export function addDirArgumentCompletions( + argumentPrefix: string, + _context?: SlashCommandCompletionContext, +): AutocompleteItem[] | null { if (isPathLikeAddDirArgument(argumentPrefix)) { return completeAddDirPath(argumentPrefix); } @@ -231,6 +317,16 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 80, availability: 'always', }, + { + name: 'workflow', + aliases: ['workflows'], + description: 'Run and manage dynamic workflows', + priority: 80, + argumentHint: '[list|run|runs|show|cancel|save|reload|on|off] …', + completeArgs: workflowArgumentCompletions, + availability: 'always', + experimentalFlag: 'dynamic-workflows', + }, { name: 'mcp', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 1ec3c68352..5db1aa34d2 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,8 +1,17 @@ import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; -import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; +import type { FlagId, Session } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; +/** + * Context passed to `completeArgs` callbacks so they can fetch dynamic data + * (e.g. the list of workflows from the session) without reaching into the + * editor component themselves. + */ +export interface SlashCommandCompletionContext { + readonly session?: Pick; +} + export interface KimiSlashCommand extends SlashCommand { readonly name: Name; readonly aliases: readonly string[]; @@ -17,7 +26,10 @@ export interface KimiSlashCommand extends SlashCom * property (not a method) so passing it around is `this`-free. Adapted to * pi-tui's `getArgumentCompletions` in the autocomplete setup. */ - readonly completeArgs?: (argumentPrefix: string) => AutocompleteItem[] | null; + readonly completeArgs?: ( + argumentPrefix: string, + context: SlashCommandCompletionContext, + ) => AutocompleteItem[] | null | Promise; } export interface ParsedSlashInput { diff --git a/apps/kimi-code/src/tui/commands/web-search.ts b/apps/kimi-code/src/tui/commands/web-search.ts new file mode 100644 index 0000000000..efe023222e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/web-search.ts @@ -0,0 +1,631 @@ +import { + KIMI_CODE_PROVIDER_NAME, + OPEN_PLATFORMS, +} from '@moonshot-ai/kimi-code-oauth'; +import type { + KimiConfig, + MoonshotServiceConfig, + RerankServiceConfig, + ServicesConfig, +} from '@moonshot-ai/kimi-code-sdk'; + +import { + ChoicePickerComponent, + type ChoiceOption, +} from '../components/dialogs/choice-picker'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; +import { isExperimentalFlagEnabled } from './experimental-flags'; +import { promptApiKey } from './prompts'; + +// --------------------------------------------------------------------------- +// /settings → Web Search — search and rerank provider configuration +// --------------------------------------------------------------------------- + +const LANGSEARCH_EXPERIMENTAL_FLAG = 'langsearch-web-search'; +const ROOT_SEARCH_PROVIDER = 'search-provider'; +const ROOT_RERANK_PROVIDER = 'rerank-provider'; + +const SEARCH_PROVIDER_VALUES = ['moonshot', 'langsearch'] as const; +type SearchProviderChoice = (typeof SEARCH_PROVIDER_VALUES)[number]; + +const TIER_VALUES = ['free', 'tier1', 'tier2', 'tier3'] as const; +type LangSearchTier = (typeof TIER_VALUES)[number]; + +const RERANK_PROVIDER_VALUES = ['langsearch'] as const; +type RerankProviderChoice = (typeof RERANK_PROVIDER_VALUES)[number]; + +const RERANK_TOGGLE_VALUES = ['enabled', 'disabled'] as const; +type RerankToggle = (typeof RERANK_TOGGLE_VALUES)[number]; + +interface PickerOptions { + readonly title: string; + readonly options: readonly ChoiceOption[]; + readonly currentValue?: string; + readonly notice?: string; + readonly noticeTone?: 'success' | 'warning'; +} + +interface MoonshotOAuthSource { + readonly baseUrl: string; + readonly oauth: NonNullable; +} + +/** Settings → Web Search entry with current provider state shown at the top. */ +export async function showWebSearchConfig(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const services = config.services ?? {}; + const summary = currentProviderSummary(services); + const action = await pickChoice(host, { + title: 'Web Search', + notice: `${summary.search}\n${summary.rerank}`, + noticeTone: summary.hasWarning ? 'warning' : 'success', + options: [ + { + value: ROOT_SEARCH_PROVIDER, + label: 'Web search provider', + description: 'Configure Moonshot or LangSearch for web search.', + }, + { + value: ROOT_RERANK_PROVIDER, + label: 'Rerank provider', + description: 'Configure and manage semantic reranking.', + }, + ], + }); + if (action === ROOT_SEARCH_PROVIDER) { + await showSearchProviderMenu(host); + } else if (action === ROOT_RERANK_PROVIDER) { + await showRerankProviderMenu(host); + } +} + +async function showSearchProviderMenu(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const services = config.services ?? {}; + const current = currentSearchProvider(services); + const selected = await pickChoice(host, { + title: 'Web search provider', + currentValue: current, + options: [ + { + value: 'moonshot', + label: 'Moonshot', + description: 'Configure a Moonshot API key.', + }, + { + value: 'langsearch', + label: 'LangSearch', + description: isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) + ? 'Use the LangSearch Web Search API.' + : 'Enable LangSearch web search under Settings → Experiments first.', + }, + ], + }); + if (!isSearchProviderChoice(selected)) return; + if ( + selected === 'langsearch' && + !isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) + ) { + showLangSearchExperimentalNotice(host); + return; + } + + if (selected === current) { + await manageSearchProvider(host, selected); + } else { + await configureSearchProvider(host, selected); + } +} + +async function manageSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + const label = searchProviderLabel(provider); + const action = await pickChoice(host, { + title: `${label} web search`, + options: [ + { + value: 'edit', + label: 'Edit configuration', + description: `Replace the current ${label} search settings.`, + }, + { + value: 'remove', + label: 'Remove provider', + description: 'Remove this web search provider configuration.', + tone: 'danger', + }, + ], + }); + if (action === 'edit') { + await configureSearchProvider(host, provider); + } else if (action === 'remove') { + await removeSearchProvider(host, provider); + } +} + +async function configureSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + if (provider === 'langsearch') { + await configureLangSearch(host); + } else { + await configureMoonshot(host); + } +} + +async function configureLangSearch(host: SlashCommandHost): Promise { + const apiKey = await promptApiKey(host, 'LangSearch', [ + 'Your key will be saved to ~/.kimi-code/config.toml under [services.langsearch].', + ]); + if (apiKey === undefined) return; + + const tier = await pickTier(host); + if (tier === undefined) return; + + try { + await host.harness.replaceService('langsearch', { + apiKey, + tier, + }); + await reloadSessionAfterWebSearchChange(host, 'LangSearch web search configured.'); + } catch (error) { + host.showError(`Failed to save LangSearch config: ${formatErrorMessage(error)}`); + } +} + +async function configureMoonshot(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const oauthSource = findMoonshotOAuthSource(config); + const options: ChoiceOption[] = []; + if (oauthSource !== undefined) { + options.push({ + value: 'oauth', + label: 'Kimi Code OAuth', + description: 'Reuse the credentials from your existing Kimi Code login.', + }); + } + options.push({ + value: 'api-key', + label: 'Moonshot API key', + description: 'Configure Moonshot Search using an API key.', + }); + + const authMethod = await pickChoice(host, { + title: 'Moonshot authentication', + currentValue: + config.services?.moonshotSearch?.oauth !== undefined ? 'oauth' : undefined, + options, + }); + if (authMethod === 'oauth' && oauthSource !== undefined) { + await saveMoonshotConfig(host, { + baseUrl: oauthSource.baseUrl, + apiKey: '', + oauth: oauthSource.oauth, + }); + } else if (authMethod === 'api-key') { + await configureMoonshotApiKey(host); + } +} + +async function configureMoonshotApiKey(host: SlashCommandHost): Promise { + const platformId = await pickChoice(host, { + title: 'Moonshot API region', + options: OPEN_PLATFORMS.map((platform) => ({ + value: platform.id, + label: platform.name, + description: platform.baseUrl, + })), + }); + const platform = OPEN_PLATFORMS.find((candidate) => candidate.id === platformId); + if (platform === undefined) return; + + const apiKey = await promptApiKey(host, platform.name, [ + `${'search URL'.padEnd(12)}${searchUrlFromBaseUrl(platform.baseUrl)}`, + `${'saved to'.padEnd(12)}~/.kimi-code/config.toml`, + ]); + if (apiKey === undefined) return; + + await saveMoonshotConfig(host, { + baseUrl: searchUrlFromBaseUrl(platform.baseUrl), + apiKey, + }); +} + +async function saveMoonshotConfig( + host: SlashCommandHost, + service: MoonshotServiceConfig, +): Promise { + try { + const config = await host.harness.getConfig(); + const langsearch = config.services?.langsearch; + const rerank = config.services?.rerank; + await host.harness.replaceService('moonshotSearch', service); + if ( + rerank?.provider === 'langsearch' && + !isNonEmpty(rerank.apiKey) && + isNonEmpty(langsearch?.apiKey) + ) { + await host.harness.replaceService('rerank', { + ...rerank, + apiKey: langsearch.apiKey, + }); + } + if (langsearch !== undefined) { + await host.harness.removeService('langsearch'); + } + await reloadSessionAfterWebSearchChange(host, 'Moonshot web search configured.'); + } catch (error) { + host.showError(`Failed to save Moonshot config: ${formatErrorMessage(error)}`); + } +} + +async function removeSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + try { + await host.harness.removeService( + provider === 'moonshot' ? 'moonshotSearch' : 'langsearch', + ); + await reloadSessionAfterWebSearchChange( + host, + `${searchProviderLabel(provider)} web search removed.`, + ); + } catch (error) { + host.showError( + `Failed to remove ${searchProviderLabel(provider)}: ${formatErrorMessage(error)}`, + ); + } +} + +async function showRerankProviderMenu(host: SlashCommandHost): Promise { + if (!isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG)) { + showLangSearchExperimentalNotice(host); + return; + } + const config = await host.harness.getConfig(); + const rerank = config.services?.rerank; + const selected = await pickChoice(host, { + title: 'Rerank provider', + currentValue: rerank?.provider, + options: [ + { + value: 'langsearch', + label: 'LangSearch', + description: 'Reorder web search results using semantic relevance.', + }, + ], + }); + if (!isRerankProviderChoice(selected)) return; + + if (rerank?.provider === selected) { + await editRerankProvider(host, rerank); + } else { + await setupRerankProvider(host, selected); + } +} + +async function setupRerankProvider( + host: SlashCommandHost, + provider: RerankProviderChoice, +): Promise { + const apiKey = await promptRerankApiKey(host); + if (apiKey === undefined) return; + const config = await host.harness.getConfig(); + if (!isNonEmpty(apiKey) && !isNonEmpty(config.services?.langsearch?.apiKey)) { + host.showError( + 'A LangSearch API key is required when the search provider is not LangSearch.', + ); + return; + } + + const toggle = await pickRerankToggle(host); + if (toggle === undefined) return; + + try { + await host.harness.replaceService('rerank', { + enabled: toggle === 'enabled', + provider, + apiKey: isNonEmpty(apiKey) ? apiKey : undefined, + }); + await reloadSessionAfterWebSearchChange(host, 'Rerank configured.'); + } catch (error) { + host.showError(`Failed to save rerank config: ${formatErrorMessage(error)}`); + } +} + +async function editRerankProvider( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const action = await pickChoice(host, { + title: 'LangSearch rerank', + notice: `Current status: ${rerank.enabled === false ? 'disabled' : 'enabled'}`, + options: [ + { + value: 'status', + label: 'Status', + description: rerank.enabled === false ? 'Disabled' : 'Enabled', + }, + { + value: 'api-key', + label: 'API key', + description: + isNonEmpty(rerank.apiKey) + ? 'A dedicated rerank API key is configured.' + : 'Reuses the LangSearch web search API key.', + }, + { + value: 'remove', + label: 'Remove provider', + description: 'Delete the rerank provider configuration.', + tone: 'danger', + }, + ], + }); + + if (action === 'status') { + await editRerankStatus(host, rerank); + } else if (action === 'api-key') { + await editRerankApiKey(host, rerank); + } else if (action === 'remove') { + await removeRerankProvider(host); + } +} + +async function editRerankStatus( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const current: RerankToggle = rerank.enabled === false ? 'disabled' : 'enabled'; + const toggle = await pickRerankToggle(host, current); + if (toggle === undefined || toggle === current) return; + + try { + await host.harness.replaceService('rerank', { + ...rerank, + enabled: toggle === 'enabled', + }); + await reloadSessionAfterWebSearchChange(host, `Rerank ${toggle}.`); + } catch (error) { + host.showError(`Failed to update rerank status: ${formatErrorMessage(error)}`); + } +} + +async function editRerankApiKey( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const apiKey = await promptRerankApiKey(host); + if (apiKey === undefined) return; + const config = await host.harness.getConfig(); + if (!isNonEmpty(apiKey) && !isNonEmpty(config.services?.langsearch?.apiKey)) { + host.showError( + 'A LangSearch API key is required when the search provider is not LangSearch.', + ); + return; + } + + try { + await host.harness.replaceService('rerank', { + ...rerank, + apiKey: isNonEmpty(apiKey) ? apiKey : undefined, + }); + await reloadSessionAfterWebSearchChange(host, 'Rerank API key updated.'); + } catch (error) { + host.showError(`Failed to update rerank API key: ${formatErrorMessage(error)}`); + } +} + +async function removeRerankProvider(host: SlashCommandHost): Promise { + try { + await host.harness.removeService('rerank'); + await reloadSessionAfterWebSearchChange(host, 'Rerank provider removed.'); + } catch (error) { + host.showError(`Failed to remove rerank provider: ${formatErrorMessage(error)}`); + } +} + +function promptRerankApiKey(host: SlashCommandHost): Promise { + return promptApiKey( + host, + 'LangSearch Rerank', + ['API key for rerank. Leave empty to reuse the LangSearch search key.'], + { allowEmpty: true }, + ); +} + +function pickTier(host: SlashCommandHost): Promise { + return pickChoice(host, { + title: 'LangSearch tier', + options: TIER_VALUES.map((value) => ({ + value, + label: value, + description: + value === 'free' + ? 'Free tier — lowest rate limits.' + : `${value} — higher rate limits.`, + })), + }).then((value) => (isLangSearchTier(value) ? value : undefined)); +} + +function pickRerankToggle( + host: SlashCommandHost, + currentValue?: RerankToggle, +): Promise { + return pickChoice(host, { + title: 'Rerank status', + currentValue, + options: [ + { + value: 'enabled', + label: 'Enabled', + description: 'Rerank search results by relevance.', + }, + { + value: 'disabled', + label: 'Disabled', + description: 'Keep rerank configured but turned off.', + }, + ], + }).then((value) => (isRerankToggle(value) ? value : undefined)); +} + +function pickChoice( + host: SlashCommandHost, + options: PickerOptions, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: options.title, + options: options.options, + currentValue: options.currentValue, + notice: options.notice, + noticeTone: options.noticeTone, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +async function reloadSessionAfterWebSearchChange( + host: SlashCommandHost, + statusMessage: string, +): Promise { + if (host.session === undefined) { + host.showStatus(statusMessage); + return; + } + await host.session.reloadSession(); + await host.reloadCurrentSessionView(host.session, `${statusMessage} Session reloaded.`); +} + +function findMoonshotOAuthSource(config: KimiConfig): MoonshotOAuthSource | undefined { + const managed = config.providers[KIMI_CODE_PROVIDER_NAME]; + if (managed?.oauth !== undefined && isNonEmpty(managed.baseUrl)) { + return { + baseUrl: searchUrlFromBaseUrl(managed.baseUrl), + oauth: managed.oauth, + }; + } + + const service = config.services?.moonshotSearch; + if (service?.oauth !== undefined && isNonEmpty(service.baseUrl)) { + return { + baseUrl: service.baseUrl, + oauth: service.oauth, + }; + } + return undefined; +} + +function currentSearchProvider( + services: ServicesConfig, +): SearchProviderChoice | undefined { + if ( + isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) && + isNonEmpty(services.langsearch?.apiKey) + ) { + return 'langsearch'; + } + if (isNonEmpty(services.moonshotSearch?.baseUrl)) return 'moonshot'; + return undefined; +} + +function currentProviderSummary(services: ServicesConfig): { + readonly search: string; + readonly rerank: string; + readonly hasWarning: boolean; +} { + const langSearchEnabled = isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG); + const current = currentSearchProvider(services); + const langSearchDisabled = !langSearchEnabled && isNonEmpty(services.langsearch?.apiKey); + const search = + current === 'langsearch' + ? `Current web search: LangSearch (tier: ${services.langsearch?.tier ?? 'free'})` + : current === 'moonshot' + ? `Current web search: Moonshot (${services.moonshotSearch?.oauth !== undefined ? 'OAuth' : 'API key'})` + : langSearchDisabled + ? 'Current web search: LangSearch configured, experimental feature disabled' + : 'Current web search: not configured'; + + const rerank = services.rerank; + if (rerank?.provider === undefined) { + return { + search, + rerank: 'Current rerank: not configured', + hasWarning: current === undefined || langSearchDisabled, + }; + } + const rerankLabel = rerankProviderLabel(rerank.provider); + if (!langSearchEnabled) { + return { + search, + rerank: `Current rerank: ${rerankLabel} configured, experimental feature disabled`, + hasWarning: true, + }; + } + if (rerank.enabled === false) { + return { + search, + rerank: `Current rerank: ${rerankLabel} disabled`, + hasWarning: current === undefined, + }; + } + + const hasKey = isNonEmpty(rerank.apiKey) || isNonEmpty(services.langsearch?.apiKey); + return { + search, + rerank: `Current rerank: ${rerankLabel} ${hasKey ? 'enabled' : 'missing API key'}`, + hasWarning: current === undefined || !hasKey, + }; +} + +function searchProviderLabel(provider: SearchProviderChoice): string { + return provider === 'moonshot' ? 'Moonshot' : 'LangSearch'; +} + +function rerankProviderLabel(provider: RerankProviderChoice): string { + return provider === 'langsearch' ? 'LangSearch' : provider; +} + +function showLangSearchExperimentalNotice(host: SlashCommandHost): void { + host.showNotice( + 'Enable “LangSearch web search” under Settings → Experiments before configuring LangSearch or rerank.', + ); +} + +function searchUrlFromBaseUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/search`; +} + +function isSearchProviderChoice(value: string | undefined): value is SearchProviderChoice { + return value !== undefined && (SEARCH_PROVIDER_VALUES as readonly string[]).includes(value); +} + +function isLangSearchTier(value: string | undefined): value is LangSearchTier { + return value !== undefined && (TIER_VALUES as readonly string[]).includes(value); +} + +function isRerankProviderChoice(value: string | undefined): value is RerankProviderChoice { + return value !== undefined && (RERANK_PROVIDER_VALUES as readonly string[]).includes(value); +} + +function isRerankToggle(value: string | undefined): value is RerankToggle { + return value !== undefined && (RERANK_TOGGLE_VALUES as readonly string[]).includes(value); +} + +function isNonEmpty(value: string | undefined): value is string { + return value !== undefined && value.trim().length > 0; +} diff --git a/apps/kimi-code/src/tui/commands/workflow.ts b/apps/kimi-code/src/tui/commands/workflow.ts new file mode 100644 index 0000000000..074cc6f176 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/workflow.ts @@ -0,0 +1,267 @@ +import { TextViewerComponent } from '../components/dialogs/text-viewer'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { showWorkflowsBrowser, workflowsBrowserOpen } from '../controllers/workflows-browser'; +import { formatErrorMessage } from '../utils/event-payload'; +import { WorkflowV2Client } from '../workflow-v2-client'; +import type { SlashCommandHost } from './dispatch'; + +const USAGE = + 'Usage: /workflow [list] | run [args…] | runs | show | cancel | save [--user] | reload | on | off'; + +export async function handleWorkflowCommand(host: SlashCommandHost, args: string): Promise { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + const client = new WorkflowV2Client(host.requireSession()); + const trimmed = args.trim(); + // No args → open the runs browser (like /tasks), so the user sees active runs immediately. + if (trimmed === '') { + if (workflowsBrowserOpen()) return; + await showWorkflowsBrowser(host, client); + return; + } + if (trimmed === 'list') { + await listWorkflows(host, client); + return; + } + + const [subcommand, ...rest] = trimmed.split(/\s+/); + const subArgs = rest.join(' '); + switch (subcommand) { + case 'run': + await runWorkflow(host, client, subArgs, `/workflow ${trimmed}`); + return; + case 'runs': + if (workflowsBrowserOpen()) return; + await showWorkflowsBrowser(host, client); + return; + case 'show': + await showWorkflowScript(host, client, subArgs); + return; + case 'cancel': + await cancelWorkflowRun(host, client, subArgs); + return; + case 'save': + await saveWorkflowRun(host, client, subArgs); + return; + case 'reload': + await reloadWorkflows(host, client); + return; + case 'on': + case 'off': + await toggleWorkflowMode(host, client, subcommand === 'on'); + return; + default: + // `/workflow [args…]` is shorthand for `/workflow run [args…]`. + if (subcommand !== undefined && !subcommand.startsWith('-')) { + await runWorkflow(host, client, trimmed, `/workflow ${trimmed}`); + return; + } + host.showError(USAGE); + } +} + +async function listWorkflows(host: SlashCommandHost, client: WorkflowV2Client): Promise { + try { + const { workflows, skipped } = await client.listWorkflows(); + const lines = workflows.map( + (workflow) => + `• ${workflow.name} (${workflow.source}, ${String(workflow.phases.length)} phases) — ${workflow.description}`, + ); + if (skipped.length > 0) { + lines.push('', 'Skipped (invalid):'); + for (const entry of skipped) lines.push(`• ${entry.path}: ${entry.reason}`); + } + host.showNotice( + `Workflows (${String(workflows.length)})`, + lines.length > 0 ? lines.join('\n') : 'No workflows discovered.', + ); + } catch (error) { + showWorkflowError(host, error); + } +} + +async function runWorkflow( + host: SlashCommandHost, + client: WorkflowV2Client, + input: string, + commandText: string, +): Promise { + const name = input.split(/\s+/)[0]; + if (name === undefined || name === '') { + host.showError(USAGE); + return; + } + const runArgs = input.slice(name.length).trim(); + await startRun(host, client, name, runArgs); +} + +async function startRun( + host: SlashCommandHost, + client: WorkflowV2Client, + name: string, + runArgs: string, +): Promise { + try { + const started = await client.runWorkflow({ name, args: runArgs }); + host.showStatus( + `Workflow "${started.workflowName}" started (run ${started.runId}). Track with /workflow runs or /tasks.`, + ); + } catch (error) { + showWorkflowError(host, error); + } +} + +async function showWorkflowScript( + host: SlashCommandHost, + client: WorkflowV2Client, + name: string, +): Promise { + if (name === '') { + host.showError(USAGE); + return; + } + try { + const { workflow } = await client.getWorkflow(name); + if (workflow === null) { + host.showError(`Workflow "${name}" not found.`); + return; + } + openScriptViewer(host, `Workflow script: ${workflow.name}`, workflow.script ?? '', () => {}); + } catch (error) { + showWorkflowError(host, error); + } +} + +function openScriptViewer( + host: SlashCommandHost, + title: string, + content: string, + onClose: () => void, +): void { + const { ui } = host.state; + const savedChildren = [...ui.children]; + const viewer = new TextViewerComponent( + { + title, + content, + onClose: () => { + ui.clear(); + for (const child of savedChildren) ui.addChild(child); + ui.setFocus(host.state.editor); + ui.requestRender(true); + onClose(); + }, + }, + host.state.terminal, + ); + ui.clear(); + ui.addChild(viewer); + ui.setFocus(viewer); + ui.requestRender(true); +} + +async function cancelWorkflowRun( + host: SlashCommandHost, + client: WorkflowV2Client, + prefix: string, +): Promise { + if (prefix === '') { + host.showError(USAGE); + return; + } + try { + const runId = await resolveRunId(host, client, prefix); + if (runId === undefined) return; + const { cancelled } = await client.cancelWorkflowRun(runId); + host.showStatus( + cancelled ? `Workflow run ${runId} cancelled.` : `Workflow run ${runId} is not running — nothing to cancel.`, + ); + } catch (error) { + showWorkflowError(host, error); + } +} + +async function saveWorkflowRun( + host: SlashCommandHost, + client: WorkflowV2Client, + input: string, +): Promise { + const useUserScope = input.split(/\s+/).includes('--user'); + const prefix = input.replace(/--user\b/g, '').trim(); + if (prefix === '') { + host.showError(USAGE); + return; + } + try { + const runId = await resolveRunId(host, client, prefix); + if (runId === undefined) return; + const { run } = await client.getWorkflowRun(runId); + if (run === null) { + host.showError(`Workflow run ${runId} not found.`); + return; + } + const saved = await client.saveWorkflow({ + script: run.script, + scope: useUserScope ? 'user' : 'project', + }); + host.showStatus(`Workflow "${saved.name}" saved to ${useUserScope ? 'user' : 'project'} scope: ${saved.path}`); + } catch (error) { + showWorkflowError(host, error); + } +} + +async function reloadWorkflows(host: SlashCommandHost, client: WorkflowV2Client): Promise { + try { + const { workflows, skipped } = await client.reloadWorkflows(); + const skippedNote = + skipped.length > 0 ? ` (${String(skipped.length)} invalid skipped)` : ''; + host.showStatus(`Reloaded workflows: ${String(workflows.length)} discovered${skippedNote}.`); + } catch (error) { + showWorkflowError(host, error); + } +} + +async function resolveRunId( + host: SlashCommandHost, + client: WorkflowV2Client, + prefix: string, +): Promise { + const { runs } = await client.listWorkflowRuns(); + const exact = runs.find((run) => run.runId === prefix); + if (exact !== undefined) return exact.runId; + const matches = runs.filter((run) => run.runId.startsWith(prefix)); + if (matches.length === 1) return matches[0]!.runId; + if (matches.length === 0) { + host.showError(`No workflow run matches "${prefix}". Run /workflow runs to see active runs.`); + } else { + host.showError(`"${prefix}" matches ${String(matches.length)} workflow runs — be more specific.`); + } + return undefined; +} + +function showWorkflowError(host: SlashCommandHost, error: unknown): void { + const message = formatErrorMessage(error); + if (message.includes('request.invalid') || message.includes('dynamic-workflows')) { + host.showError( + `Dynamic workflows are experimental — enable them with KIMI_CODE_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1 or /experiments. (${message})`, + ); + return; + } + host.showError(message); +} + +async function toggleWorkflowMode( + host: SlashCommandHost, + client: WorkflowV2Client, + enabled: boolean, +): Promise { + try { + await client.setWorkflowMode(enabled, 'command'); + host.setAppState({ workflowMode: enabled }); + host.showStatus(enabled ? 'Dynamic Workflow mode enabled.' : 'Dynamic Workflow mode disabled.'); + } catch (error) { + showWorkflowError(host, error); + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..3fe1db34fc 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -200,6 +200,7 @@ export class FooterComponent implements Component { */ private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; + private backgroundWorkflowCount = 0; constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; @@ -239,9 +240,10 @@ export class FooterComponent implements Component { * count produces its own bracketed badge on line 1; zeros hide them * independently. */ - setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void { + setBackgroundCounts(counts: { bashTasks: number; agentTasks: number; workflowTasks: number }): void { this.backgroundBashTaskCount = Math.max(0, counts.bashTasks); this.backgroundAgentCount = Math.max(0, counts.agentTasks); + this.backgroundWorkflowCount = Math.max(0, counts.workflowTasks); } invalidate(): void {} @@ -257,6 +259,7 @@ export class FooterComponent implements Component { if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); if (state.swarmMode) modes.push(chalk.hex(colors.accent).bold('swarm')); + if (state.workflowMode) modes.push(chalk.hex(colors.accent).bold('Dynamic Workflow')); if (modes.length > 0) left.push(modes.join(' ')); const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); @@ -299,6 +302,12 @@ export class FooterComponent implements Component { chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), ); } + if (this.backgroundWorkflowCount > 0) { + const noun = this.backgroundWorkflowCount === 1 ? 'workflow' : 'workflows'; + left.push( + chalk.hex(colors.accent)(`[${String(this.backgroundWorkflowCount)} ${noun} running]`), + ); + } const cwd = shortenCwd(state.workDir); if (cwd) left.push(chalk.hex(colors.textDim)(cwd)); diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 2165dc48da..4686154621 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -48,6 +48,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly onDone: (result: ApiKeyInputResult) => void; private readonly title: string; private readonly subtitleLines: readonly string[]; + private readonly allowEmpty: boolean; private readonly mask: boolean; private readonly emptyHint: string; private done = false; @@ -57,12 +58,13 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - options?: { title?: string; mask?: boolean; emptyHint?: string }, + options?: { title?: string; mask?: boolean; emptyHint?: string; allowEmpty?: boolean }, ) { super(); this.onDone = onDone; this.title = options?.title ?? `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; + this.allowEmpty = options?.allowEmpty ?? false; this.mask = options?.mask ?? true; this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.'; this.input.onSubmit = (value) => { @@ -149,7 +151,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private submit(value: string): void { if (this.done) return; const trimmed = value.trim(); - if (trimmed.length === 0) { + if (trimmed.length === 0 && !this.allowEmpty) { this.emptyHinted = true; return; } diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index e60d85ce02..e6f639b377 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -37,7 +37,7 @@ export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -57,7 +57,7 @@ export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; } @@ -77,7 +77,7 @@ const YOLO_NOTICE_LINES = [ 'Switch to Auto if you want questions skipped during goal work.', ] as const; -export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { +export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { constructor(opts: GoalStartPermissionPromptOptions) { super({ title: diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 81e4b8d125..b1788d20ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -7,7 +7,8 @@ export type SettingsSelection = | 'permission' | 'experiments' | 'upgrade' - | 'usage'; + | 'usage' + | 'webSearch'; const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ { @@ -45,6 +46,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Usage', description: 'Show session tokens, context window, and plan quotas.', }, + { + value: 'webSearch', + label: 'Web Search', + description: 'Configure web search and rerank providers.', + }, ]; function isSettingsSelection(value: string): value is SettingsSelection { @@ -55,7 +61,8 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'permission' || value === 'experiments' || value === 'upgrade' || - value === 'usage' + value === 'usage' || + value === 'webSearch' ); } diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 341ced723b..6028b37e8d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -10,7 +10,7 @@ import { import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; -export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; +export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel' | 'run' | 'view'; export interface StartPermissionOption { readonly value: TChoice; diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 7902e81e1a..bcf1d6b0a3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -476,7 +476,9 @@ export class TasksBrowserApp extends Container implements Focusable { ? 'success' : task.kind === 'question' ? 'warning' - : 'accent'; + : task.kind === 'workflow' + ? 'accent' + : 'accent'; const idText = selected ? currentTheme.boldFg(idColor, task.taskId) : currentTheme.fg(idColor, task.taskId); @@ -559,6 +561,19 @@ export class TasksBrowserApp extends Container implements Focusable { lines.push(`${label('Tool call:')}${currentTheme.fg('textMuted', task.toolCallId)}`); } } + if (task.kind === 'workflow') { + lines.push(`${label('Workflow:')}${value(task.workflowName)}`); + if (task.phase !== undefined) { + const phaseProgress = + task.phases !== undefined && task.phaseIndex !== undefined + ? `${String(task.phaseIndex + 1)}/${String(task.phases.length)}` + : task.phase; + lines.push(`${label('Phase:')}${value(`${task.phase} (${phaseProgress})`)}`); + } + if (task.agentCalls !== undefined) { + lines.push(`${label('Agent calls:')}${currentTheme.fg('textMuted', String(task.agentCalls))}`); + } + } const timing = task.status === 'running' ? `running ${formatRelativeTime(task.startedAt)}` diff --git a/apps/kimi-code/src/tui/components/dialogs/text-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/text-viewer.ts new file mode 100644 index 0000000000..8d7378f3d7 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/text-viewer.ts @@ -0,0 +1,128 @@ +/** + * TextViewerComponent — minimal full-screen scrollable text viewer (static + * content). Modeled on TaskOutputViewer, but content-agnostic: used to + * inspect workflow scripts from the /workflow command and the workflow run + * confirmation prompt. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '@/tui/utils/printable-key'; + +const ELLIPSIS = '…'; + +export interface TextViewerProps { + readonly title: string; + readonly content: string; + readonly onClose: () => void; +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + const w = visibleWidth(s); + return w === width ? s : s + ' '.repeat(width - w); +} + +export class TextViewerComponent extends Container implements Focusable { + focused = false; + + private readonly props: TextViewerProps; + private readonly terminal: Terminal; + private readonly lines: string[]; + private scrollTop = 0; + + constructor(props: TextViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + this.lines = (props.content.length > 0 ? props.content : '[empty]').split('\n'); + } + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl('u')) || k === ' ') { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl('d'))) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + + const header = fitExactly(currentTheme.boldFg('primary', ` ${this.props.title}`), width); + const innerWidth = Math.max(1, width - 4); + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + + const viewRows = bodyHeight - 2; + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [header, top]; + for (let i = 0; i < viewRows; i++) { + const raw = this.lines[this.scrollTop + i] ?? ''; + const inner = fitExactly(currentTheme.fg('text', raw), innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + out.push(fitExactly(` ${key('↑↓')} ${dim('line')} ${key('PgUp/PgDn')} ${dim('page')} ${key('g/G')} ${dim('top/bot')} ${key('Q/Esc')} ${dim('close')}`, width)); + return out; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/workflow-run-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/workflow-run-permission-prompt.ts new file mode 100644 index 0000000000..77f0c041eb --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/workflow-run-permission-prompt.ts @@ -0,0 +1,61 @@ +import type { WorkflowSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type WorkflowRunPermissionChoice = 'run' | 'view' | 'cancel'; + +export interface WorkflowRunPermissionPromptOptions { + readonly workflow: WorkflowSummary; + readonly args: string; + readonly onSelect: (choice: WorkflowRunPermissionChoice) => void; + readonly onCancel: () => void; +} + +const OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'run', + label: 'Run workflow', + description: 'Start the workflow in the background. Track progress with /workflow runs or /tasks.', + }, + { + value: 'view', + label: 'View script', + description: 'Inspect the full workflow script before deciding.', + }, + { + value: 'cancel', + label: 'Do not run', + description: 'Return to the input box without starting anything.', + }, +]; + +function noticeLines(workflow: WorkflowSummary, args: string): string[] { + const lines = [ + workflow.description, + `Phases: ${workflow.phases.map((phase, index) => `${String(index + 1)}. ${phase.title}${phase.detail !== undefined ? ` — ${phase.detail}` : ''}`).join(' · ')}`, + 'Runs multiple subagents in parallel — token usage can be significantly higher than a normal session. Bounded by workflow limits (see the [workflows] config section).', + ]; + const hint = (workflow as { argumentHint?: string }).argumentHint; + if (args.length > 0) { + lines.push(`Args: ${args}`); + } else if (hint !== undefined && hint.length > 0) { + lines.push(`Arguments: ${hint}`); + } + if (workflow.source !== 'builtin') lines.push(`Source: ${workflow.source} (${workflow.path})`); + return lines; +} + +export class WorkflowRunPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: WorkflowRunPermissionPromptOptions) { + super({ + title: `Run workflow "${opts.workflow.name}"?`, + noticeLines: noticeLines(opts.workflow, opts.args), + options: OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/workflows-browser.ts b/apps/kimi-code/src/tui/components/dialogs/workflows-browser.ts new file mode 100644 index 0000000000..bed1aba3a2 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/workflows-browser.ts @@ -0,0 +1,210 @@ +/** + * WorkflowsBrowserApp — full-screen browser for dynamic workflow runs. + * Left: the run list (status, phase progress, agent calls). Right: the + * selected run's detail (phases with the current one marked, error/result, + * log tail). Keys: ↑↓ select · c cancel run · s save to project · S save to + * user · v view script · r refresh · Esc close. + */ + +import { + Container, + Key, + matchesKey, + type Terminal, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; +import type { WorkflowRunDetail, WorkflowRunSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { printableChar } from '@/tui/utils/printable-key'; + +export interface WorkflowsBrowserProps { + readonly runs: readonly WorkflowRunSnapshot[]; + readonly selectedRunId: string | undefined; + readonly detail: WorkflowRunDetail | undefined; + readonly detailLoading: boolean; + readonly flashMessage: string | undefined; + readonly onSelect: (runId: string) => void; + readonly onCancel: () => void; + readonly onCancelRun: (runId: string) => void; + readonly onSaveRun: (runId: string, scope: 'project' | 'user') => void; + readonly onViewScript: (runId: string) => void; + readonly onRefresh: () => void; +} + +const STATUS_COLOR: Record = { + running: 'success', + completed: 'textMuted', + failed: 'error', + cancelled: 'warning', +}; + +function phaseProgress(run: WorkflowRunSnapshot): string { + if (run.phases.length === 0) return ''; + const current = run.phaseIndex !== undefined ? run.phaseIndex + 1 : run.phases.length; + return `${String(Math.min(current, run.phases.length))}/${String(run.phases.length)}`; +} + +function fit(line: string, width: number): string { + return truncateToWidth(line, Math.max(1, width), '…'); +} + +export class WorkflowsBrowserApp extends Container implements Focusable { + focused = false; + + private props: WorkflowsBrowserProps; + + constructor( + props: WorkflowsBrowserProps, + private readonly terminal: Terminal, + ) { + super(); + this.props = props; + } + + setProps(next: WorkflowsBrowserProps): void { + this.props = next; + this.invalidate(); + } + + handleInput(data: string): void { + const k = printableChar(data); + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onCancel(); + return; + } + const runs = this.props.runs; + const index = runs.findIndex((run) => run.runId === this.props.selectedRunId); + if (matchesKey(data, Key.up)) { + const next = runs[Math.max(0, index - 1)]; + if (next !== undefined && next.runId !== this.props.selectedRunId) { + this.props.onSelect(next.runId); + } + return; + } + if (matchesKey(data, Key.down)) { + const next = runs[Math.min(runs.length - 1, index + 1)]; + if (next !== undefined && next.runId !== this.props.selectedRunId) { + this.props.onSelect(next.runId); + } + return; + } + const selected = this.props.selectedRunId; + if (k === 'r' || k === 'R') { + this.props.onRefresh(); + return; + } + if (selected === undefined) return; + if (k === 'c' || k === 'C') { + this.props.onCancelRun(selected); + return; + } + if (k === 's') { + this.props.onSaveRun(selected, 'project'); + return; + } + if (k === 'S') { + this.props.onSaveRun(selected, 'user'); + return; + } + if (k === 'v' || k === 'V') { + this.props.onViewScript(selected); + } + } + + override render(width: number): string[] { + const rows = Math.max(6, this.terminal.rows); + const out: string[] = []; + out.push(fit(currentTheme.boldFg('primary', ' Workflow runs ') + currentTheme.fg('textMuted', `(${String(this.props.runs.length)})`), width)); + out.push(currentTheme.fg('primary', '─'.repeat(width))); + + const bodyRows = rows - 4; + const listWidth = Math.min(44, Math.max(24, Math.floor(width / 2))); + for (let i = 0; i < bodyRows; i++) { + const left = this.renderListLine(i, listWidth); + const right = this.renderDetailLine(i, width - listWidth - 1); + out.push(left + currentTheme.fg('primary', '│') + right); + } + + out.push(currentTheme.fg('primary', '─'.repeat(width))); + out.push(fit(this.renderFooter(width), width)); + return out; + } + + private renderListLine(index: number, width: number): string { + const run = this.props.runs[index]; + if (run === undefined) { + if (index === 0 && this.props.runs.length === 0) { + return pad(currentTheme.fg('textMuted', ' No workflow runs yet.'), width); + } + return pad('', width); + } + const selected = run.runId === this.props.selectedRunId; + const pointer = selected ? SELECT_POINTER : ' '; + const status = currentTheme.fg(STATUS_COLOR[run.status], run.status.padEnd(9)); + const name = currentTheme.fg(selected ? 'textStrong' : 'text', run.workflowName); + const progress = currentTheme.fg('textMuted', `${phaseProgress(run)} · ${String(run.agentCalls)} calls`); + return pad(` ${currentTheme.fg('primary', pointer)} ${status} ${name} ${progress}`, width); + } + + private renderDetailLine(index: number, width: number): string { + const lines = this.detailLines(); + return pad(lines[index] ?? '', width); + } + + private detailLines(): string[] { + if (this.props.flashMessage !== undefined) { + return [currentTheme.fg('warning', ` ${this.props.flashMessage}`)]; + } + if (this.props.selectedRunId === undefined) return [currentTheme.fg('textMuted', ' Select a run.')]; + if (this.props.detailLoading) return [currentTheme.fg('textMuted', ' Loading…')]; + const detail = this.props.detail; + if (detail === undefined) return [currentTheme.fg('textMuted', ' Run not found.')]; + + const lines: string[] = []; + lines.push(currentTheme.boldFg('text', ` ${detail.workflowName}`) + currentTheme.fg('textMuted', ` (${detail.runId})`)); + lines.push(currentTheme.fg(STATUS_COLOR[detail.status], ` ${detail.status}`) + currentTheme.fg('textMuted', ` · ${String(detail.agentCalls)} agent calls · source ${detail.source}`)); + if (detail.args.length > 0) lines.push(currentTheme.fg('textMuted', ` args: ${detail.args}`)); + lines.push(''); + detail.phases.forEach((phase, i) => { + const current = i === detail.phaseIndex && detail.status === 'running'; + const done = detail.phaseIndex !== undefined && i < detail.phaseIndex; + const marker = current ? '▶' : done ? '✓' : '·'; + lines.push( + ` ${currentTheme.fg(current ? 'success' : 'textMuted', marker)} ${currentTheme.fg(current ? 'textStrong' : 'textMuted', phase.title)}${phase.detail !== undefined ? currentTheme.fg('textMuted', ` — ${phase.detail}`) : ''}`, + ); + }); + lines.push(''); + if (detail.error !== undefined) { + lines.push(currentTheme.fg('error', ` error: ${detail.error}`)); + } + if (detail.resultJson !== undefined) { + const result = detail.resultJson.length > 300 ? detail.resultJson.slice(0, 300) + '…' : detail.resultJson; + lines.push(currentTheme.fg('textMuted', ' result: ' + result)); + } + if (detail.logs.length > 0) { + lines.push(currentTheme.fg('textMuted', ' logs:')); + for (const log of detail.logs.slice(-8)) { + lines.push(currentTheme.fg('textMuted', ` ${log}`)); + } + } + return lines; + } + + private renderFooter(width: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + const footer = + ` ${key('↑↓')} ${dim('select')} ${key('c')} ${dim('cancel run')} ${key('s/S')} ${dim('save project/user')} ${key('v')} ${dim('view script')} ${key('r')} ${dim('refresh')} ${key('Esc')} ${dim('close')}`; + return visibleWidth(footer) <= width ? footer : ` ${key('↑↓')} ${dim('select')} ${key('c')} ${dim('cancel')} ${key('s/S')} ${dim('save')} ${key('v')} ${dim('script')} ${key('Esc')} ${dim('close')}`; + } +} + +function pad(line: string, width: number): string { + const w = visibleWidth(line); + if (w >= width) return truncateToWidth(line, Math.max(1, width), '…'); + return line + ' '.repeat(width - w); +} diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 722682db60..82bd00c44d 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -449,11 +449,16 @@ function parseSlashArgumentContext( textBeforeCursor: string, slashCommands: readonly SlashAutocompleteCommand[], ): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { - const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + // The whitespace branch captures the FULL argument text (including spaces) + // so subcommands like `/workflow run ` can complete the second token. + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(.*)$/); if (whitespaceMatch !== null) { const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; const command = findSlashCommand(slashCommands, commandName); if (command === undefined) return null; + // When the cursor is right after the space with no argument text yet, + // argumentPrefix === '' — only show completions if the user explicitly + // typed a space (textBeforeCursor ends with whitespace). if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; return { command, argumentPrefix }; } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index b82f919610..b97ece2c70 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -29,6 +29,8 @@ import type { TurnStepInterruptedEvent, TurnStepStartedEvent, WarningEvent, + WorkflowRunCompletedEvent, + WorkflowRunStartedEvent, } from '@moonshot-ai/kimi-code-sdk'; import { MoonLoader } from '../components/chrome/moon-loader'; @@ -293,6 +295,12 @@ export class SessionEventHandler { case 'background.task.started': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; + case 'workflow.run.started': + case 'workflow.run.completed': + this.handleWorkflowRunEvent(event); break; + case 'workflow.run.phase': + case 'workflow.run.log': + case 'workflow.run.agent_call': break; // progress is in /workflow runs case 'cron.fired': this.handleCronFired(event); break; case 'mcp.server.status': this.renderMcpServerStatus(event.server); break; case 'tool.list.updated': break; @@ -330,6 +338,28 @@ export class SessionEventHandler { }); } + + + private handleWorkflowRunEvent( + event: WorkflowRunStartedEvent | WorkflowRunCompletedEvent, + ): void { + this.host.streamingUI.flushNow(); + const content = + event.type === 'workflow.run.started' + ? `Workflow "${event.workflowName}" started (${String(event.phases.length)} phases) — /workflow runs to follow, /tasks for output.` + : event.status === 'completed' + ? `Workflow run ${event.runId} completed (${String(event.agentCalls)} agent calls) — /workflow runs to inspect.` + : event.status === 'failed' + ? `Workflow run ${event.runId} failed: ${event.error ?? 'unknown error'}` + : `Workflow run ${event.runId} cancelled.`; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + renderMode: 'plain', + content, + }); + } + private handleCronFired(event: CronFiredEvent): void { this.host.streamingUI.flushNow(); this.host.appendTranscriptEntry({ @@ -348,6 +378,7 @@ export class SessionEventHandler { }); } + private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { this.host.streamingUI.flushNow(); if (event.reason === 'cancelled') { @@ -1161,6 +1192,7 @@ export class SessionEventHandler { const { state } = this.host; let bashTasks = 0; let agentTasks = 0; + let workflowTasks = 0; for (const info of this.backgroundTasks.values()) { if ( info.status === 'completed' || @@ -1173,11 +1205,13 @@ export class SessionEventHandler { } if (info.kind === 'agent') { agentTasks += 1; + } else if (info.kind === 'workflow') { + workflowTasks += 1; } else { bashTasks += 1; } } - state.footer.setBackgroundCounts({ bashTasks, agentTasks }); + state.footer.setBackgroundCounts({ bashTasks, agentTasks, workflowTasks }); state.ui.requestRender(); } } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index f2281ea54c..e6309d8e55 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -131,6 +131,10 @@ export class SubAgentEventHandler { } handleLifecycleEvent(event: SubagentLifecycleEvent): void { + // Workflow-run subagents are rendered by WorkflowProgressComponent — + // skip the individual transcript entries to keep the transcript clean. + if (isWorkflowSubagent(event, this.subagentInfo)) return; + switch (event.type) { case 'subagent.spawned': this.handleSubagentSpawned(event); @@ -642,3 +646,20 @@ function isUserCancelledSubagentError(error: string): boolean { return false; } } + +/** + * Returns true when a subagent lifecycle event belongs to a dynamic workflow + * run (identified by the `workflow::` pattern in parentToolCallId). + * These subagents are rendered by WorkflowProgressComponent instead of as + * individual transcript entries. + */ +function isWorkflowSubagent( + event: SubagentLifecycleEvent, + subagentInfo: Map, +): boolean { + if (event.type === 'subagent.spawned') { + return event.parentToolCallId.startsWith('workflow:'); + } + const info = subagentInfo.get(event.agentId); + return info !== undefined && info.parentToolCallId.startsWith('workflow:'); +} diff --git a/apps/kimi-code/src/tui/controllers/workflows-browser.ts b/apps/kimi-code/src/tui/controllers/workflows-browser.ts new file mode 100644 index 0000000000..828ece3471 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/workflows-browser.ts @@ -0,0 +1,290 @@ +/** + * WorkflowsBrowserController — mounts the full-screen workflow-runs browser. + * Self-contained (no TUIState field): one browser at a time per process, + * opened by `/workflow runs` and closed with Esc. Polling refreshes the run + * list once a second; the selected run's detail (logs, script) loads on + * demand. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import type { WorkflowRunDetail, WorkflowRunSnapshot } from '@moonshot-ai/kimi-code-sdk'; + +import type { SlashCommandHost } from '../commands/dispatch'; +import { TextViewerComponent } from '../components/dialogs/text-viewer'; +import { WorkflowsBrowserApp } from '../components/dialogs/workflows-browser'; +import { formatErrorMessage } from '../utils/event-payload'; +import { WorkflowV2Client } from '../workflow-v2-client'; + +interface BrowserState { + component: WorkflowsBrowserApp; + savedChildren: readonly Component[]; + selectedRunId: string | undefined; + detail: WorkflowRunDetail | undefined; + detailLoading: boolean; + detailRequestId: number; + flashMessage: string | undefined; + flashTimer: NodeJS.Timeout | undefined; + pollTimer: NodeJS.Timeout; + viewer: + | { + component: TextViewerComponent; + savedChildren: readonly Component[]; + } + | undefined; +} + +let active: + | { host: SlashCommandHost; client: WorkflowV2Client; browser: BrowserState } + | undefined; + +export function workflowsBrowserOpen(): boolean { + return active !== undefined; +} + +export async function showWorkflowsBrowser( + host: SlashCommandHost, + client: WorkflowV2Client, +): Promise { + if (active !== undefined) return; + + let runs: readonly WorkflowRunSnapshot[]; + try { + runs = (await client.listWorkflowRuns()).runs; + } catch (error) { + host.showError(`Failed to load workflow runs: ${formatErrorMessage(error)}`); + return; + } + if (active !== undefined) return; + + const selectedRunId = runs.find((run) => run.status === 'running')?.runId ?? runs[0]?.runId; + const component = new WorkflowsBrowserApp( + { + runs, + selectedRunId, + detail: undefined, + detailLoading: false, + flashMessage: undefined, + onSelect: (runId) => handleSelect(runId), + onCancel: () => closeWorkflowsBrowser(), + onCancelRun: (runId) => void handleCancelRun(runId), + onSaveRun: (runId, scope) => void handleSaveRun(runId, scope), + onViewScript: (runId) => void handleViewScript(runId), + onRefresh: () => void refresh(false), + }, + host.state.terminal, + ); + + const { ui } = host.state; + const savedChildren = [...ui.children]; + ui.clear(); + ui.addChild(component); + ui.setFocus(component); + ui.requestRender(true); + + const pollTimer = setInterval(() => { + void refresh(true); + }, 1000); + + active = { + host, + client, + browser: { + component, + savedChildren, + selectedRunId, + detail: undefined, + detailLoading: false, + detailRequestId: 0, + flashMessage: undefined, + flashTimer: undefined, + pollTimer, + viewer: undefined, + }, + }; + + if (selectedRunId !== undefined) loadDetail(selectedRunId); +} + +export function closeWorkflowsBrowser(): void { + if (active === undefined) return; + const { host, browser } = active; + if (browser.viewer !== undefined) closeScriptViewer(); + clearInterval(browser.pollTimer); + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + + const { ui } = host.state; + ui.clear(); + for (const child of browser.savedChildren) ui.addChild(child); + active = undefined; + ui.setFocus(host.state.editor); + ui.requestRender(true); +} + +async function refresh(silent: boolean): Promise { + if (active === undefined) return; + const { host, client, browser } = active; + let runs: readonly WorkflowRunSnapshot[]; + try { + runs = (await client.listWorkflowRuns()).runs; + } catch (error) { + if (!silent) flash(`Refresh failed: ${formatErrorMessage(error)}`); + return; + } + if (active === undefined || active.browser !== browser) return; + pushProps(runs); + if (browser.selectedRunId !== undefined) { + void refreshDetail(browser.selectedRunId); + } +} + +function pushProps(runs: readonly WorkflowRunSnapshot[]): void { + if (active === undefined) return; + const { host, browser } = active; + browser.component.setProps({ + runs, + selectedRunId: browser.selectedRunId, + detail: browser.detail, + detailLoading: browser.detailLoading, + flashMessage: browser.flashMessage, + onSelect: (runId) => handleSelect(runId), + onCancel: () => closeWorkflowsBrowser(), + onCancelRun: (runId) => void handleCancelRun(runId), + onSaveRun: (runId, scope) => void handleSaveRun(runId, scope), + onViewScript: (runId) => void handleViewScript(runId), + onRefresh: () => void refresh(false), + }); + host.state.ui.requestRender(); +} + +function handleSelect(runId: string): void { + if (active === undefined) return; + const { browser } = active; + if (browser.selectedRunId === runId) return; + browser.selectedRunId = runId; + browser.detail = undefined; + browser.detailLoading = true; + loadDetail(runId); +} + +function loadDetail(runId: string): void { + if (active === undefined) return; + const { client, browser } = active; + const requestId = ++browser.detailRequestId; + void client + .getWorkflowRun(runId) + .then(({ run }) => { + if (active === undefined || active.browser !== browser) return; + if (browser.detailRequestId !== requestId || browser.selectedRunId !== runId) return; + browser.detail = run ?? undefined; + browser.detailLoading = false; + void refresh(true); + }) + .catch(() => { + if (active === undefined || active.browser !== browser) return; + if (browser.detailRequestId !== requestId) return; + browser.detail = undefined; + browser.detailLoading = false; + void refresh(true); + }); +} + +async function refreshDetail(runId: string): Promise { + if (active === undefined) return; + const { client, browser } = active; + if (browser.detail === undefined) return; + const requestId = ++browser.detailRequestId; + try { + const { run } = await client.getWorkflowRun(runId); + if (active === undefined || active.browser !== browser) return; + if (browser.detailRequestId !== requestId || browser.selectedRunId !== runId) return; + if (run !== null) browser.detail = run; + } catch { + // Silent: the next poll retries. + } +} + +async function handleCancelRun(runId: string): Promise { + if (active === undefined) return; + const { client } = active; + try { + const { cancelled } = await client.cancelWorkflowRun(runId); + flash(cancelled ? `Cancelling ${runId}…` : `${runId} is not running — nothing to cancel.`); + await refresh(true); + } catch (error) { + flash(`Cancel failed: ${formatErrorMessage(error)}`); + } +} + +async function handleSaveRun(runId: string, scope: 'project' | 'user'): Promise { + if (active === undefined) return; + const { client } = active; + try { + const { run } = await client.getWorkflowRun(runId); + if (run === null) { + flash(`Run ${runId} not found.`); + return; + } + const saved = await client.saveWorkflow({ script: run.script, scope }); + flash(`Saved "${saved.name}" to ${scope} scope: ${saved.path}`, 4000); + } catch (error) { + flash(`Save failed: ${formatErrorMessage(error)}`); + } +} + +async function handleViewScript(runId: string): Promise { + if (active === undefined) return; + const { host, client, browser } = active; + if (browser.viewer !== undefined) return; + try { + const { run } = await client.getWorkflowRun(runId); + if (active === undefined || active.browser !== browser) return; + if (run === null) { + flash(`Run ${runId} not found.`); + return; + } + const { ui } = host.state; + const viewer = new TextViewerComponent( + { + title: `Workflow script: ${run.workflowName}`, + content: run.script, + onClose: () => closeScriptViewer(), + }, + host.state.terminal, + ); + const savedChildren = [...ui.children]; + ui.clear(); + ui.addChild(viewer); + ui.setFocus(viewer); + ui.requestRender(true); + browser.viewer = { component: viewer, savedChildren }; + } catch (error) { + flash(`Cannot open script: ${formatErrorMessage(error)}`); + } +} + +function closeScriptViewer(): void { + if (active === undefined) return; + const { host, browser } = active; + if (browser.viewer === undefined) return; + const { savedChildren } = browser.viewer; + browser.viewer = undefined; + const { ui } = host.state; + ui.clear(); + for (const child of savedChildren) ui.addChild(child); + ui.setFocus(browser.component); + ui.requestRender(true); +} + +function flash(message: string, durationMs = 2500): void { + if (active === undefined) return; + const { browser } = active; + if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); + browser.flashMessage = message; + browser.flashTimer = setTimeout(() => { + if (active === undefined || active.browser !== browser) return; + browser.flashMessage = undefined; + browser.flashTimer = undefined; + void refresh(true); + }, durationMs); + void refresh(true); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..50674a4ba4 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -216,6 +216,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { planMode: input.cliOptions.plan, inputMode: 'prompt', swarmMode: false, + workflowMode: false, thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, @@ -435,6 +436,7 @@ export class KimiTUI { } private setupAutocomplete(): void { + const session = this.session; const slashCommands: SlashAutocompleteCommand[] = this.getSlashCommands().map((cmd) => { const completer = cmd.completeArgs; return { @@ -443,7 +445,7 @@ export class KimiTUI { description: cmd.description, ...(cmd.argumentHint !== undefined ? { argumentHint: cmd.argumentHint } : {}), ...(completer !== undefined - ? { getArgumentCompletions: (prefix: string) => completer(prefix) } + ? { getArgumentCompletions: (prefix: string) => completer(prefix, { session }) } : {}), }; }); @@ -1698,7 +1700,7 @@ export class KimiTUI { this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); this.btwPanelController.clear(); - this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); + this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0, workflowTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); this.setAppState({ mcpServersSummary: null }); diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index 373690592d..310eb22c25 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -179,6 +179,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string { return ''; case 'goal_start': return 'Start a goal?'; + case 'workflow_run': + return `Run workflow ${display.workflow_name}?`; case 'generic': if (typeof display.detail === 'string' && display.detail.length > 0) { return display.detail; @@ -330,6 +332,26 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { } return [{ type: 'brief', text: lines.join('\n') }]; } + case 'workflow_run': { + // Summary (name, phases, limits, consumption warning) as a brief block; + // the full script as a file_content block so the panel's preview/expand + // (ctrl+e) doubles as "view raw script" before the first run. + const lines = [ + `Workflow: ${display.workflow_name}`, + display.description, + `Phases: ${display.phases.map((phase, index) => `${String(index + 1)}. ${phase.title}${phase.detail !== undefined ? ` — ${phase.detail}` : ''}`).join(' · ')}`, + ]; + if (display.args !== undefined && display.args.length > 0) lines.push(`Args: ${display.args}`); + lines.push( + `Limits: max ${String(display.limits.max_agent_calls)} agent calls · ${String(display.limits.max_concurrency)} concurrent · ${String(Math.round(display.limits.max_duration_ms / 60000))} min`, + display.consumption_warning, + `Source: ${display.source}`, + ); + return [ + { type: 'brief', text: lines.join('\n') }, + { type: 'file_content', path: `${display.workflow_name}.workflow.js`, content: display.script }, + ]; + } case 'generic': return []; case 'todo_list': diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..0b55bf04db 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -34,6 +34,8 @@ export interface AppState { /** 'bash' when the editor is in `!` shell-command mode. */ inputMode: 'prompt' | 'bash'; swarmMode: boolean; + /** Dynamic Workflow mode — agent prefers proposing workflows for large tasks. */ + workflowMode: boolean; /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); * mirrors the runtime. The single source of truth for the thinking state in * the TUI. */ diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index d778b9a472..36a7f46c07 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -84,18 +84,22 @@ export function isTerminalBackgroundTask(info: BackgroundTaskInfo): boolean { export function countActiveBackgroundTasks(tasks: ReadonlyMap): { bashTasks: number; agentTasks: number; + workflowTasks: number; } { let bashTasks = 0; let agentTasks = 0; + let workflowTasks = 0; for (const info of tasks.values()) { if (isTerminalBackgroundTask(info)) continue; if (info.kind === 'agent') { agentTasks += 1; + } else if (info.kind === 'workflow') { + workflowTasks += 1; } else { bashTasks += 1; } } - return { bashTasks, agentTasks }; + return { bashTasks, agentTasks, workflowTasks }; } export function replayBackgroundProjection( diff --git a/apps/kimi-code/src/tui/workflow-v2-client.ts b/apps/kimi-code/src/tui/workflow-v2-client.ts new file mode 100644 index 0000000000..8521107140 --- /dev/null +++ b/apps/kimi-code/src/tui/workflow-v2-client.ts @@ -0,0 +1,114 @@ +import type { Session } from '@moonshot-ai/kimi-code-sdk'; + +/** + * A subset of `Session` covering all Dynamic Workflow operations. + * Defined as a `Pick` so it stays in sync with the SDK interface and + * can be implemented by a kap-server v2 client later. + */ +export type WorkflowV2Session = Pick< + Session, + | 'listWorkflows' + | 'getWorkflow' + | 'reloadWorkflows' + | 'runWorkflow' + | 'listWorkflowRuns' + | 'getWorkflowRun' + | 'cancelWorkflowRun' + | 'saveWorkflow' + | 'setWorkflowMode' +>; + +/** + * Abstraction for Dynamic Workflow operations. + * + * When a v2 session (kap-server / klient) is provided it is used + * for every workflow call. Otherwise the v1 `Session` is used as a + * transparent fallback. This lets the TUI migrate to the new engine + * gradually without breaking existing functionality. + * + * ```ts + * const client = new WorkflowV2Client(v1Session); + * // later, when the v2 engine is available: + * const client = new WorkflowV2Client(v1Session, v2Session); + * ``` + */ +export class WorkflowV2Client implements WorkflowV2Session { + constructor( + private readonly v1Session: Session, + private readonly v2Session?: WorkflowV2Session, + ) {} + + /** `true` when the kap-server v2 session is available. */ + get usingV2(): boolean { + return this.v2Session !== undefined; + } + + // --------------------------------------------------------------------------- + // Workflow discovery + // --------------------------------------------------------------------------- + + async listWorkflows() { + if (this.v2Session) return this.v2Session.listWorkflows(); + return this.v1Session.listWorkflows(); + } + + async getWorkflow(name: string) { + if (this.v2Session) return this.v2Session.getWorkflow(name); + return this.v1Session.getWorkflow(name); + } + + async reloadWorkflows() { + if (this.v2Session) return this.v2Session.reloadWorkflows(); + return this.v1Session.reloadWorkflows(); + } + + // --------------------------------------------------------------------------- + // Running workflows + // --------------------------------------------------------------------------- + + async runWorkflow(options: { + name?: string; + script?: string; + args?: string; + }) { + if (this.v2Session) return this.v2Session.runWorkflow(options); + return this.v1Session.runWorkflow(options); + } + + async listWorkflowRuns() { + if (this.v2Session) return this.v2Session.listWorkflowRuns(); + return this.v1Session.listWorkflowRuns(); + } + + async getWorkflowRun(runId: string) { + if (this.v2Session) return this.v2Session.getWorkflowRun(runId); + return this.v1Session.getWorkflowRun(runId); + } + + async cancelWorkflowRun(runId: string) { + if (this.v2Session) return this.v2Session.cancelWorkflowRun(runId); + return this.v1Session.cancelWorkflowRun(runId); + } + + async saveWorkflow(options: { + script: string; + scope: 'project' | 'user'; + overwrite?: boolean; + }) { + if (this.v2Session) return this.v2Session.saveWorkflow(options); + return this.v1Session.saveWorkflow(options); + } + + // --------------------------------------------------------------------------- + // Workflow mode (on / off) + // --------------------------------------------------------------------------- + + async setWorkflowMode( + enabled: boolean, + trigger?: 'manual' | 'command', + ): Promise { + if (this.v2Session) + return this.v2Session.setWorkflowMode(enabled, trigger ?? 'command'); + return this.v1Session.setWorkflowMode(enabled, trigger ?? 'command'); + } +} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 8bfe8561ef..eccaa0d81e 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -542,6 +542,7 @@ describe('CLI options parsing', () => { expect(commandNames).toEqual([ 'export', 'provider', + 'search', 'acp', 'web', 'server', diff --git a/apps/kimi-code/test/cli/search.test.ts b/apps/kimi-code/test/cli/search.test.ts new file mode 100644 index 0000000000..ef1a7a3072 --- /dev/null +++ b/apps/kimi-code/test/cli/search.test.ts @@ -0,0 +1,144 @@ +import type { KimiConfig, KimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { + handleSearchClear, + handleSearchSetLangSearch, + handleSearchStatus, + registerSearchCommand, + type SearchDeps, +} from '#/cli/sub/search'; + +interface TestContext { + readonly deps: SearchDeps; + readonly ensureConfigFile: ReturnType; + readonly getConfig: ReturnType; + readonly getExperimentalFeatures: ReturnType; + readonly replaceService: ReturnType; + readonly setConfig: ReturnType; + readonly removeService: ReturnType; + readonly stdout: () => string; + readonly stderr: () => string; +} + +function makeContext(config?: KimiConfig): TestContext { + let stdout = ''; + let stderr = ''; + const resolvedConfig = config ?? { providers: {} }; + const ensureConfigFile = vi.fn(async () => {}); + const getConfig = vi.fn(async () => resolvedConfig); + const getExperimentalFeatures = vi.fn(async () => [ + { id: 'langsearch-web-search', enabled: true }, + ]); + const replaceService = vi.fn(async () => resolvedConfig); + const setConfig = vi.fn(async () => resolvedConfig); + const removeService = vi.fn(async () => resolvedConfig); + const harness = { + ensureConfigFile, + getConfig, + getExperimentalFeatures, + replaceService, + setConfig, + removeService, + } as unknown as KimiHarness; + + return { + deps: { + getHarness: () => harness, + stdout: { + write: (chunk: string) => { + stdout += chunk; + return true; + }, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + return true; + }, + }, + exit: (code: number): never => { + throw new Error(`exit:${String(code)}`); + }, + }, + ensureConfigFile, + getConfig, + getExperimentalFeatures, + replaceService, + setConfig, + removeService, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +describe('kimi search', () => { + it('parses the nested set langsearch command and writes its config', async () => { + const context = makeContext(); + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerSearchCommand(program, context.deps); + + await program.parseAsync([ + 'node', + 'kimi', + 'search', + 'set', + 'langsearch', + '--api-key', + 'sk-test', + '--tier', + 'tier2', + '--count', + '10', + ]); + + expect(context.replaceService).toHaveBeenCalledWith('langsearch', { + apiKey: 'sk-test', + tier: 'tier2', + count: 10, + }); + expect(context.stdout()).toContain('LangSearch configured: tier=tier2 count=10'); + }); + + it('rejects result counts above the Web Search API maximum', async () => { + const context = makeContext(); + + await expect( + handleSearchSetLangSearch(context.deps, { + apiKey: 'sk-test', + count: '11', + }), + ).rejects.toThrow('exit:1'); + + expect(context.stderr()).toContain('Expected an integer between 1 and 10'); + expect(context.setConfig).not.toHaveBeenCalled(); + }); + + it('clears LangSearch through the explicit removal API', async () => { + const context = makeContext({ + providers: {}, + services: { + langsearch: { apiKey: 'sk-test' }, + rerank: { enabled: true, provider: 'langsearch', apiKey: 'sk-rerank-test' }, + }, + }); + + await handleSearchClear(context.deps, 'langsearch'); + + expect(context.removeService).toHaveBeenCalledWith('langsearch'); + expect(context.stdout()).toContain('LangSearch web search cleared.'); + }); + + it('reports an actionable status when no backend is configured', async () => { + const context = makeContext(); + + await handleSearchStatus(context.deps); + + expect(context.stdout()).toBe( + 'Web search backend: not configured\nRerank: not configured\n', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/web-search.test.ts b/apps/kimi-code/test/tui/commands/web-search.test.ts new file mode 100644 index 0000000000..45fce89fa4 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/web-search.test.ts @@ -0,0 +1,421 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { showWebSearchConfig } from '#/tui/commands/web-search'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; + +type MountedPanel = { + render: (width: number) => string[]; + handleInput: (data: string) => void; +}; + +interface TestConfig { + readonly providers?: Record; + readonly services?: Record; +} + +interface HostExtras { + harness: { + getConfig: ReturnType; + setConfig: ReturnType; + replaceService: ReturnType; + removeService: ReturnType; + }; + showStatus: ReturnType; + showNotice: ReturnType; + showError: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + reloadCurrentSessionView: ReturnType; +} + +const ENTER = '\r'; +const ESC = '\u001B'; +const UP = '\u001B[A'; +const DOWN = '\u001B[B'; + +function makeHost( + config: TestConfig = {}, + session?: { reloadSession: ReturnType }, +): { host: SlashCommandHost & HostExtras; getMounted: () => MountedPanel | null } { + let mounted: MountedPanel | null = null; + const normalized = { + providers: config.providers ?? {}, + services: config.services, + }; + const host = { + harness: { + getConfig: vi.fn(async () => normalized), + setConfig: vi.fn(async () => normalized), + replaceService: vi.fn(async () => normalized), + removeService: vi.fn(async () => normalized), + }, + session, + showStatus: vi.fn(), + showNotice: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mounted = panel; + }), + restoreEditor: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + } as unknown as SlashCommandHost & HostExtras; + return { host, getMounted: () => mounted }; +} + +function renderedText(panel: MountedPanel): string { + return panel.render(100).join('\n'); +} + +async function settle(): Promise { + for (let index = 0; index < 8; index++) await Promise.resolve(); +} + +async function input(panel: MountedPanel, value: string): Promise { + panel.handleInput(value); + await settle(); +} + +async function type(panel: MountedPanel, value: string): Promise { + for (const character of value) panel.handleInput(character); + await settle(); +} + +describe('showWebSearchConfig', () => { + beforeEach(() => { + setExperimentalFeatures([{ id: 'langsearch-web-search', enabled: true }]); + }); + + it('shows current provider state at the top and only the two provider menus', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-test', tier: 'tier2' }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + const panel = getMounted(); + expect(panel).not.toBeNull(); + const text = renderedText(panel!); + expect(text).toContain('Current web search: LangSearch (tier: tier2)'); + expect(text).toContain('Current rerank: LangSearch enabled'); + expect(text).toContain('Web search provider'); + expect(text).toContain('Rerank provider'); + expect(text).not.toContain('Show current backend'); + expect(text).not.toContain('Activate LangSearch'); + + await input(panel!, ESC); + await pending; + }); + + it('shows a missing-key warning when rerank depended on removed LangSearch config', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + expect(renderedText(getMounted()!)).toContain( + 'Current rerank: LangSearch missing API key', + ); + await input(getMounted()!, ESC); + await pending; + }); + + it('marks the active search provider as current', async () => { + const { host, getMounted } = makeHost({ + services: { langsearch: { apiKey: 'sk-test' } }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); + const text = renderedText(getMounted()!); + expect(text).toContain('LangSearch ← current'); + expect(text).not.toContain('Moonshot ← current'); + + await input(getMounted()!, ESC); + await pending; + }); + + it('preserves the LangSearch key used by rerank when switching to Moonshot', async () => { + const session = { reloadSession: vi.fn(async () => {}) }; + const { host, getMounted } = makeHost( + { + providers: { + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + services: { + langsearch: { apiKey: 'sk-langsearch' }, + rerank: { + provider: 'langsearch', + enabled: true, + baseUrl: 'https://rerank.example.test/v1', + customHeaders: { 'X-Test': 'test' }, + }, + }, + }, + session, + ); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, UP); // Moonshot (LangSearch starts selected) + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('Kimi Code OAuth'); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('moonshotSearch', { + baseUrl: 'https://api.kimi.com/coding/v1/search', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }); + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-langsearch', + baseUrl: 'https://rerank.example.test/v1', + customHeaders: { 'X-Test': 'test' }, + }); + expect(host.harness.replaceService.mock.invocationCallOrder[1]).toBeLessThan( + host.harness.removeService.mock.invocationCallOrder[0]!, + ); + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + expect(session.reloadSession).toHaveBeenCalledTimes(1); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + session, + 'Moonshot web search configured. Session reloaded.', + ); + }); + + it('keeps a dedicated rerank key unchanged when switching to Moonshot', async () => { + const { host, getMounted } = makeHost({ + providers: { + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + services: { + langsearch: { apiKey: 'sk-langsearch' }, + rerank: { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, UP); // Moonshot + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Kimi Code OAuth + await pending; + + expect(host.harness.replaceService).not.toHaveBeenCalledWith( + 'rerank', + expect.anything(), + ); + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + }); + + it('configures Moonshot manually with an automatically derived search URL', async () => { + const { host, getMounted } = makeHost(); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, ENTER); // Moonshot + await input(getMounted()!, ENTER); // Moonshot API key (only auth option) + expect(renderedText(getMounted()!)).toContain('Moonshot API region'); + await input(getMounted()!, ENTER); // China + expect(renderedText(getMounted()!)).toContain( + 'https://api.moonshot.cn/v1/search', + ); + await type(getMounted()!, 'sk-test'); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('moonshotSearch', { + baseUrl: 'https://api.moonshot.cn/v1/search', + apiKey: 'sk-test', + }); + expect(host.showStatus).toHaveBeenCalledWith('Moonshot web search configured.'); + }); + + it('keeps Moonshot as fallback when configuring LangSearch', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, DOWN); // LangSearch + await input(getMounted()!, ENTER); + await type(getMounted()!, 'sk-langsearch'); + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // free tier + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('langsearch', { + apiKey: 'sk-langsearch', + tier: 'free', + }); + expect(host.harness.removeService).not.toHaveBeenCalledWith('moonshotSearch'); + }); + + it('opens management for the current provider and removes it', async () => { + const { host, getMounted } = makeHost({ + services: { langsearch: { apiKey: 'sk-test' } }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, ENTER); // current LangSearch + expect(renderedText(getMounted()!)).toContain('Edit configuration'); + expect(renderedText(getMounted()!)).toContain('Remove provider'); + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + expect(host.showStatus).toHaveBeenCalledWith('LangSearch web search removed.'); + }); + + it('configures rerank independently while Moonshot is the search provider', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // LangSearch + await type(getMounted()!, 'sk-rerank'); + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Enabled + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }); + expect(host.harness.removeService).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Rerank configured.'); + }); + + it('edits the current rerank provider status', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-search' }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); // Rerank provider + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('LangSearch ← current'); + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('Current status: enabled'); + await input(getMounted()!, ENTER); // Status + expect(renderedText(getMounted()!)).toContain('Enabled ← current'); + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Disabled + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: false, + }); + expect(host.showStatus).toHaveBeenCalledWith('Rerank disabled.'); + }); + + it('clears the dedicated rerank key so it reuses the search key', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-search' }, + rerank: { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // Current LangSearch + await input(getMounted()!, DOWN); // API key + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Empty means reuse search key + await pending; + + expect(host.harness.removeService).not.toHaveBeenCalledWith('rerank'); + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: undefined, + }); + expect(host.showStatus).toHaveBeenCalledWith('Rerank API key updated.'); + }); + + it('removes the current rerank provider from its editor', async () => { + const { host, getMounted } = makeHost({ + services: { + rerank: { + provider: 'langsearch', + enabled: false, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // Current LangSearch + await input(getMounted()!, DOWN); + await input(getMounted()!, DOWN); // Remove provider + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.removeService).toHaveBeenCalledWith('rerank'); + expect(host.showStatus).toHaveBeenCalledWith('Rerank provider removed.'); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/workflow.test.ts b/apps/kimi-code/test/tui/commands/workflow.test.ts new file mode 100644 index 0000000000..2cf28ed5b4 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/workflow.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleWorkflowCommand } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { closeWorkflowsBrowser } from '#/tui/controllers/workflows-browser'; +import { currentTheme } from '#/tui/theme'; + +const ENTER = '\r'; +const ESCAPE = ''; +const DOWN = ''; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const WORKFLOW = { + name: 'demo-flow', + description: 'Demo workflow.', + whenToUse: undefined, + phases: [{ title: 'One', detail: 'first' }, { title: 'Two' }], + path: '/tmp/demo-flow.js', + source: 'project' as const, +}; + +const WORKFLOW_DETAIL = { ...WORKFLOW, script: 'export const meta = {};\nreturn 1;\n' }; + +const RUN = { + runId: 'wfrun-abc123', + workflowName: 'demo-flow', + description: 'Demo workflow.', + phases: WORKFLOW.phases, + status: 'running' as const, + phase: 'One', + phaseIndex: 0, + agentCalls: 2, + logs: ['[phase] One'], + startedAt: Date.now(), + taskId: 'workflow-xyz', + args: '', + source: 'project' as const, + scriptPath: '/tmp/demo-flow.js', +}; + +function makeSession() { + return { + listWorkflows: vi.fn(async () => ({ workflows: [WORKFLOW], skipped: [] as { path: string; reason: string }[] })), + getWorkflow: vi.fn(async (name: string) => ({ workflow: name === 'demo-flow' ? WORKFLOW_DETAIL : null })), + reloadWorkflows: vi.fn(async () => ({ workflows: [WORKFLOW], skipped: [{ path: '/tmp/bad.js', reason: 'no meta' }] })), + runWorkflow: vi.fn(async () => ({ runId: 'wfrun-abc123', taskId: 'workflow-xyz', workflowName: 'demo-flow' })), + listWorkflowRuns: vi.fn(async () => ({ runs: [RUN] })), + getWorkflowRun: vi.fn(async () => ({ run: { ...RUN, script: WORKFLOW_DETAIL.script } })), + cancelWorkflowRun: vi.fn(async () => ({ cancelled: true })), + saveWorkflow: vi.fn(async () => ({ path: '/tmp/.kimi-code/workflows/demo-flow.js', name: 'demo-flow' })), + }; +} + +function makeHost(options: { hasSession?: boolean } = {}) { + const session = makeSession(); + const children: unknown[] = ['layout']; + const host = { + state: { + appState: {}, + theme: currentTheme, + ui: { + children, + clear: vi.fn(() => children.splice(0, children.length)), + addChild: vi.fn((child: unknown) => children.push(child)), + setFocus: vi.fn(), + requestRender: vi.fn(), + }, + terminal: { rows: 24 }, + editor: {}, + transcriptContainer: { addChild: vi.fn() }, + }, + session: (options.hasSession ?? true) ? session : undefined, + requireSession: () => session, + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +interface TestComponent { + handleInput(data: string): void; + render(width: number): string[]; +} + +function mountedPrompt(host: SlashCommandHost): TestComponent { + const mock = host.mountEditorReplacement as ReturnType; + const component = mock.mock.calls.at(-1)?.[0] as TestComponent | undefined; + if (component === undefined) throw new Error('expected a mounted prompt'); + return component; +} + +afterEach(() => { + closeWorkflowsBrowser(); +}); + +describe('handleWorkflowCommand', () => { + it('requires an active session', async () => { + const { host } = makeHost({ hasSession: false }); + await handleWorkflowCommand(host, 'list'); + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('session')); + }); + + it('lists discovered workflows', async () => { + const { host } = makeHost(); + await handleWorkflowCommand(host, 'list'); + expect(host.showNotice).toHaveBeenCalledWith( + 'Workflows (1)', + expect.stringContaining('demo-flow'), + ); + }); + + it('opens the runs browser when invoked without arguments', async () => { + const { host } = makeHost(); + await handleWorkflowCommand(host, ''); + const ui = host.state.ui as unknown as { children: unknown[] }; + const browser = ui.children.at(-1) as TestComponent; + const rendered = stripAnsi(browser.render(100).join('\n')); + expect(rendered).toContain('Workflow runs'); + browser.handleInput(ESCAPE); // close + }); + + it('executes the workflow immediately when called with run', async () => { + const { host, session } = makeHost(); + await handleWorkflowCommand(host, 'run demo-flow some args'); + expect(session.runWorkflow).toHaveBeenCalledWith({ name: 'demo-flow', args: 'some args' }); + expect(host.showStatus).toHaveBeenCalledWith(expect.stringContaining('wfrun-abc123')); + }); + + it('executes without args when none are provided', async () => { + const { host, session } = makeHost(); + await handleWorkflowCommand(host, 'run demo-flow'); + expect(session.runWorkflow).toHaveBeenCalledWith({ name: 'demo-flow', args: '' }); + }); + + it('errors when the workflow does not exist', async () => { + const { host, session } = makeHost(); + session.runWorkflow = vi.fn(async () => { throw new Error('workflow not found: missing-flow'); }); + await handleWorkflowCommand(host, 'run missing-flow'); + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('missing-flow')); + }); + + it('cancels a run by unique prefix', async () => { + const { host, session } = makeHost(); + await handleWorkflowCommand(host, 'cancel wfrun-abc'); + expect(session.cancelWorkflowRun).toHaveBeenCalledWith('wfrun-abc123'); + }); + + it('rejects an unknown run prefix', async () => { + const { host, session } = makeHost(); + await handleWorkflowCommand(host, 'cancel nope'); + expect(session.cancelWorkflowRun).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('nope')); + }); + + it('saves a run to the project scope by default and to user scope with --user', async () => { + const { host, session } = makeHost(); + await handleWorkflowCommand(host, 'save wfrun-abc'); + expect(session.saveWorkflow).toHaveBeenCalledWith({ + script: WORKFLOW_DETAIL.script, + scope: 'project', + }); + await handleWorkflowCommand(host, 'save wfrun-abc --user'); + expect(session.saveWorkflow).toHaveBeenCalledWith({ + script: WORKFLOW_DETAIL.script, + scope: 'user', + }); + }); + + it('reloads workflows and reports skipped files', async () => { + const { host } = makeHost(); + await handleWorkflowCommand(host, 'reload'); + expect(host.showStatus).toHaveBeenCalledWith(expect.stringContaining('1 discovered (1 invalid skipped)')); + }); + + it('opens the runs browser with /workflow runs', async () => { + const { host } = makeHost(); + await handleWorkflowCommand(host, 'runs'); + const ui = host.state.ui as unknown as { children: unknown[] }; + const browser = ui.children.at(-1) as TestComponent; + const rendered = stripAnsi(browser.render(100).join('\n')); + expect(rendered).toContain('Workflow runs'); + expect(rendered).toContain('demo-flow'); + browser.handleInput(ESCAPE); // close + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e..a4b68d814b 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -51,6 +51,7 @@ const appState: AppState = { planMode: false, inputMode: 'prompt', swarmMode: false, + workflowMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb6..ae77433c7d 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -28,6 +28,7 @@ const appState: AppState = { planMode: false, inputMode: 'prompt', swarmMode: false, + workflowMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts index 610ea23e26..376e60d841 100644 --- a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -1,5 +1,5 @@ import { visibleWidth } from '@moonshot-ai/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; @@ -18,4 +18,32 @@ describe('ApiKeyInputDialogComponent', () => { } } }); + + it('submits an empty value when the caller allows key reuse', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent( + 'LangSearch Rerank', + ['Leave empty to reuse the search key.'], + onDone, + { allowEmpty: true }, + ); + + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ kind: 'ok', value: '' }); + }); + + it('rejects an empty value by default', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent( + 'LangSearch', + ['Paste your API key below.'], + onDone, + ); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(dialog.render(80).join('\n')).toContain('API key cannot be empty.'); + }); }); diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index 101cd19116..14f3254f25 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -44,7 +44,7 @@ describe('FooterComponent — background task / agent badges', () => { it('renders the task badge alone when only bash tasks are running', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); + footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0, workflowTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); expect(out).not.toMatch(/agents? running/); @@ -52,7 +52,7 @@ describe('FooterComponent — background task / agent badges', () => { it('renders the agent badge alone when only agent tasks are running', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); + footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1, workflowTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 agent running\]/); expect(out).not.toMatch(/tasks? running/); @@ -60,7 +60,7 @@ describe('FooterComponent — background task / agent badges', () => { it('renders both badges side by side when both are non-zero', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); + footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3, workflowTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); expect(out).toMatch(/\[3 agents running\]/); @@ -70,7 +70,7 @@ describe('FooterComponent — background task / agent badges', () => { it('pluralizes correctly across both badges', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); + footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1, workflowTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); expect(out).toMatch(/\[1 agent running\]/); @@ -78,9 +78,9 @@ describe('FooterComponent — background task / agent badges', () => { it('updates badges live via setBackgroundCounts', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); + footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1, workflowTasks: 0 }); expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); - footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); + footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0, workflowTasks: 0 }); const after = strip(footer.render(120)[0]!); expect(after).not.toMatch(/tasks? running/); expect(after).not.toMatch(/agents? running/); @@ -88,7 +88,7 @@ describe('FooterComponent — background task / agent badges', () => { it('clamps negative counts to 0', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); + footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2, workflowTasks: 0 }); const out = strip(footer.render(120)[0]!); expect(out).not.toMatch(/tasks? running/); expect(out).not.toMatch(/agents? running/); @@ -96,7 +96,7 @@ describe('FooterComponent — background task / agent badges', () => { it('drops the badges when terminal is too narrow to fit them', () => { const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); + footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3, workflowTasks: 0 }); // Extremely narrow width: footer primary content fills the line, so leftLine wins. const [line1] = footer.render(20); expect(line1).toBeDefined(); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf0702..c5a30acc5c 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -14,6 +14,7 @@ function fakeInitialAppState(): AppState { planMode: false, inputMode: 'prompt', swarmMode: false, + workflowMode: false, thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79f8cfb552..19095971c0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -177,6 +177,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool permission: { mode: overrides.permissionMode ?? 'manual', rules: [] }, plan: overrides.planMode ? { id: 'plan-1', content: '', path: '/tmp/plan.md' } : null, swarmMode: false, + workflowMode: false, usage: {}, tools: [], background: [], diff --git a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index cdc8709c36..1ff7ea2316 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -302,6 +302,52 @@ describe('approval adapter', () => { ]); }); + it('renders a workflow_run approval with phases, limits, warning and the full script', () => { + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-workflow', + toolName: 'Workflow', + action: 'Run workflow deep-research', + display: { + kind: 'workflow_run', + workflow_name: 'deep-research', + description: 'Deep research harness.', + phases: [ + { title: 'Scope', detail: 'Decompose' }, + { title: 'Search' }, + ], + args: 'hermes agent', + script: 'export const meta = {};', + source: 'builtin', + limits: { max_concurrency: 4, max_agent_calls: 50, max_duration_ms: 1_800_000 }, + consumption_warning: 'Runs many subagents — high token usage.', + }, + }); + + expect(adapted.description).toBe('Run workflow deep-research?'); + expect(adapted.display).toEqual([ + { + type: 'brief', + text: [ + 'Workflow: deep-research', + 'Deep research harness.', + 'Phases: 1. Scope — Decompose · 2. Search', + 'Args: hermes agent', + 'Limits: max 50 agent calls · 4 concurrent · 30 min', + 'Runs many subagents — high token usage.', + 'Source: builtin', + ].join('\n'), + }, + { + type: 'file_content', + path: 'deep-research.workflow.js', + content: 'export const meta = {};', + }, + ]); + // Default approve/reject choices — approval starts the run, rejection blocks it. + expect(adapted.choices.some((choice) => choice.response === 'approved')).toBe(true); + expect(adapted.choices.some((choice) => choice.response === 'rejected')).toBe(true); + }); + it('maps approved-for-session responses into core approval payloads', () => { expect( adaptPanelResponse({ diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 9c220b8681..ea21008c0c 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -159,6 +159,20 @@ describe('loadPluginMarketplace', () => { source: join(REPO_ROOT, 'plugins/official/kimi-datasource'), }), ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'image_generation', + tier: 'official', + source: join(REPO_ROOT, 'plugins/official/image_generation'), + }), + ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'audio_generation', + tier: 'official', + source: join(REPO_ROOT, 'plugins/official/audio_generation'), + }), + ); }); it('loads the default CDN marketplace with injectable fetch', async () => { diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..2997112a29 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -26,6 +26,7 @@ import Onboarding from './components/settings/Onboarding.vue'; import GlobalLoading from './components/GlobalLoading.vue'; import DebugPanel from './debug/DebugPanel.vue'; import { isTraceEnabled } from './debug/trace'; +import { getKimiWebApi } from './api'; import { useKimiWebClient } from './composables/useKimiWebClient'; import { useConfirmDialog } from './composables/useConfirmDialog'; import type { PromptAttachment } from './composables/useKimiWebClient'; @@ -39,8 +40,9 @@ import { useIsMobile } from './composables/useIsMobile'; import { openDialogCount } from './composables/dialogStack'; import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; +import WorkflowHubDialog from './components/workflow/WorkflowHubDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; -import type { AppConfig, ThinkingLevel } from './api/types'; +import type { AppConfig, AppWorkflowSummary, AppWorkflowRunRecord, ThinkingLevel } from './api/types'; import { commitLevel, effectiveThinkingLevel, segmentsFor } from './lib/modelThinking'; import { stripSkillPrefix } from './lib/slashCommands'; import Button from './components/ui/Button.vue'; @@ -319,6 +321,10 @@ const showLogin = ref(false); const showAddWorkspace = ref(false); const showStatusPanel = ref(false); const showSettings = ref(false); +const showWorkflowHub = ref(false); +const workflows = ref([]); +const workflowRuns = ref([]); +const workflowLoading = ref(false); type SubmitPayload = { text: string; @@ -343,6 +349,7 @@ const anyOverlayOpen = computed( showAddWorkspace.value || showStatusPanel.value || showSettings.value || + showWorkflowHub.value || showOnboarding.value || showMobileSwitcher.value || showMobileSettings.value, @@ -524,6 +531,11 @@ function handleCommand(cmd: string): void { else client.toggleGoalMode(); return; } + // `/workflow` opens the workflow hub; `/workflow ` runs a workflow. + if (cmd === '/workflow' || cmd.startsWith('/workflow ')) { + handleWorkflowCommand(cmd); + return; + } // `/btw ` opens (creating if needed) the side chat and asks it; bare // `/btw` toggles the side-chat tab for the active session. if (cmd === '/btw' || cmd.startsWith('/btw ')) { @@ -649,6 +661,38 @@ function handleCloseAddWorkspace(): void { showAddWorkspace.value = false; } +async function loadWorkflows(): Promise { + const sid = client.activeSessionId.value; + if (!sid) return; + workflowLoading.value = true; + try { + const [wfs, rns] = await Promise.all([ + getKimiWebApi().listWorkflows(sid), + getKimiWebApi().listWorkflowRuns(sid), + ]); + workflows.value = wfs; + workflowRuns.value = rns; + } catch { + workflows.value = []; + workflowRuns.value = []; + } finally { + workflowLoading.value = false; + } +} + +function handleWorkflowCommand(cmd: string): void { + const arg = cmd.slice('/workflow'.length).trim(); + if (arg) { + // /workflow — run a workflow right away + const sid = client.activeSessionId.value; + if (sid) void getKimiWebApi().runWorkflow(sid, { name: arg }); + return; + } + // bare /workflow — open the hub + showWorkflowHub.value = true; + void loadWorkflows(); +} + function focusComposerAfterDraft(): void { void nextTick(() => { conversationPaneRef.value?.focusComposer(); @@ -794,6 +838,7 @@ function openPr(url: string): void { :plan-mode="client.planMode.value" :swarm-mode="client.swarmMode.value" :goal-mode="client.goalMode.value" + :workflow-mode="client.workflowMode.value" :models="client.models.value" :starred-ids="client.starredModelIds.value" :skills="client.skills.value" @@ -843,6 +888,7 @@ function openPr(url: string): void { @toggle-plan="client.togglePlanMode()" @toggle-swarm="client.toggleSwarmMode()" @toggle-goal="client.toggleGoalMode()" + @toggle-workflow="client.toggleWorkflowMode()" @create-goal="client.createGoal($event)" @control-goal="client.controlGoal($event)" @refresh-git-status="client.activeSessionId.value && client.loadGitStatus(client.activeSessionId.value)" @@ -1043,6 +1089,20 @@ function openPr(url: string): void { @close="showStatusPanel = false" /> + + + { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/workflows`, + ); + return data.items.map(toAppWorkflowSummary); + } + + async getWorkflow(sessionId: string, name: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/workflows/${encodeURIComponent(name)}`, + ); + return data.workflow ? toAppWorkflowDetail(data.workflow) : null; + } + + async runWorkflow( + sessionId: string, + input: { name?: string; script?: string; args?: string }, + ): Promise<{ runId: string; taskId: string; workflowName: string }> { + const body: Record = {}; + if (input.name !== undefined) body.name = input.name; + if (input.script !== undefined) body.script = input.script; + if (input.args !== undefined) body.args = input.args; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/workflows/run`, + body, + ); + return { + runId: data.run_id, + taskId: data.task_id, + workflowName: data.workflow_name, + }; + } + + async listWorkflowRuns(sessionId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/workflows/runs`, + ); + return data.items.map(toAppWorkflowRunRecord); + } + + async getWorkflowRun(sessionId: string, runId: string): Promise { + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/workflows/runs/${encodeURIComponent(runId)}`, + ); + return data.run ? toAppWorkflowRunRecord(data.run) : null; + } + + async cancelWorkflowRun(sessionId: string, runId: string): Promise<{ cancelled: boolean }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/workflows/runs/${encodeURIComponent(runId)}/cancel`, + ); + return { cancelled: data.cancelled }; + } + + async saveWorkflow( + sessionId: string, + input: { script: string; scope: 'project' | 'user'; overwrite?: boolean }, + ): Promise<{ path: string; name: string }> { + const body: WireSaveWorkflowBody = { + script: input.script, + scope: input.scope, + overwrite: input.overwrite, + }; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/workflows/save`, + body, + ); + return { path: data.path, name: data.name }; + } + + async reloadWorkflows( + sessionId: string, + ): Promise<{ workflows: AppWorkflowSummary[]; skipped: { path: string; reason: string }[] }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/workflows/reload`, + ); + return { + workflows: data.workflows.map(toAppWorkflowSummary), + skipped: data.skipped, + }; + } + // ------------------------------------------------------------------------- // WebSocket events // ------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index a9e9c03be6..a83d62956d 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -20,6 +20,11 @@ import type { AppTaskStatus, AppWorkspace, ApprovalResponse, + AppWorkflowPhase, + AppWorkflowSummary, + AppWorkflowDetail, + AppWorkflowRunRecord, + AppWorkflowRunStatus, ImageSource, PromptSubmission, QuestionAnswer, @@ -49,6 +54,11 @@ import type { WireWorkspace, WireEvent, WireConfig, + WireWorkflowPhase, + WireWorkflowSummary, + WireWorkflowDetail, + WireWorkflowRunRecord, + WireWorkflowRunStatus, } from './wire'; // --------------------------------------------------------------------------- @@ -259,6 +269,7 @@ export function toWirePromptSubmission(input: PromptSubmission): WirePromptSubmi permission_mode: input.permissionMode, plan_mode: input.planMode, swarm_mode: input.swarmMode, + workflow_mode: input.workflowMode, goal_objective: input.goalObjective, goal_control: input.goalControl, }; @@ -791,3 +802,61 @@ export function wireEventSessionId(wire: WireEvent): string { export function wireEventSeq(wire: WireEvent): number { return wire.seq; } + +// --------------------------------------------------------------------------- +// Workflow mappers +// --------------------------------------------------------------------------- + +export function toAppWorkflowPhase(wire: WireWorkflowPhase): AppWorkflowPhase { + return { + title: wire.title, + detail: wire.detail, + }; +} + +export function toAppWorkflowSummary(wire: WireWorkflowSummary): AppWorkflowSummary { + return { + name: wire.name, + description: wire.description, + whenToUse: wire.when_to_use, + argumentHint: wire.argument_hint, + phases: wire.phases.map(toAppWorkflowPhase), + path: wire.path, + source: wire.source, + }; +} + +export function toAppWorkflowDetail(wire: WireWorkflowDetail): AppWorkflowDetail { + return { + ...toAppWorkflowSummary(wire), + script: wire.script, + }; +} + +export function toAppWorkflowRunStatus(wire: WireWorkflowRunStatus): AppWorkflowRunStatus { + return wire; +} + +export function toAppWorkflowRunRecord(wire: WireWorkflowRunRecord): AppWorkflowRunRecord { + return { + runId: wire.run_id, + workflowName: wire.workflow_name, + description: wire.description, + phases: wire.phases.map(toAppWorkflowPhase), + status: toAppWorkflowRunStatus(wire.status), + phase: wire.phase, + phaseIndex: wire.phase_index, + agentCalls: wire.agent_calls, + logs: wire.logs, + error: wire.error, + resultJson: wire.result_json, + startedAt: wire.started_at, + endedAt: wire.ended_at, + taskId: wire.task_id, + scriptPath: wire.script_path, + source: wire.source, + script: wire.script, + args: wire.args, + callerAgentId: wire.caller_agent_id, + }; +} diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index bb87f3e3c0..87b74b724a 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -92,6 +92,7 @@ export interface WireSession { permission_mode?: string; plan_mode?: boolean; swarm_mode?: boolean; + workflow_mode?: boolean; goal_objective?: string; goal_control?: 'pause' | 'resume' | 'cancel'; }; @@ -108,6 +109,7 @@ export interface WireSessionRuntimeStatus { permission: string; plan_mode: boolean; swarm_mode: boolean; + workflow_mode: boolean; context_tokens: number; max_context_tokens: number; context_usage: number; @@ -221,6 +223,7 @@ export interface WirePromptSubmission { permission_mode?: string; plan_mode?: boolean; swarm_mode?: boolean; + workflow_mode?: boolean; goal_objective?: string; goal_control?: 'pause' | 'resume' | 'cancel'; } @@ -873,3 +876,92 @@ export type WireEvent = | WireEventModelCatalogChanged // Unknown / future events | WireEventUnknown; + +// --------------------------------------------------------------------------- +// Workflows +// --------------------------------------------------------------------------- + +export interface WireWorkflowPhase { + title: string; + detail?: string; +} + +export interface WireWorkflowSummary { + name: string; + description: string; + when_to_use?: string; + argument_hint?: string; + phases: WireWorkflowPhase[]; + path: string; + source: string; +} + +export interface WireWorkflowDetail extends WireWorkflowSummary { + script: string; +} + +export type WireWorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface WireWorkflowRunRecord { + run_id: string; + workflow_name: string; + description: string; + phases: WireWorkflowPhase[]; + status: WireWorkflowRunStatus; + phase?: string; + phase_index?: number; + agent_calls: number; + logs: string[]; + error?: string; + result_json?: string; + started_at: number; + ended_at?: number; + task_id?: string; + script_path?: string; + source: string; + script: string; + args: string; + caller_agent_id: string; +} + +export interface WireListWorkflowsResponse { + items: WireWorkflowSummary[]; +} + +export interface WireGetWorkflowResponse { + workflow: WireWorkflowDetail | null; +} + +export interface WireRunWorkflowResponse { + run_id: string; + task_id: string; + workflow_name: string; +} + +export interface WireListWorkflowRunsResponse { + items: WireWorkflowRunRecord[]; +} + +export interface WireGetWorkflowRunResponse { + run: WireWorkflowRunRecord | null; +} + +export interface WireCancelWorkflowRunResponse { + cancelled: boolean; +} + +export interface WireSaveWorkflowBody { + script: string; + scope: 'project' | 'user'; + overwrite?: boolean; +} + +export interface WireSaveWorkflowResponse { + path: string; + name: string; +} + +export interface WireReloadWorkflowsResponse { + workflows: WireWorkflowSummary[]; + skipped: Array<{ path: string; reason: string }>; +} diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..6021c4a8a3 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -103,6 +103,7 @@ export interface AppSessionRuntimeStatus { permission: string; planMode: boolean; swarmMode: boolean; + workflowMode: boolean; contextTokens: number; maxContextTokens: number; contextUsage: number; @@ -217,6 +218,7 @@ export interface PromptSubmission { permissionMode?: 'manual' | 'auto' | 'yolo'; planMode?: boolean; swarmMode?: boolean; + workflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; } @@ -426,7 +428,7 @@ export type AppEvent = lastTurnReason?: 'completed' | 'cancelled' | 'failed'; } | { type: 'sessionMetaUpdated'; sessionId: string; title?: string; lastPrompt?: string } - | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean; thinking?: string } + | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string; swarmMode?: boolean; planMode?: boolean; workflowMode?: boolean; thinking?: string } | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } | { type: 'compactionStarted'; sessionId: string; trigger: 'manual' | 'auto'; instruction?: string } | { type: 'compactionCompleted'; sessionId: string; tokensBefore?: number; tokensAfter?: number; summary?: string } @@ -687,6 +689,53 @@ export interface AppSkill { source: string; } +// --------------------------------------------------------------------------- +// Workflow +// --------------------------------------------------------------------------- + +export interface AppWorkflowPhase { + title: string; + detail?: string; +} + +export interface AppWorkflowSummary { + name: string; + description: string; + whenToUse?: string; + argumentHint?: string; + phases: AppWorkflowPhase[]; + path: string; + source: string; +} + +export interface AppWorkflowDetail extends AppWorkflowSummary { + script: string; +} + +export type AppWorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface AppWorkflowRunRecord { + runId: string; + workflowName: string; + description: string; + phases: AppWorkflowPhase[]; + status: AppWorkflowRunStatus; + phase?: string; + phaseIndex?: number; + agentCalls: number; + logs: string[]; + error?: string; + resultJson?: string; + startedAt: number; + endedAt?: number; + taskId?: string; + scriptPath?: string; + source: string; + script: string; + args: string; + callerAgentId: string; +} + // --------------------------------------------------------------------------- // KimiWebApi — the app-facing interface // --------------------------------------------------------------------------- @@ -704,7 +753,7 @@ export interface KimiWebApi { createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; /** Fetch one session by id (deep links beyond the first listSessions page). */ getSession(sessionId: string): Promise; - updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string; permissionMode?: string; planMode?: boolean; swarmMode?: boolean; workflowMode?: boolean; goalObjective?: string; goalControl?: 'pause' | 'resume' | 'cancel'; thinking?: string }): Promise; getSessionStatus(sessionId: string): Promise; /** Current goal snapshot, or null when the session has no active goal. */ getSessionGoal(sessionId: string): Promise; @@ -801,6 +850,16 @@ export interface KimiWebApi { } | null>; cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; logout(): Promise<{ loggedOut: boolean }>; + + // Workflows + listWorkflows(sessionId: string): Promise; + getWorkflow(sessionId: string, name: string): Promise; + runWorkflow(sessionId: string, input: { name?: string; script?: string; args?: string }): Promise<{ runId: string; taskId: string; workflowName: string }>; + listWorkflowRuns(sessionId: string): Promise; + getWorkflowRun(sessionId: string, runId: string): Promise; + cancelWorkflowRun(sessionId: string, runId: string): Promise<{ cancelled: boolean }>; + saveWorkflow(sessionId: string, input: { script: string; scope: 'project' | 'user'; overwrite?: boolean }): Promise<{ path: string; name: string }>; + reloadWorkflows(sessionId: string): Promise<{ workflows: AppWorkflowSummary[]; skipped: { path: string; reason: string }[] }>; } /** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */ diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 160c77a242..c97c0fd6d8 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -33,6 +33,7 @@ const props = defineProps<{ planMode?: boolean; swarmMode?: boolean; goalMode?: boolean; + workflowMode?: boolean; activationBadges?: ActivationBadges; models?: AppModel[]; starredIds?: string[]; @@ -66,6 +67,7 @@ const emit = defineEmits<{ togglePlan: []; toggleSwarm: []; toggleGoal: []; + toggleWorkflow: []; openBtw: []; createGoal: [objective: string]; controlGoal: [action: 'pause' | 'resume' | 'cancel']; @@ -276,6 +278,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :plan-mode="planMode" :swarm-mode="swarmMode" :goal-mode="goalMode" + :workflow-mode="workflowMode" :goal="goal" :activation-badges="activationBadges" :models="models" @@ -291,6 +294,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); @toggle-plan="emit('togglePlan')" @toggle-swarm="emit('toggleSwarm')" @toggle-goal="emit('toggleGoal')" + @toggle-workflow="emit('toggleWorkflow')" @open-btw="emit('openBtw')" @create-goal="emit('createGoal', $event)" @control-goal="emit('controlGoal', $event)" diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 5d2e16f6d9..addcb338d1 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{ planMode?: boolean; swarmMode?: boolean; goalMode?: boolean; + workflowMode?: boolean; goal?: AppGoal | null; activationBadges?: ActivationBadges; /** Available models for the quick-switch dropdown. */ @@ -97,6 +98,7 @@ const emit = defineEmits<{ togglePlan: []; toggleSwarm: []; toggleGoal: []; + toggleWorkflow: []; openBtw: []; createGoal: [objective: string]; controlGoal: [action: 'pause' | 'resume' | 'cancel']; @@ -669,6 +671,7 @@ function thinkingSegmentLabel(segment: string): string { // Plan toggle const planOn = computed(() => props.planMode === true); const swarmOn = computed(() => props.swarmMode === true); +const workflowOn = computed(() => props.workflowMode === true); const goalStatus = computed(() => props.goal?.status ?? props.activationBadges?.goal?.status ?? null); const goalActive = computed(() => goalStatus.value !== null && goalStatus.value !== 'complete'); const goalArmed = computed(() => goalActive.value || props.goalMode === true); @@ -684,7 +687,7 @@ const modesMenuRef = ref(null); // The menu is position:fixed (so no composer stacking context can paint over // it); these coords anchor it just above the pill, computed on open. const modesMenuStyle = ref>({}); -const anyModeActive = computed(() => planOn.value || swarmOn.value || goalArmed.value); +const anyModeActive = computed(() => planOn.value || swarmOn.value || goalArmed.value || workflowOn.value); function closeModes(): void { modesOpen.value = false; document.removeEventListener('mousedown', onModesDocClick); @@ -718,7 +721,7 @@ const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descK { mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' }, { mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' }, ]; -const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc'] as const; +const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc', 'status.workflowDesc'] as const; const menuMeasureRef = ref(null); const permissionDescriptionWidth = ref(''); @@ -1007,6 +1010,7 @@ function selectModel(modelId: string): void { {{ t('status.planLabel') }} {{ t('status.swarmLabel') }} {{ t('status.goalLabel') }} + {{ t('status.workflowLabel') }}