From 6d694350769822ff92e8b764cf283302aa04b5a3 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Fri, 3 Apr 2026 22:00:45 +0800 Subject: [PATCH 01/28] refactor: rewrite kimi-cli from Python to Bun + TypeScript + React Ink Complete rewrite of the CLI from Python to TypeScript/Bun with React Ink UI, including OpenAI-compatible LLM provider, slash commands, tool system, subagent support, and all upstream Python fixes synced from main. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci-kimi-cli-ts.yml | 134 ++ .gitignore | 13 +- CLAUDE.md | 111 ++ bun.lock | 320 +++++ index.ts | 1 + package.json | 52 + src/kimi_cli_ts/agentspec.ts | 167 +++ src/kimi_cli_ts/app.ts | 306 ++++ src/kimi_cli_ts/approval_runtime/index.ts | 325 +++++ src/kimi_cli_ts/auth/index.ts | 5 + src/kimi_cli_ts/auth/oauth.ts | 560 ++++++++ src/kimi_cli_ts/auth/platforms.ts | 230 +++ src/kimi_cli_ts/background/ids.ts | 21 + src/kimi_cli_ts/background/manager.ts | 367 +++++ src/kimi_cli_ts/background/models.ts | 212 +++ src/kimi_cli_ts/background/store.ts | 251 ++++ src/kimi_cli_ts/background/summary.ts | 79 + src/kimi_cli_ts/background/worker.ts | 193 +++ src/kimi_cli_ts/cli/export.ts | 185 +++ src/kimi_cli_ts/cli/index.ts | 518 +++++++ src/kimi_cli_ts/cli/info.ts | 48 + src/kimi_cli_ts/cli/login.ts | 53 + src/kimi_cli_ts/cli/logout.ts | 44 + src/kimi_cli_ts/cli/mcp.ts | 218 +++ src/kimi_cli_ts/cli/plugin.ts | 385 +++++ src/kimi_cli_ts/cli/vis.ts | 33 + src/kimi_cli_ts/cli/web.ts | 52 + src/kimi_cli_ts/config.ts | 305 ++++ src/kimi_cli_ts/constant.ts | 30 + src/kimi_cli_ts/exception.ts | 60 + src/kimi_cli_ts/hooks/config.ts | 23 + src/kimi_cli_ts/hooks/engine.ts | 352 +++++ src/kimi_cli_ts/hooks/events.ts | 176 +++ src/kimi_cli_ts/index.ts | 10 + src/kimi_cli_ts/llm.ts | 722 ++++++++++ src/kimi_cli_ts/metadata.ts | 118 ++ src/kimi_cli_ts/notifications/index.ts | 23 + src/kimi_cli_ts/notifications/llm.ts | 127 ++ src/kimi_cli_ts/notifications/manager.ts | 156 ++ src/kimi_cli_ts/notifications/models.ts | 132 ++ src/kimi_cli_ts/notifications/notifier.ts | 49 + src/kimi_cli_ts/notifications/store.ts | 151 ++ src/kimi_cli_ts/notifications/wire.ts | 35 + src/kimi_cli_ts/plugin/manager.ts | 267 ++++ src/kimi_cli_ts/plugin/tool.ts | 194 +++ src/kimi_cli_ts/session.ts | 337 +++++ src/kimi_cli_ts/session_fork.ts | 316 ++++ src/kimi_cli_ts/skill/flow/d2.ts | 435 ++++++ src/kimi_cli_ts/skill/flow/index.ts | 110 ++ src/kimi_cli_ts/skill/flow/mermaid.ts | 273 ++++ src/kimi_cli_ts/skill/index.ts | 379 +++++ src/kimi_cli_ts/soul/agent.ts | 471 ++++++ src/kimi_cli_ts/soul/approval.ts | 134 ++ src/kimi_cli_ts/soul/compaction.ts | 224 +++ src/kimi_cli_ts/soul/context.ts | 288 ++++ src/kimi_cli_ts/soul/denwarenji.ts | 49 + src/kimi_cli_ts/soul/dynamic_injection.ts | 102 ++ .../soul/dynamic_injections/plan_mode.ts | 236 +++ .../soul/dynamic_injections/yolo_mode.ts | 36 + src/kimi_cli_ts/soul/kimisoul.ts | 1266 +++++++++++++++++ src/kimi_cli_ts/soul/message.ts | 106 ++ src/kimi_cli_ts/soul/slash.ts | 206 +++ src/kimi_cli_ts/soul/toolset.ts | 193 +++ src/kimi_cli_ts/subagents/builder.ts | 56 + src/kimi_cli_ts/subagents/core.ts | 77 + src/kimi_cli_ts/subagents/git_context.ts | 127 ++ src/kimi_cli_ts/subagents/models.ts | 52 + src/kimi_cli_ts/subagents/output.ts | 59 + src/kimi_cli_ts/subagents/registry.ts | 30 + src/kimi_cli_ts/subagents/runner.ts | 355 +++++ src/kimi_cli_ts/subagents/store.ts | 210 +++ src/kimi_cli_ts/tools/agent/agent.ts | 216 +++ src/kimi_cli_ts/tools/ask_user/ask_user.ts | 99 ++ .../tools/background/background.ts | 170 +++ src/kimi_cli_ts/tools/base.ts | 29 + src/kimi_cli_ts/tools/display.ts | 44 + src/kimi_cli_ts/tools/dmail/dmail.ts | 43 + src/kimi_cli_ts/tools/file/glob.ts | 97 ++ src/kimi_cli_ts/tools/file/grep.ts | 400 ++++++ src/kimi_cli_ts/tools/file/plan_mode.ts | 50 + src/kimi_cli_ts/tools/file/read.ts | 367 +++++ src/kimi_cli_ts/tools/file/read_media.ts | 115 ++ src/kimi_cli_ts/tools/file/replace.ts | 184 +++ src/kimi_cli_ts/tools/file/utils.ts | 223 +++ src/kimi_cli_ts/tools/file/write.ts | 185 +++ src/kimi_cli_ts/tools/plan/heroes.ts | 292 ++++ src/kimi_cli_ts/tools/plan/plan.ts | 337 +++++ src/kimi_cli_ts/tools/registry.ts | 97 ++ src/kimi_cli_ts/tools/shell/shell.ts | 213 +++ src/kimi_cli_ts/tools/think/think.ts | 28 + src/kimi_cli_ts/tools/todo/todo.ts | 50 + src/kimi_cli_ts/tools/types.ts | 349 +++++ src/kimi_cli_ts/tools/web/fetch.ts | 252 ++++ src/kimi_cli_ts/tools/web/search.ts | 119 ++ src/kimi_cli_ts/types.ts | 133 ++ .../ui/components/ApprovalPrompt.tsx | 105 ++ .../ui/components/CommandPanel.tsx | 288 ++++ src/kimi_cli_ts/ui/components/SlashMenu.tsx | 90 ++ src/kimi_cli_ts/ui/components/Spinner.tsx | 54 + src/kimi_cli_ts/ui/components/StatusBar.tsx | 110 ++ src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 91 ++ src/kimi_cli_ts/ui/hooks/index.ts | 6 + src/kimi_cli_ts/ui/hooks/useApproval.ts | 66 + src/kimi_cli_ts/ui/hooks/useInput.ts | 103 ++ src/kimi_cli_ts/ui/hooks/useWire.ts | 238 ++++ src/kimi_cli_ts/ui/print/index.ts | 464 ++++++ src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx | 328 +++++ src/kimi_cli_ts/ui/shell/DebugPanel.tsx | 183 +++ src/kimi_cli_ts/ui/shell/Prompt.tsx | 183 +++ src/kimi_cli_ts/ui/shell/QuestionPanel.tsx | 406 ++++++ src/kimi_cli_ts/ui/shell/ReplayPanel.tsx | 219 +++ src/kimi_cli_ts/ui/shell/SetupWizard.tsx | 258 ++++ src/kimi_cli_ts/ui/shell/Shell.tsx | 310 ++++ src/kimi_cli_ts/ui/shell/TaskBrowser.tsx | 284 ++++ src/kimi_cli_ts/ui/shell/UsagePanel.tsx | 257 ++++ src/kimi_cli_ts/ui/shell/Visualize.tsx | 917 ++++++++++++ src/kimi_cli_ts/ui/shell/commands/add_dir.ts | 91 ++ src/kimi_cli_ts/ui/shell/commands/editor.ts | 47 + .../ui/shell/commands/export_import.ts | 112 ++ src/kimi_cli_ts/ui/shell/commands/feedback.ts | 67 + src/kimi_cli_ts/ui/shell/commands/info.ts | 81 ++ src/kimi_cli_ts/ui/shell/commands/init.ts | 64 + src/kimi_cli_ts/ui/shell/commands/login.ts | 275 ++++ src/kimi_cli_ts/ui/shell/commands/misc.ts | 20 + src/kimi_cli_ts/ui/shell/commands/model.ts | 31 + src/kimi_cli_ts/ui/shell/commands/session.ts | 53 + src/kimi_cli_ts/ui/shell/commands/usage.ts | 69 + src/kimi_cli_ts/ui/shell/console.ts | 24 + src/kimi_cli_ts/ui/shell/events.ts | 71 + src/kimi_cli_ts/ui/shell/index.ts | 48 + src/kimi_cli_ts/ui/shell/keyboard.ts | 105 ++ src/kimi_cli_ts/ui/shell/slash.ts | 303 ++++ src/kimi_cli_ts/ui/theme.ts | 267 ++++ src/kimi_cli_ts/utils/async.ts | 78 + src/kimi_cli_ts/utils/changelog.ts | 19 + src/kimi_cli_ts/utils/clipboard.ts | 94 ++ src/kimi_cli_ts/utils/diff.ts | 298 ++++ src/kimi_cli_ts/utils/environment.ts | 95 ++ src/kimi_cli_ts/utils/export.ts | 261 ++++ src/kimi_cli_ts/utils/logging.ts | 51 + src/kimi_cli_ts/utils/message.ts | 47 + src/kimi_cli_ts/utils/path.ts | 71 + src/kimi_cli_ts/utils/queue.ts | 101 ++ src/kimi_cli_ts/utils/sensitive.ts | 89 ++ src/kimi_cli_ts/utils/signals.ts | 44 + src/kimi_cli_ts/utils/string.ts | 48 + src/kimi_cli_ts/utils/yaml.ts | 179 +++ src/kimi_cli_ts/wire/file.ts | 299 ++++ src/kimi_cli_ts/wire/index.ts | 12 + src/kimi_cli_ts/wire/jsonrpc.ts | 295 ++++ src/kimi_cli_ts/wire/protocol.ts | 6 + src/kimi_cli_ts/wire/root_hub.ts | 39 + src/kimi_cli_ts/wire/serde.ts | 90 ++ src/kimi_cli_ts/wire/server.ts | 955 +++++++++++++ src/kimi_cli_ts/wire/types.ts | 701 +++++++++ src/kimi_cli_ts/wire/wire_core.ts | 198 +++ tests/background/background_manager.test.ts | 51 + tests/conftest.ts | 291 ++++ tests/core/agentspec.test.ts | 118 ++ tests/core/approval.test.ts | 127 ++ tests/core/compaction.test.ts | 107 ++ tests/core/config.test.ts | 177 +++ tests/core/context.test.ts | 173 +++ tests/core/llm.test.ts | 192 +++ tests/core/session.test.ts | 127 ++ tests/core/slash_commands.test.ts | 137 ++ tests/core/toolset.test.ts | 172 +++ tests/core/wire_message.test.ts | 199 +++ tests/e2e/basic_e2e.test.ts | 86 ++ tests/e2e/cli_entry.test.ts | 52 + tests/hooks/engine.test.ts | 206 +++ tests/tools/ask_user.test.ts | 86 ++ tests/tools/fetch_url.test.ts | 137 ++ tests/tools/glob.test.ts | 186 +++ tests/tools/grep.test.ts | 476 +++++++ tests/tools/read_file.test.ts | 292 ++++ tests/tools/shell.test.ts | 224 +++ tests/tools/str_replace_file.test.ts | 201 +++ tests/tools/think.test.ts | 52 + tests/tools/todo.test.ts | 74 + tests/tools/tool_schemas.test.ts | 304 ++++ tests/tools/write_file.test.ts | 160 +++ tests/ui/console.test.ts | 35 + tests/ui/events.test.ts | 69 + tests/ui/keyboard.test.ts | 36 + tests/ui/print_mode.test.ts | 147 ++ tests/ui/slash_commands.test.ts | 121 ++ tests/ui/theme.test.ts | 107 ++ tests/utils/async.test.ts | 113 ++ tests/utils/environment.test.ts | 41 + tests/utils/logging.test.ts | 91 ++ tests/utils/path.test.ts | 95 ++ tests/utils/result_builder.test.ts | 161 +++ tests/utils/sensitive.test.ts | 78 + tsconfig.json | 31 + 195 files changed, 34321 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-kimi-cli-ts.yml create mode 100644 CLAUDE.md create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/kimi_cli_ts/agentspec.ts create mode 100644 src/kimi_cli_ts/app.ts create mode 100644 src/kimi_cli_ts/approval_runtime/index.ts create mode 100644 src/kimi_cli_ts/auth/index.ts create mode 100644 src/kimi_cli_ts/auth/oauth.ts create mode 100644 src/kimi_cli_ts/auth/platforms.ts create mode 100644 src/kimi_cli_ts/background/ids.ts create mode 100644 src/kimi_cli_ts/background/manager.ts create mode 100644 src/kimi_cli_ts/background/models.ts create mode 100644 src/kimi_cli_ts/background/store.ts create mode 100644 src/kimi_cli_ts/background/summary.ts create mode 100644 src/kimi_cli_ts/background/worker.ts create mode 100644 src/kimi_cli_ts/cli/export.ts create mode 100644 src/kimi_cli_ts/cli/index.ts create mode 100644 src/kimi_cli_ts/cli/info.ts create mode 100644 src/kimi_cli_ts/cli/login.ts create mode 100644 src/kimi_cli_ts/cli/logout.ts create mode 100644 src/kimi_cli_ts/cli/mcp.ts create mode 100644 src/kimi_cli_ts/cli/plugin.ts create mode 100644 src/kimi_cli_ts/cli/vis.ts create mode 100644 src/kimi_cli_ts/cli/web.ts create mode 100644 src/kimi_cli_ts/config.ts create mode 100644 src/kimi_cli_ts/constant.ts create mode 100644 src/kimi_cli_ts/exception.ts create mode 100644 src/kimi_cli_ts/hooks/config.ts create mode 100644 src/kimi_cli_ts/hooks/engine.ts create mode 100644 src/kimi_cli_ts/hooks/events.ts create mode 100644 src/kimi_cli_ts/index.ts create mode 100644 src/kimi_cli_ts/llm.ts create mode 100644 src/kimi_cli_ts/metadata.ts create mode 100644 src/kimi_cli_ts/notifications/index.ts create mode 100644 src/kimi_cli_ts/notifications/llm.ts create mode 100644 src/kimi_cli_ts/notifications/manager.ts create mode 100644 src/kimi_cli_ts/notifications/models.ts create mode 100644 src/kimi_cli_ts/notifications/notifier.ts create mode 100644 src/kimi_cli_ts/notifications/store.ts create mode 100644 src/kimi_cli_ts/notifications/wire.ts create mode 100644 src/kimi_cli_ts/plugin/manager.ts create mode 100644 src/kimi_cli_ts/plugin/tool.ts create mode 100644 src/kimi_cli_ts/session.ts create mode 100644 src/kimi_cli_ts/session_fork.ts create mode 100644 src/kimi_cli_ts/skill/flow/d2.ts create mode 100644 src/kimi_cli_ts/skill/flow/index.ts create mode 100644 src/kimi_cli_ts/skill/flow/mermaid.ts create mode 100644 src/kimi_cli_ts/skill/index.ts create mode 100644 src/kimi_cli_ts/soul/agent.ts create mode 100644 src/kimi_cli_ts/soul/approval.ts create mode 100644 src/kimi_cli_ts/soul/compaction.ts create mode 100644 src/kimi_cli_ts/soul/context.ts create mode 100644 src/kimi_cli_ts/soul/denwarenji.ts create mode 100644 src/kimi_cli_ts/soul/dynamic_injection.ts create mode 100644 src/kimi_cli_ts/soul/dynamic_injections/plan_mode.ts create mode 100644 src/kimi_cli_ts/soul/dynamic_injections/yolo_mode.ts create mode 100644 src/kimi_cli_ts/soul/kimisoul.ts create mode 100644 src/kimi_cli_ts/soul/message.ts create mode 100644 src/kimi_cli_ts/soul/slash.ts create mode 100644 src/kimi_cli_ts/soul/toolset.ts create mode 100644 src/kimi_cli_ts/subagents/builder.ts create mode 100644 src/kimi_cli_ts/subagents/core.ts create mode 100644 src/kimi_cli_ts/subagents/git_context.ts create mode 100644 src/kimi_cli_ts/subagents/models.ts create mode 100644 src/kimi_cli_ts/subagents/output.ts create mode 100644 src/kimi_cli_ts/subagents/registry.ts create mode 100644 src/kimi_cli_ts/subagents/runner.ts create mode 100644 src/kimi_cli_ts/subagents/store.ts create mode 100644 src/kimi_cli_ts/tools/agent/agent.ts create mode 100644 src/kimi_cli_ts/tools/ask_user/ask_user.ts create mode 100644 src/kimi_cli_ts/tools/background/background.ts create mode 100644 src/kimi_cli_ts/tools/base.ts create mode 100644 src/kimi_cli_ts/tools/display.ts create mode 100644 src/kimi_cli_ts/tools/dmail/dmail.ts create mode 100644 src/kimi_cli_ts/tools/file/glob.ts create mode 100644 src/kimi_cli_ts/tools/file/grep.ts create mode 100644 src/kimi_cli_ts/tools/file/plan_mode.ts create mode 100644 src/kimi_cli_ts/tools/file/read.ts create mode 100644 src/kimi_cli_ts/tools/file/read_media.ts create mode 100644 src/kimi_cli_ts/tools/file/replace.ts create mode 100644 src/kimi_cli_ts/tools/file/utils.ts create mode 100644 src/kimi_cli_ts/tools/file/write.ts create mode 100644 src/kimi_cli_ts/tools/plan/heroes.ts create mode 100644 src/kimi_cli_ts/tools/plan/plan.ts create mode 100644 src/kimi_cli_ts/tools/registry.ts create mode 100644 src/kimi_cli_ts/tools/shell/shell.ts create mode 100644 src/kimi_cli_ts/tools/think/think.ts create mode 100644 src/kimi_cli_ts/tools/todo/todo.ts create mode 100644 src/kimi_cli_ts/tools/types.ts create mode 100644 src/kimi_cli_ts/tools/web/fetch.ts create mode 100644 src/kimi_cli_ts/tools/web/search.ts create mode 100644 src/kimi_cli_ts/types.ts create mode 100644 src/kimi_cli_ts/ui/components/ApprovalPrompt.tsx create mode 100644 src/kimi_cli_ts/ui/components/CommandPanel.tsx create mode 100644 src/kimi_cli_ts/ui/components/SlashMenu.tsx create mode 100644 src/kimi_cli_ts/ui/components/Spinner.tsx create mode 100644 src/kimi_cli_ts/ui/components/StatusBar.tsx create mode 100644 src/kimi_cli_ts/ui/components/WelcomeBox.tsx create mode 100644 src/kimi_cli_ts/ui/hooks/index.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useApproval.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useInput.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useWire.ts create mode 100644 src/kimi_cli_ts/ui/print/index.ts create mode 100644 src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/DebugPanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/Prompt.tsx create mode 100644 src/kimi_cli_ts/ui/shell/QuestionPanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/ReplayPanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/SetupWizard.tsx create mode 100644 src/kimi_cli_ts/ui/shell/Shell.tsx create mode 100644 src/kimi_cli_ts/ui/shell/TaskBrowser.tsx create mode 100644 src/kimi_cli_ts/ui/shell/UsagePanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/Visualize.tsx create mode 100644 src/kimi_cli_ts/ui/shell/commands/add_dir.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/editor.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/export_import.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/feedback.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/info.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/init.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/login.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/misc.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/model.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/session.ts create mode 100644 src/kimi_cli_ts/ui/shell/commands/usage.ts create mode 100644 src/kimi_cli_ts/ui/shell/console.ts create mode 100644 src/kimi_cli_ts/ui/shell/events.ts create mode 100644 src/kimi_cli_ts/ui/shell/index.ts create mode 100644 src/kimi_cli_ts/ui/shell/keyboard.ts create mode 100644 src/kimi_cli_ts/ui/shell/slash.ts create mode 100644 src/kimi_cli_ts/ui/theme.ts create mode 100644 src/kimi_cli_ts/utils/async.ts create mode 100644 src/kimi_cli_ts/utils/changelog.ts create mode 100644 src/kimi_cli_ts/utils/clipboard.ts create mode 100644 src/kimi_cli_ts/utils/diff.ts create mode 100644 src/kimi_cli_ts/utils/environment.ts create mode 100644 src/kimi_cli_ts/utils/export.ts create mode 100644 src/kimi_cli_ts/utils/logging.ts create mode 100644 src/kimi_cli_ts/utils/message.ts create mode 100644 src/kimi_cli_ts/utils/path.ts create mode 100644 src/kimi_cli_ts/utils/queue.ts create mode 100644 src/kimi_cli_ts/utils/sensitive.ts create mode 100644 src/kimi_cli_ts/utils/signals.ts create mode 100644 src/kimi_cli_ts/utils/string.ts create mode 100644 src/kimi_cli_ts/utils/yaml.ts create mode 100644 src/kimi_cli_ts/wire/file.ts create mode 100644 src/kimi_cli_ts/wire/index.ts create mode 100644 src/kimi_cli_ts/wire/jsonrpc.ts create mode 100644 src/kimi_cli_ts/wire/protocol.ts create mode 100644 src/kimi_cli_ts/wire/root_hub.ts create mode 100644 src/kimi_cli_ts/wire/serde.ts create mode 100644 src/kimi_cli_ts/wire/server.ts create mode 100644 src/kimi_cli_ts/wire/types.ts create mode 100644 src/kimi_cli_ts/wire/wire_core.ts create mode 100644 tests/background/background_manager.test.ts create mode 100644 tests/conftest.ts create mode 100644 tests/core/agentspec.test.ts create mode 100644 tests/core/approval.test.ts create mode 100644 tests/core/compaction.test.ts create mode 100644 tests/core/config.test.ts create mode 100644 tests/core/context.test.ts create mode 100644 tests/core/llm.test.ts create mode 100644 tests/core/session.test.ts create mode 100644 tests/core/slash_commands.test.ts create mode 100644 tests/core/toolset.test.ts create mode 100644 tests/core/wire_message.test.ts create mode 100644 tests/e2e/basic_e2e.test.ts create mode 100644 tests/e2e/cli_entry.test.ts create mode 100644 tests/hooks/engine.test.ts create mode 100644 tests/tools/ask_user.test.ts create mode 100644 tests/tools/fetch_url.test.ts create mode 100644 tests/tools/glob.test.ts create mode 100644 tests/tools/grep.test.ts create mode 100644 tests/tools/read_file.test.ts create mode 100644 tests/tools/shell.test.ts create mode 100644 tests/tools/str_replace_file.test.ts create mode 100644 tests/tools/think.test.ts create mode 100644 tests/tools/todo.test.ts create mode 100644 tests/tools/tool_schemas.test.ts create mode 100644 tests/tools/write_file.test.ts create mode 100644 tests/ui/console.test.ts create mode 100644 tests/ui/events.test.ts create mode 100644 tests/ui/keyboard.test.ts create mode 100644 tests/ui/print_mode.test.ts create mode 100644 tests/ui/slash_commands.test.ts create mode 100644 tests/ui/theme.test.ts create mode 100644 tests/utils/async.test.ts create mode 100644 tests/utils/environment.test.ts create mode 100644 tests/utils/logging.test.ts create mode 100644 tests/utils/path.test.ts create mode 100644 tests/utils/result_builder.test.ts create mode 100644 tests/utils/sensitive.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci-kimi-cli-ts.yml b/.github/workflows/ci-kimi-cli-ts.yml new file mode 100644 index 000000000..ea94d2009 --- /dev/null +++ b/.github/workflows/ci-kimi-cli-ts.yml @@ -0,0 +1,134 @@ +name: CI - Kimi CLI (TypeScript) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + typecheck-and-test: + name: Typecheck & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + + - name: Typecheck + run: ./node_modules/.bin/tsc --noEmit --skipLibCheck + + - name: Test + run: bun test + + build-binaries: + name: Build Binary (${{ matrix.os }}-${{ matrix.arch }}) + needs: typecheck-and-test + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: x64 + runner: ubuntu-latest + target: bun-linux-x64 + artifact: kimi-linux-x64 + - os: linux + arch: arm64 + runner: ubuntu-latest + target: bun-linux-arm64 + artifact: kimi-linux-arm64 + - os: darwin + arch: x64 + runner: macos-13 + target: bun-darwin-x64 + artifact: kimi-darwin-x64 + - os: darwin + arch: arm64 + runner: macos-14 + target: bun-darwin-arm64 + artifact: kimi-darwin-arm64 + - os: windows + arch: x64 + runner: windows-latest + target: bun-windows-x64 + artifact: kimi-windows-x64 + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + + - name: Build standalone binary + run: bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi --target=${{ matrix.target }} + + - name: Verify binary (unix) + if: matrix.os != 'windows' + run: | + chmod +x dist/kimi + dist/kimi --version + dist/kimi --help + + - name: Verify binary (windows) + if: matrix.os == 'windows' + run: | + dist\kimi.exe --version + dist\kimi.exe --help + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/kimi* + if-no-files-found: error + + release: + name: Create Release + needs: build-binaries + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && startsWith(github.event.head_commit.message, 'release:') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get version + id: version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Prepare release assets + run: | + mkdir -p release + for dir in artifacts/kimi-*; do + name=$(basename "$dir") + if [[ "$name" == *windows* ]]; then + (cd "$dir" && zip -r "../../release/${name}.zip" .) + else + chmod +x "$dir/kimi" + tar -czf "release/${name}.tar.gz" -C "$dir" kimi + fi + done + ls -la release/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + files: release/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index f00b02169..0570094ef 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,15 @@ node_modules/ static/ .memo/ .entire -.claude \ No newline at end of file +.claude + +# TypeScript / Bun +out +*.tgz +coverage +*.lcov +logs +*.log +*.tsbuildinfo +.eslintcache +.cache diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b8100b77e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 000000000..2b62cb02b --- /dev/null +++ b/bun.lock @@ -0,0 +1,320 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ts", + "dependencies": { + "@anthropic-ai/sdk": "^0.81.0", + "@google/genai": "^1.48.0", + "@iarna/toml": "^2.2.5", + "chalk": "^5.6.2", + "commander": "^14.0.3", + "globby": "^16.2.0", + "ink": "^6.8.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "micromatch": "^4.0.8", + "nanoid": "^5.1.7", + "openai": "^6.33.0", + "react": "^19.2.4", + "smol-toml": "^1.6.1", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/bun": "latest", + "@types/micromatch": "^4.0.10", + "@types/react": "^19.2.14", + "react-devtools-core": "^7.0.1", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "https://mirrors.tencent.com/npm/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "https://mirrors.tencent.com/npm/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "https://mirrors.tencent.com/npm/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/biome/-/biome-2.4.10.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "https://mirrors.tencent.com/npm/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + + "@google/genai": ["@google/genai@1.48.0", "https://mirrors.tencent.com/npm/@google/genai/-/genai-1.48.0.tgz", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ=="], + + "@iarna/toml": ["@iarna/toml@2.2.5", "https://mirrors.tencent.com/npm/@iarna/toml/-/toml-2.2.5.tgz", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://mirrors.tencent.com/npm/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://mirrors.tencent.com/npm/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://mirrors.tencent.com/npm/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://mirrors.tencent.com/npm/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://mirrors.tencent.com/npm/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "https://mirrors.tencent.com/npm/@protobufjs/codegen/-/codegen-2.0.4.tgz", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://mirrors.tencent.com/npm/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://mirrors.tencent.com/npm/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "https://mirrors.tencent.com/npm/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://mirrors.tencent.com/npm/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "https://mirrors.tencent.com/npm/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://mirrors.tencent.com/npm/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://mirrors.tencent.com/npm/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://mirrors.tencent.com/npm/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@types/braces": ["@types/braces@3.0.5", "https://mirrors.tencent.com/npm/@types/braces/-/braces-3.0.5.tgz", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], + + "@types/bun": ["@types/bun@1.3.11", "https://mirrors.tencent.com/npm/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/micromatch": ["@types/micromatch@4.0.10", "https://mirrors.tencent.com/npm/@types/micromatch/-/micromatch-4.0.10.tgz", { "dependencies": { "@types/braces": "*" } }, "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ=="], + + "@types/node": ["@types/node@25.5.0", "https://mirrors.tencent.com/npm/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/react": ["@types/react@19.2.14", "https://mirrors.tencent.com/npm/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/retry": ["@types/retry@0.12.0", "https://mirrors.tencent.com/npm/@types/retry/-/retry-0.12.0.tgz", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "agent-base": ["agent-base@7.1.4", "https://mirrors.tencent.com/npm/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "https://mirrors.tencent.com/npm/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "auto-bind": ["auto-bind@5.0.1", "https://mirrors.tencent.com/npm/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "base64-js": ["base64-js@1.5.1", "https://mirrors.tencent.com/npm/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "https://mirrors.tencent.com/npm/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "braces": ["braces@3.0.3", "https://mirrors.tencent.com/npm/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://mirrors.tencent.com/npm/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.11", "https://mirrors.tencent.com/npm/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "chalk": ["chalk@5.6.2", "https://mirrors.tencent.com/npm/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-boxes": ["cli-boxes@3.0.0", "https://mirrors.tencent.com/npm/cli-boxes/-/cli-boxes-3.0.0.tgz", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "https://mirrors.tencent.com/npm/cli-cursor/-/cli-cursor-4.0.0.tgz", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-spinners": ["cli-spinners@2.9.2", "https://mirrors.tencent.com/npm/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@5.2.0", "https://mirrors.tencent.com/npm/cli-truncate/-/cli-truncate-5.2.0.tgz", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "code-excerpt": ["code-excerpt@4.0.0", "https://mirrors.tencent.com/npm/code-excerpt/-/code-excerpt-4.0.0.tgz", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "commander": ["commander@14.0.3", "https://mirrors.tencent.com/npm/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "https://mirrors.tencent.com/npm/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "csstype": ["csstype@3.2.3", "https://mirrors.tencent.com/npm/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://mirrors.tencent.com/npm/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "https://mirrors.tencent.com/npm/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "environment": ["environment@1.1.0", "https://mirrors.tencent.com/npm/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-toolkit": ["es-toolkit@1.45.1", "https://mirrors.tencent.com/npm/es-toolkit/-/es-toolkit-1.45.1.tgz", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "https://mirrors.tencent.com/npm/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "extend": ["extend@3.0.2", "https://mirrors.tencent.com/npm/extend/-/extend-3.0.2.tgz", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-glob": ["fast-glob@3.3.3", "https://mirrors.tencent.com/npm/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "https://mirrors.tencent.com/npm/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fetch-blob": ["fetch-blob@3.2.0", "https://mirrors.tencent.com/npm/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "fill-range": ["fill-range@7.1.1", "https://mirrors.tencent.com/npm/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "https://mirrors.tencent.com/npm/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.1.4", "https://mirrors.tencent.com/npm/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "https://mirrors.tencent.com/npm/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "https://mirrors.tencent.com/npm/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "glob-parent": ["glob-parent@5.1.2", "https://mirrors.tencent.com/npm/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "globby": ["globby@16.2.0", "https://mirrors.tencent.com/npm/globby/-/globby-16.2.0.tgz", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", "is-path-inside": "^4.0.0", "slash": "^5.1.0", "unicorn-magic": "^0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], + + "google-auth-library": ["google-auth-library@10.6.2", "https://mirrors.tencent.com/npm/google-auth-library/-/google-auth-library-10.6.2.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "https://mirrors.tencent.com/npm/google-logging-utils/-/google-logging-utils-1.1.3.tgz", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://mirrors.tencent.com/npm/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ignore": ["ignore@7.0.5", "https://mirrors.tencent.com/npm/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "indent-string": ["indent-string@5.0.0", "https://mirrors.tencent.com/npm/indent-string/-/indent-string-5.0.0.tgz", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ink": ["ink@6.8.0", "https://mirrors.tencent.com/npm/ink/-/ink-6.8.0.tgz", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + + "ink-spinner": ["ink-spinner@5.0.0", "https://mirrors.tencent.com/npm/ink-spinner/-/ink-spinner-5.0.0.tgz", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "ink-text-input": ["ink-text-input@6.0.0", "https://mirrors.tencent.com/npm/ink-text-input/-/ink-text-input-6.0.0.tgz", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + + "is-extglob": ["is-extglob@2.1.1", "https://mirrors.tencent.com/npm/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://mirrors.tencent.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "https://mirrors.tencent.com/npm/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ci": ["is-in-ci@2.0.0", "https://mirrors.tencent.com/npm/is-in-ci/-/is-in-ci-2.0.0.tgz", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + + "is-number": ["is-number@7.0.0", "https://mirrors.tencent.com/npm/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-path-inside": ["is-path-inside@4.0.0", "https://mirrors.tencent.com/npm/is-path-inside/-/is-path-inside-4.0.0.tgz", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="], + + "json-bigint": ["json-bigint@1.0.0", "https://mirrors.tencent.com/npm/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://mirrors.tencent.com/npm/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "https://mirrors.tencent.com/npm/jwa/-/jwa-2.0.1.tgz", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "https://mirrors.tencent.com/npm/jws/-/jws-4.0.1.tgz", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "https://mirrors.tencent.com/npm/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "merge2": ["merge2@1.4.1", "https://mirrors.tencent.com/npm/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "https://mirrors.tencent.com/npm/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mimic-fn": ["mimic-fn@2.1.0", "https://mirrors.tencent.com/npm/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "ms": ["ms@2.1.3", "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@5.1.7", "https://mirrors.tencent.com/npm/nanoid/-/nanoid-5.1.7.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], + + "node-domexception": ["node-domexception@1.0.0", "https://mirrors.tencent.com/npm/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "https://mirrors.tencent.com/npm/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "onetime": ["onetime@5.1.2", "https://mirrors.tencent.com/npm/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "openai": ["openai@6.33.0", "https://mirrors.tencent.com/npm/openai/-/openai-6.33.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="], + + "p-retry": ["p-retry@4.6.2", "https://mirrors.tencent.com/npm/p-retry/-/p-retry-4.6.2.tgz", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "patch-console": ["patch-console@2.0.0", "https://mirrors.tencent.com/npm/patch-console/-/patch-console-2.0.0.tgz", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "picomatch": ["picomatch@2.3.2", "https://mirrors.tencent.com/npm/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "protobufjs": ["protobufjs@7.5.4", "https://mirrors.tencent.com/npm/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "https://mirrors.tencent.com/npm/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.4", "https://mirrors.tencent.com/npm/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "https://mirrors.tencent.com/npm/react-devtools-core/-/react-devtools-core-7.0.1.tgz", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.33.0", "https://mirrors.tencent.com/npm/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "restore-cursor": ["restore-cursor@4.0.0", "https://mirrors.tencent.com/npm/restore-cursor/-/restore-cursor-4.0.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "retry": ["retry@0.13.1", "https://mirrors.tencent.com/npm/retry/-/retry-0.13.1.tgz", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "reusify": ["reusify@1.1.0", "https://mirrors.tencent.com/npm/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "https://mirrors.tencent.com/npm/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "scheduler": ["scheduler@0.27.0", "https://mirrors.tencent.com/npm/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "shell-quote": ["shell-quote@1.8.3", "https://mirrors.tencent.com/npm/shell-quote/-/shell-quote-1.8.3.tgz", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "signal-exit": ["signal-exit@3.0.7", "https://mirrors.tencent.com/npm/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slash": ["slash@5.1.0", "https://mirrors.tencent.com/npm/slash/-/slash-5.1.0.tgz", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + + "slice-ansi": ["slice-ansi@8.0.0", "https://mirrors.tencent.com/npm/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "smol-toml": ["smol-toml@1.6.1", "https://mirrors.tencent.com/npm/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + + "stack-utils": ["stack-utils@2.0.6", "https://mirrors.tencent.com/npm/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "string-width": ["string-width@8.2.0", "https://mirrors.tencent.com/npm/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "strip-ansi": ["strip-ansi@7.2.0", "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tagged-tag": ["tagged-tag@1.0.0", "https://mirrors.tencent.com/npm/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "https://mirrors.tencent.com/npm/terminal-size/-/terminal-size-4.0.1.tgz", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "https://mirrors.tencent.com/npm/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-algebra": ["ts-algebra@2.0.0", "https://mirrors.tencent.com/npm/ts-algebra/-/ts-algebra-2.0.0.tgz", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-fest": ["type-fest@5.5.0", "https://mirrors.tencent.com/npm/type-fest/-/type-fest-5.5.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], + + "typescript": ["typescript@5.9.3", "https://mirrors.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "https://mirrors.tencent.com/npm/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unicorn-magic": ["unicorn-magic@0.4.0", "https://mirrors.tencent.com/npm/unicorn-magic/-/unicorn-magic-0.4.0.tgz", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://mirrors.tencent.com/npm/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "widest-line": ["widest-line@6.0.0", "https://mirrors.tencent.com/npm/widest-line/-/widest-line-6.0.0.tgz", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@7.5.10", "https://mirrors.tencent.com/npm/ws/-/ws-7.5.10.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + + "yoga-layout": ["yoga-layout@3.2.1", "https://mirrors.tencent.com/npm/yoga-layout/-/yoga-layout-3.2.1.tgz", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.3.6", "https://mirrors.tencent.com/npm/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://mirrors.tencent.com/npm/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@google/genai/ws": ["ws@8.20.0", "https://mirrors.tencent.com/npm/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "ink/ws": ["ws@8.20.0", "https://mirrors.tencent.com/npm/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "ink-text-input/type-fest": ["type-fest@4.41.0", "https://mirrors.tencent.com/npm/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "openai/ws": ["ws@8.20.0", "https://mirrors.tencent.com/npm/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "https://mirrors.tencent.com/npm/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 000000000..f67b2c645 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..8326a8c90 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "kimi-cli", + "version": "2.0.0", + "module": "src/kimi_cli_ts/index.ts", + "type": "module", + "private": true, + "bin": { + "kimi": "src/kimi_cli_ts/index.ts" + }, + "scripts": { + "start": "bun run src/kimi_cli_ts/index.ts", + "dev": "bun --watch run src/kimi_cli_ts/index.ts", + "test": "bun test", + "build": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi", + "build:linux-x64": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi --target=bun-linux-x64", + "build:linux-arm64": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi --target=bun-linux-arm64", + "build:darwin-x64": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi --target=bun-darwin-x64", + "build:darwin-arm64": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi --target=bun-darwin-arm64", + "build:windows-x64": "bun build src/kimi_cli_ts/index.ts --compile --outfile dist/kimi.exe --target=bun-windows-x64", + "lint": "biome check src/", + "format": "biome format --write src/", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/bun": "latest", + "@types/micromatch": "^4.0.10", + "@types/react": "^19.2.14", + "react-devtools-core": "^7.0.1" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.81.0", + "@google/genai": "^1.48.0", + "@iarna/toml": "^2.2.5", + "chalk": "^5.6.2", + "commander": "^14.0.3", + "globby": "^16.2.0", + "ink": "^6.8.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "micromatch": "^4.0.8", + "nanoid": "^5.1.7", + "openai": "^6.33.0", + "react": "^19.2.4", + "smol-toml": "^1.6.1", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.2" + } +} diff --git a/src/kimi_cli_ts/agentspec.ts b/src/kimi_cli_ts/agentspec.ts new file mode 100644 index 000000000..2fa6331a2 --- /dev/null +++ b/src/kimi_cli_ts/agentspec.ts @@ -0,0 +1,167 @@ +/** + * Agent spec loader — corresponds to Python agentspec.py + * Loads agent YAML specifications with inheritance support. + */ + +import { join, dirname, resolve } from "node:path"; +import { z } from "zod/v4"; +import { parse as parseYaml } from "./utils/yaml.ts"; + +// ── Constants ─────────────────────────────────────────── + +const DEFAULT_AGENT_SPEC_VERSION = "1"; +const SUPPORTED_VERSIONS = new Set([DEFAULT_AGENT_SPEC_VERSION]); + +export function getAgentsDir(): string { + return join(dirname(import.meta.dir), "kimi_cli", "agents"); +} + +const INHERIT = Symbol("inherit"); +type Inherit = typeof INHERIT; + +// ── Types ─────────────────────────────────────────────── + +export interface SubagentSpec { + path: string; + description: string; +} + +export interface AgentSpec { + extend?: string; + name: string | Inherit; + systemPromptPath: string | Inherit; + systemPromptArgs: Record; + model?: string; + whenToUse?: string; + tools: string[] | null | Inherit; + allowedTools: string[] | null | Inherit; + excludeTools: string[] | null | Inherit; + subagents: Record | null | Inherit; +} + +export interface ResolvedAgentSpec { + name: string; + systemPromptPath: string; + systemPromptArgs: Record; + model: string | null; + whenToUse: string; + tools: string[]; + allowedTools: string[] | null; + excludeTools: string[]; + subagents: Record; +} + +// ── Errors ────────────────────────────────────────────── + +export class AgentSpecError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentSpecError"; + } +} + +// ── Loader ────────────────────────────────────────────── + +function parseAgentData(data: Record, agentFileDir: string): AgentSpec { + const agent = (data.agent ?? {}) as Record; + + const spec: AgentSpec = { + extend: agent.extend as string | undefined, + name: agent.name != null ? String(agent.name) : INHERIT, + systemPromptPath: agent.system_prompt_path != null + ? resolve(agentFileDir, String(agent.system_prompt_path)) + : INHERIT, + systemPromptArgs: (agent.system_prompt_args as Record) ?? {}, + model: agent.model != null ? String(agent.model) : undefined, + whenToUse: agent.when_to_use != null ? String(agent.when_to_use) : undefined, + tools: agent.tools !== undefined ? (agent.tools as string[] | null) : INHERIT, + allowedTools: agent.allowed_tools !== undefined ? (agent.allowed_tools as string[] | null) : INHERIT, + excludeTools: agent.exclude_tools !== undefined ? (agent.exclude_tools as string[] | null) : INHERIT, + subagents: agent.subagents !== undefined + ? parseSubagents(agent.subagents as Record, agentFileDir) + : INHERIT, + }; + + return spec; +} + +function parseSubagents( + raw: Record | null, + baseDir: string, +): Record | null { + if (!raw) return null; + const result: Record = {}; + for (const [key, val] of Object.entries(raw)) { + const v = val as Record; + result[key] = { + path: resolve(baseDir, String(v.path)), + description: String(v.description ?? ""), + }; + } + return result; +} + +async function loadAgentSpecRaw(agentFile: string): Promise { + const file = Bun.file(agentFile); + if (!(await file.exists())) { + throw new AgentSpecError(`Agent spec file not found: ${agentFile}`); + } + + const text = await file.text(); + const data = parseYaml(text) as Record; + + const version = String(data.version ?? DEFAULT_AGENT_SPEC_VERSION); + if (!SUPPORTED_VERSIONS.has(version)) { + throw new AgentSpecError(`Unsupported agent spec version: ${version}`); + } + + const agentFileDir = dirname(agentFile); + const spec = parseAgentData(data, agentFileDir); + + // Handle inheritance + if (spec.extend) { + const baseFile = spec.extend === "default" + ? join(getAgentsDir(), "default", "agent.yaml") + : resolve(agentFileDir, spec.extend); + + const base = await loadAgentSpecRaw(baseFile); + + if (spec.name !== INHERIT) base.name = spec.name; + if (spec.systemPromptPath !== INHERIT) base.systemPromptPath = spec.systemPromptPath; + // Merge system prompt args + for (const [k, v] of Object.entries(spec.systemPromptArgs)) { + base.systemPromptArgs[k] = v; + } + if (spec.model != null) base.model = spec.model; + if (spec.whenToUse != null) base.whenToUse = spec.whenToUse; + if (spec.tools !== INHERIT) base.tools = spec.tools; + if (spec.allowedTools !== INHERIT) base.allowedTools = spec.allowedTools; + if (spec.excludeTools !== INHERIT) base.excludeTools = spec.excludeTools; + if (spec.subagents !== INHERIT) base.subagents = spec.subagents; + + base.extend = undefined; + return base; + } + + return spec; +} + +export async function loadAgentSpec(agentFile: string): Promise { + const spec = await loadAgentSpecRaw(agentFile); + + if (spec.name === INHERIT) throw new AgentSpecError("Agent name is required"); + if (spec.systemPromptPath === INHERIT) throw new AgentSpecError("System prompt path is required"); + if (spec.tools === INHERIT) throw new AgentSpecError("Tools are required"); + + return { + name: spec.name as string, + systemPromptPath: spec.systemPromptPath as string, + systemPromptArgs: spec.systemPromptArgs, + model: spec.model ?? null, + whenToUse: spec.whenToUse ?? "", + tools: (spec.tools as string[]) ?? [], + allowedTools: spec.allowedTools === INHERIT ? null : (spec.allowedTools as string[] | null), + excludeTools: spec.excludeTools === INHERIT ? [] : ((spec.excludeTools as string[]) ?? []), + subagents: spec.subagents === INHERIT ? {} : ((spec.subagents as Record) ?? {}), + }; +} diff --git a/src/kimi_cli_ts/app.ts b/src/kimi_cli_ts/app.ts new file mode 100644 index 000000000..277f24f2d --- /dev/null +++ b/src/kimi_cli_ts/app.ts @@ -0,0 +1,306 @@ +/** + * KimiCLI app orchestrator — corresponds to Python app.py + * Creates and wires together all components. + */ + +import { loadConfig, type Config, type ConfigMeta } from "./config.ts"; +import { createLLM, augmentProviderWithEnvVars, type LLM } from "./llm.ts"; +import { OAuthManager, loadTokens, commonHeaders } from "./auth/oauth.ts"; +import { Session } from "./session.ts"; +import { HookEngine } from "./hooks/engine.ts"; +import { Context } from "./soul/context.ts"; +import { Runtime, Agent, loadAgent } from "./soul/agent.ts"; +import { KimiSoul, type SoulCallbacks } from "./soul/kimisoul.ts"; +import { logger } from "./utils/logging.ts"; + +// ── KimiCLI ───────────────────────────────────────── + +export class KimiCLI { + readonly soul: KimiSoul; + readonly agent: Agent; + readonly session: Session; + readonly config: Config; + readonly configMeta: ConfigMeta; + readonly context: Context; + + private constructor(opts: { + soul: KimiSoul; + agent: Agent; + session: Session; + config: Config; + configMeta: ConfigMeta; + context: Context; + }) { + this.soul = opts.soul; + this.agent = opts.agent; + this.session = opts.session; + this.config = opts.config; + this.configMeta = opts.configMeta; + this.context = opts.context; + } + + // ── Factory ────────────────────────────────────── + + static async create(opts: { + workDir?: string; + additionalDirs?: string[]; + configFile?: string; + modelName?: string; + thinking?: boolean; + yolo?: boolean; + planMode?: boolean; + resumed?: boolean; + sessionId?: string; + continueSession?: boolean; + maxStepsPerTurn?: number; + callbacks?: SoulCallbacks; + }): Promise { + const workDir = opts.workDir ?? process.cwd(); + + // 1. Load config + const { config, meta: configMeta } = await loadConfig(opts.configFile); + + // Override settings from CLI flags + if (opts.maxStepsPerTurn) { + config.loop_control.max_steps_per_turn = opts.maxStepsPerTurn; + } + if (opts.yolo) { + config.default_yolo = true; + } + + // Determine plan mode (only apply default for new sessions, not restored) + let planMode = opts.planMode ?? false; + if (!opts.resumed) { + planMode = planMode || config.default_plan_mode; + } + + // 2. Determine model + const modelName = opts.modelName ?? config.default_model; + let llm: LLM | null = null; + + if (modelName && config.models[modelName]) { + const modelConfig = config.models[modelName]!; + const providerName = modelConfig.provider; + const providerConfig = config.providers[providerName]; + + if (providerConfig) { + // Resolve API key: if OAuth is configured, load the access token + let apiKey = providerConfig.api_key; + if (providerConfig.oauth) { + const token = await loadTokens(providerConfig.oauth); + if (token) { + apiKey = token.access_token; + } + } + + // Build platform identification headers (matches Python _kimi_default_headers) + const platformHeaders = await commonHeaders(); + const mergedCustomHeaders: Record = { + "User-Agent": `KimiCLI/2.0.0`, + ...platformHeaders, + ...(providerConfig.custom_headers ?? {}), + }; + + // Convert snake_case config to camelCase LLM interface + const llmProvider = { + type: providerConfig.type as any, + baseUrl: providerConfig.base_url, + apiKey, + customHeaders: mergedCustomHeaders, + env: providerConfig.env, + oauth: providerConfig.oauth?.key ?? null, + }; + const llmModel = { + model: modelConfig.model, + provider: modelConfig.provider, + maxContextSize: modelConfig.max_context_size, + capabilities: modelConfig.capabilities, + }; + + // Apply env var overrides + augmentProviderWithEnvVars(llmProvider, llmModel); + + llm = createLLM(llmProvider, llmModel, { + thinking: opts.thinking ?? config.default_thinking, + }); + } + } + + // Fallback: create LLM from environment variables directly + // Supports: KIMI_BASE_URL, KIMI_API_KEY, KIMI_MODEL_NAME + if (!llm) { + const envBaseUrl = process.env.KIMI_BASE_URL; + const envApiKey = process.env.KIMI_API_KEY; + const envModel = process.env.KIMI_MODEL_NAME; + + if (envBaseUrl && envApiKey && envModel) { + const envPlatformHeaders = await commonHeaders(); + const llmProvider = { + type: "kimi" as const, + baseUrl: envBaseUrl, + apiKey: envApiKey, + customHeaders: { + "User-Agent": `KimiCLI/2.0.0`, + ...envPlatformHeaders, + } as Record, + }; + const llmModel = { + model: envModel, + provider: "env", + maxContextSize: parseInt( + process.env.KIMI_MODEL_MAX_CONTEXT_SIZE ?? "131072", + 10, + ), + capabilities: undefined as any, + }; + + llm = createLLM(llmProvider, llmModel, { + thinking: opts.thinking ?? config.default_thinking, + }); + + if (llm) { + logger.info( + `LLM from env: ${envModel} @ ${envBaseUrl}`, + ); + } + } + } + + if (!llm) { + logger.warn( + `No LLM configured for model "${modelName}". ` + + "Set up a model in ~/.kimi/config.toml", + ); + } + + // 3. Create/restore session + let session: Session; + let resumed = opts.resumed ?? false; + if (opts.sessionId) { + const found = await Session.find(workDir, opts.sessionId); + if (found) { + session = found; + resumed = true; + } else { + session = await Session.create(workDir); + } + } else if (opts.continueSession) { + const continued = await Session.continue_(workDir); + if (continued) { + session = continued; + resumed = true; + logger.info(`Continuing session ${session.id}`); + } else { + session = await Session.create(workDir); + logger.info("No previous session found, starting new session"); + } + } else { + session = await Session.create(workDir); + } + + // Store additional dirs in session state + if (opts.additionalDirs && opts.additionalDirs.length > 0) { + session.state.additional_dirs = opts.additionalDirs.map((d) => + d.startsWith("/") ? d : `${workDir}/${d}`, + ); + } + + // 4. Create hook engine + const hookEngine = new HookEngine({ + hooks: config.hooks, + cwd: workDir, + }); + + // 5. Create runtime + const runtime = await Runtime.create({ + config, + llm, + session, + hookEngine, + }); + + // 6. Load agent + const agent = await loadAgent({ runtime }); + + // 7. Create/restore context + const context = new Context(session.contextFile); + await context.restore(); + + // 8. Write system prompt if new context; otherwise use restored prompt + if (!context.systemPrompt) { + await context.writeSystemPrompt(agent.systemPrompt); + } else { + // On session continuation, use the system prompt from the restored context + // to ensure consistency (the prompt may have changed between versions) + (agent as any).systemPrompt = context.systemPrompt; + } + + // 9. Create KimiSoul + const soul = new KimiSoul({ + agent, + context, + callbacks: opts.callbacks ?? {}, + }); + + // Wire slash commands + soul.wireSlashCommands(); + + // Wire tool context (plan mode, ask user, etc.) + soul.wireToolContext(); + + // Activate plan mode if requested (for new sessions or --plan flag) + if (planMode && !soul.planMode) { + await soul.setPlanModeFromManual(true); + } else if (planMode && soul.planMode) { + // Already in plan mode from restored session, trigger activation reminder + soul.schedulePlanActivationReminder(); + } + + return new KimiCLI({ + soul, + agent, + session, + config, + configMeta, + context, + }); + } + + // ── Run modes ──────────────────────────────────── + + /** + * Run in interactive shell mode (React Ink TUI). + */ + async runShell(initialCommand?: string): Promise { + // This will be called from cli/index.ts with Ink rendering + // The shell component will call soul.run() directly + if (initialCommand) { + await this.soul.run(initialCommand); + } + return true; // continue + } + + /** + * Run in print mode (non-interactive). + */ + async runPrint(input: string): Promise { + await this.soul.run(input); + } + + // ── Lifecycle ────────────────────────────────── + + async shutdown(): Promise { + this.soul.abort(); + await this.agent.toolset.cleanup(); + + // Clean up empty sessions (no real messages exchanged) + if (await this.session.isEmpty()) { + await this.session.delete(); + logger.debug("Deleted empty session"); + } else { + await this.session.saveState(); + } + + logger.info("KimiCLI shutdown complete"); + } +} diff --git a/src/kimi_cli_ts/approval_runtime/index.ts b/src/kimi_cli_ts/approval_runtime/index.ts new file mode 100644 index 000000000..971ce6eb1 --- /dev/null +++ b/src/kimi_cli_ts/approval_runtime/index.ts @@ -0,0 +1,325 @@ +/** + * Approval runtime — corresponds to Python approval_runtime/ + * Manages approval requests lifecycle: create, wait, resolve, cancel. + */ + +import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { logger } from "../utils/logging.ts"; +import type { RootWireHub } from "../wire/root_hub.ts"; +import type { + ApprovalRequest as WireApprovalRequest, + ApprovalResponse as WireApprovalResponse, +} from "../wire/types.ts"; + +// ── Types ─────────────────────────────────────────────── + +export type ApprovalResponseKind = "approve" | "approve_for_session" | "reject"; +export type ApprovalSourceKind = "foreground_turn" | "background_agent"; +export type ApprovalStatus = "pending" | "resolved" | "cancelled"; +export type ApprovalRuntimeEventKind = "request_created" | "request_resolved"; + +export interface ApprovalSource { + kind: ApprovalSourceKind; + id: string; + agentId?: string; + subagentType?: string; +} + +export interface ApprovalRequestRecord { + id: string; + toolCallId: string; + sender: string; + action: string; + description: string; + display: unknown[]; + source: ApprovalSource; + createdAt: number; + status: ApprovalStatus; + resolvedAt: number | null; + response: ApprovalResponseKind | null; + feedback: string; +} + +export interface ApprovalRuntimeEvent { + kind: ApprovalRuntimeEventKind; + request: ApprovalRequestRecord; +} + +// ── Errors ────────────────────────────────────────────── + +export class ApprovalCancelledError extends Error { + constructor(requestId: string) { + super(`Approval cancelled: ${requestId}`); + this.name = "ApprovalCancelledError"; + } +} + +// ── Approval Source Context (ContextVar equivalent) ───── + +const _approvalSourceStorage = new AsyncLocalStorage(); + +export function getCurrentApprovalSourceOrNull(): ApprovalSource | null { + return _approvalSourceStorage.getStore() ?? null; +} + +export function setCurrentApprovalSource(source: ApprovalSource): void { + // Note: AsyncLocalStorage manages context automatically via run(). + // For imperative set/reset, we store on the current context. + const store = _approvalSourceStorage.getStore(); + if (store !== undefined) { + // We're inside a run() context — callers should use runWithApprovalSource instead + logger.warn("setCurrentApprovalSource called inside existing context"); + } +} + +/** + * Run a callback with the given approval source set as the current context. + * Equivalent to Python's ContextVar set/reset pattern. + */ +export function runWithApprovalSource(source: ApprovalSource, fn: () => T): T { + return _approvalSourceStorage.run(source, fn); +} + +/** + * Run an async callback with the given approval source set as the current context. + */ +export async function runWithApprovalSourceAsync( + source: ApprovalSource, + fn: () => Promise, +): Promise { + return _approvalSourceStorage.run(source, fn); +} + +// ── Waiter (promise-based future) ─────────────────────── + +interface Waiter { + resolve: (value: [ApprovalResponseKind, string]) => void; + reject: (reason: Error) => void; + promise: Promise<[ApprovalResponseKind, string]>; +} + +function createWaiter(): Waiter { + let resolve!: Waiter["resolve"]; + let reject!: Waiter["reject"]; + const promise = new Promise<[ApprovalResponseKind, string]>((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject, promise }; +} + +// ── Runtime ───────────────────────────────────────────── + +export type EventSubscriber = (event: ApprovalRuntimeEvent) => void; + +export class ApprovalRuntime { + private requests = new Map(); + private waiters = new Map(); + private subscribers = new Map(); + private _rootWireHub: RootWireHub | null = null; + + /** Bind a root wire hub for broadcasting approval events to UI. */ + bindRootWireHub(rootWireHub: RootWireHub): void { + if (this._rootWireHub === rootWireHub) return; + this._rootWireHub = rootWireHub; + } + + createRequest(opts: { + requestId?: string; + toolCallId: string; + sender: string; + action: string; + description: string; + display?: unknown[]; + source: ApprovalSource; + }): ApprovalRequestRecord { + const request: ApprovalRequestRecord = { + id: opts.requestId ?? randomUUID(), + toolCallId: opts.toolCallId, + sender: opts.sender, + action: opts.action, + description: opts.description, + display: opts.display ?? [], + source: opts.source, + createdAt: Date.now() / 1000, + status: "pending", + resolvedAt: null, + response: null, + feedback: "", + }; + this.requests.set(request.id, request); + this.publishEvent({ kind: "request_created", request }); + this._publishWireRequest(request); + return request; + } + + async waitForResponse( + requestId: string, + timeout: number = 300_000, + ): Promise<[ApprovalResponseKind, string]> { + const request = this.requests.get(requestId); + if (!request) throw new Error(`Approval request not found: ${requestId}`); + + if (request.status === "cancelled") { + throw new ApprovalCancelledError(requestId); + } + if (request.status === "resolved" && request.response) { + return [request.response, request.feedback]; + } + + let waiter = this.waiters.get(requestId); + if (!waiter) { + waiter = createWaiter(); + this.waiters.set(requestId, waiter); + } + + // Race the waiter against a timeout to prevent hanging forever + const timeoutPromise = new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error("timeout")); + }, timeout); + // Don't hold the process open for this timer + if (typeof timer === "object" && "unref" in timer) { + timer.unref(); + } + }); + + try { + return await Promise.race([waiter.promise, timeoutPromise]); + } catch (err) { + if (err instanceof Error && err.message === "timeout") { + logger.warn( + `Approval request ${requestId} timed out after ${timeout}ms`, + ); + // Pop the waiter before cancelling so _cancelRequest won't + // reject a future that nobody is awaiting (which would trigger + // an "unhandled rejection" warning). + this.waiters.delete(requestId); + this._cancelRequest(requestId, "approval timed out"); + throw new ApprovalCancelledError(requestId); + } + throw err; + } + } + + resolve(requestId: string, response: ApprovalResponseKind, feedback = ""): boolean { + const request = this.requests.get(requestId); + if (!request || request.status !== "pending") return false; + + request.status = "resolved"; + request.response = response; + request.feedback = feedback; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.resolve([response, feedback]); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, response, feedback); + return true; + } + + /** Cancel a single pending request by ID. */ + private _cancelRequest(requestId: string, feedback = ""): void { + const request = this.requests.get(requestId); + if (!request || request.status !== "pending") return; + + request.status = "cancelled"; + request.response = "reject"; + request.feedback = feedback; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.reject(new ApprovalCancelledError(requestId)); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, "reject", feedback); + } + + cancelBySource(sourceKind: ApprovalSourceKind, sourceId: string): number { + let cancelled = 0; + for (const [requestId, request] of this.requests) { + if (request.status !== "pending") continue; + if (request.source.kind !== sourceKind || request.source.id !== sourceId) continue; + + request.status = "cancelled"; + request.response = "reject"; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.reject(new ApprovalCancelledError(requestId)); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, "reject"); + cancelled++; + } + return cancelled; + } + + listPending(): ApprovalRequestRecord[] { + return [...this.requests.values()] + .filter((r) => r.status === "pending") + .sort((a, b) => a.createdAt - b.createdAt); + } + + getRequest(requestId: string): ApprovalRequestRecord | undefined { + return this.requests.get(requestId); + } + + subscribe(callback: EventSubscriber): string { + const token = randomUUID(); + this.subscribers.set(token, callback); + return token; + } + + unsubscribe(token: string): void { + this.subscribers.delete(token); + } + + private publishEvent(event: ApprovalRuntimeEvent): void { + for (const cb of this.subscribers.values()) { + try { + cb(event); + } catch (err) { + logger.error("Approval runtime event subscriber failed", err); + } + } + } + + private _publishWireRequest(request: ApprovalRequestRecord): void { + if (!this._rootWireHub) return; + this._rootWireHub.publishNowait({ + id: request.id, + tool_call_id: request.toolCallId, + sender: request.sender, + action: request.action, + description: request.description, + display: request.display, + source_kind: request.source.kind, + source_id: request.source.id, + agent_id: request.source.agentId ?? null, + subagent_type: request.source.subagentType ?? null, + source_description: null, + } as unknown as WireApprovalRequest); + } + + private _publishWireResponse( + requestId: string, + response: ApprovalResponseKind, + feedback = "", + ): void { + if (!this._rootWireHub) return; + this._rootWireHub.publishNowait({ + request_id: requestId, + response, + feedback, + } as unknown as WireApprovalResponse); + } +} diff --git a/src/kimi_cli_ts/auth/index.ts b/src/kimi_cli_ts/auth/index.ts new file mode 100644 index 000000000..b59d75d78 --- /dev/null +++ b/src/kimi_cli_ts/auth/index.ts @@ -0,0 +1,5 @@ +/** + * Auth module — corresponds to Python auth/__init__.py + */ + +export { KIMI_CODE_PLATFORM_ID } from "./platforms.ts"; diff --git a/src/kimi_cli_ts/auth/oauth.ts b/src/kimi_cli_ts/auth/oauth.ts new file mode 100644 index 000000000..17405bfcd --- /dev/null +++ b/src/kimi_cli_ts/auth/oauth.ts @@ -0,0 +1,560 @@ +/** + * OAuth module — corresponds to Python auth/oauth.py + * Device-code OAuth flow, token storage & refresh for Kimi Code. + */ + +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { hostname, platform, arch, release } from "node:os"; +import { getShareDir, type Config, saveConfig } from "../config.ts"; +import type { OAuthRef } from "../config.ts"; +import { getVersion } from "../constant.ts"; +import { + KIMI_CODE_PLATFORM_ID, + getPlatformById, + listModels, + managedProviderKey, + managedModelKey, + deriveModelCapabilities, + type ModelInfo, +} from "./platforms.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Constants ─────────────────────────────────────────── + +const KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +export const KIMI_CODE_OAUTH_KEY = "oauth/kimi-code"; +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const KEYRING_SERVICE = "kimi-code"; +export const REFRESH_INTERVAL_SECONDS = 60; +export const REFRESH_THRESHOLD_SECONDS = 300; + +// ── Errors ────────────────────────────────────────────── + +export class OAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "OAuthError"; + } +} + +export class OAuthUnauthorized extends OAuthError { + constructor(message = "OAuth credentials rejected.") { + super(message); + this.name = "OAuthUnauthorized"; + } +} + +export class OAuthDeviceExpired extends OAuthError { + constructor(message = "Device authorization expired.") { + super(message); + this.name = "OAuthDeviceExpired"; + } +} + +// ── Event / Token types ───────────────────────────────── + +export type OAuthEventKind = "info" | "error" | "waiting" | "verification_url" | "success"; + +export interface OAuthEvent { + type: OAuthEventKind; + message: string; + data?: Record; +} + +export interface OAuthToken { + access_token: string; + refresh_token: string; + expires_at: number; + scope: string; + token_type: string; +} + +export interface DeviceAuthorization { + user_code: string; + device_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number | null; + interval: number; +} + +// ── Helpers ───────────────────────────────────────────── + +function oauthHost(): string { + return process.env.KIMI_CODE_OAUTH_HOST ?? process.env.KIMI_OAUTH_HOST ?? DEFAULT_OAUTH_HOST; +} + +function credentialsDir(): string { + return join(getShareDir(), "credentials"); +} + +function credentialsPath(key: string): string { + const name = key.replace(/^oauth\//, "").split("/").pop() ?? key; + return join(credentialsDir(), `${name}.json`); +} + +function deviceIdPath(): string { + return join(getShareDir(), "device_id"); +} + +export async function getDeviceId(): Promise { + const path = deviceIdPath(); + const file = Bun.file(path); + if (await file.exists()) { + return (await file.text()).trim(); + } + const deviceId = randomUUID().replace(/-/g, ""); + await Bun.$`mkdir -p ${getShareDir()}`.quiet(); + await Bun.write(path, deviceId); + return deviceId; +} + +function deviceModel(): string { + const sys = platform(); + const a = arch(); + if (sys === "darwin") return `macOS ${a}`; + if (sys === "win32") return `Windows ${a}`; + if (sys === "linux") return `Linux ${a}`; + return `${sys} ${a}`; +} + +export async function commonHeaders(): Promise> { + return { + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": getVersion(), + "X-Msh-Device-Name": hostname(), + "X-Msh-Device-Model": deviceModel(), + "X-Msh-Os-Version": release(), + "X-Msh-Device-Id": await getDeviceId(), + }; +} + +// ── Token persistence (file-based) ───────────────────── + +export async function loadTokens(ref: OAuthRef): Promise { + const path = credentialsPath(ref.key); + const file = Bun.file(path); + if (!(await file.exists())) return null; + try { + return (await file.json()) as OAuthToken; + } catch { + return null; + } +} + +export async function saveTokens(ref: OAuthRef, token: OAuthToken): Promise { + const path = credentialsPath(ref.key); + await Bun.$`mkdir -p ${credentialsDir()}`.quiet(); + await Bun.write(path, JSON.stringify(token)); + return { storage: "file", key: ref.key }; +} + +export async function deleteTokens(ref: OAuthRef): Promise { + const path = credentialsPath(ref.key); + const file = Bun.file(path); + if (await file.exists()) { + await Bun.$`rm -f ${path}`.quiet(); + } +} + +// ── Device authorization flow ─────────────────────────── + +export async function requestDeviceAuthorization(): Promise { + const host = oauthHost().replace(/\/+$/, ""); + const headers = await commonHeaders(); + const res = await fetch(`${host}/api/oauth/device_authorization`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: KIMI_CODE_CLIENT_ID }), + }); + const data = (await res.json()) as Record; + if (res.status !== 200) throw new OAuthError(`Device authorization failed: ${JSON.stringify(data)}`); + return { + user_code: String(data.user_code), + device_code: String(data.device_code), + verification_uri: String(data.verification_uri ?? ""), + verification_uri_complete: String(data.verification_uri_complete), + expires_in: data.expires_in ? Number(data.expires_in) : null, + interval: Number(data.interval ?? 5), + }; +} + +/** Poll the token endpoint once. Corresponds to Python _request_device_token. */ +async function requestDeviceToken(auth: DeviceAuthorization): Promise<{ status: number; data: Record }> { + const host = oauthHost().replace(/\/+$/, ""); + const headers = await commonHeaders(); + try { + const res = await fetch(`${host}/api/oauth/token`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: KIMI_CODE_CLIENT_ID, + device_code: auth.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + const data = (await res.json()) as Record; + if (res.status >= 500) throw new OAuthError(`Token polling server error: ${res.status}.`); + return { status: res.status, data }; + } catch (err) { + if (err instanceof OAuthError) throw err; + throw new OAuthError("Token polling request failed."); + } +} + +export async function refreshToken(refreshTokenValue: string): Promise { + const host = oauthHost().replace(/\/+$/, ""); + const headers = await commonHeaders(); + const res = await fetch(`${host}/api/oauth/token`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: KIMI_CODE_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshTokenValue, + }), + }); + const data = (await res.json()) as Record; + if (res.status === 401 || res.status === 403) { + throw new OAuthUnauthorized((data.error_description as string) ?? "Token refresh unauthorized."); + } + if (res.status !== 200) { + throw new OAuthError((data.error_description as string) ?? "Token refresh failed."); + } + return { + access_token: String(data.access_token), + refresh_token: String(data.refresh_token), + expires_at: Date.now() / 1000 + Number(data.expires_in), + scope: String(data.scope), + token_type: String(data.token_type), + }; +} + +// ── Kimi Code login/logout (async generator) ──────────── + +function selectDefaultModelAndThinking(models: ModelInfo[]): { model: ModelInfo; thinking: boolean } | null { + if (!models.length) return null; + const model = models[0]!; + const caps = deriveModelCapabilities(model); + const thinking = caps.has("thinking") || caps.has("always_thinking"); + return { model, thinking }; +} + +function applyKimiCodeConfig( + config: Config, + opts: { + models: ModelInfo[]; + selectedModel: ModelInfo; + thinking: boolean; + oauthRef: OAuthRef; + }, +): void { + const plat = getPlatformById(KIMI_CODE_PLATFORM_ID); + if (!plat) throw new OAuthError("Kimi Code platform not found."); + + const providerKey = managedProviderKey(plat.id); + config.providers[providerKey] = { + type: "kimi", + base_url: plat.baseUrl, + api_key: "", + oauth: opts.oauthRef, + }; + + // Remove old models for this provider + for (const [key, model] of Object.entries(config.models)) { + if (model.provider === providerKey) delete config.models[key]; + } + + // Add fresh models + for (const modelInfo of opts.models) { + const caps = deriveModelCapabilities(modelInfo); + config.models[managedModelKey(plat.id, modelInfo.id)] = { + provider: providerKey, + model: modelInfo.id, + max_context_size: modelInfo.contextLength, + capabilities: caps.size > 0 ? ([...caps] as any) : undefined, + }; + } + + config.default_model = managedModelKey(plat.id, opts.selectedModel.id); + config.default_thinking = opts.thinking; + + if (plat.searchUrl) { + config.services = config.services ?? {}; + (config.services as any).moonshot_search = { + base_url: plat.searchUrl, + api_key: "", + oauth: opts.oauthRef, + }; + } + if (plat.fetchUrl) { + config.services = config.services ?? {}; + (config.services as any).moonshot_fetch = { + base_url: plat.fetchUrl, + api_key: "", + oauth: opts.oauthRef, + }; + } +} + +/** + * Run the Kimi Code OAuth device-code login flow. + * Yields OAuthEvent objects for UI display. + * Corresponds to Python login_kimi_code(). + */ +export async function* loginKimiCode( + config: Config, + opts: { openBrowser?: boolean } = {}, +): AsyncGenerator { + const plat = getPlatformById(KIMI_CODE_PLATFORM_ID); + if (!plat) { + yield { type: "error", message: "Kimi Code platform is unavailable." }; + return; + } + + let token: OAuthToken | null = null; + + // Retry loop — device codes can expire + while (true) { + let auth: DeviceAuthorization; + try { + auth = await requestDeviceAuthorization(); + } catch (err) { + yield { type: "error", message: `Login failed: ${err}` }; + return; + } + + yield { type: "info", message: "Please visit the following URL to finish authorization." }; + yield { + type: "verification_url", + message: `Verification URL: ${auth.verification_uri_complete}`, + data: { verification_url: auth.verification_uri_complete, user_code: auth.user_code }, + }; + + if (opts.openBrowser !== false) { + try { + // Use Bun.spawn to open URL in default browser + const proc = Bun.spawn( + process.platform === "darwin" + ? ["open", auth.verification_uri_complete] + : process.platform === "win32" + ? ["cmd", "/c", "start", auth.verification_uri_complete] + : ["xdg-open", auth.verification_uri_complete], + { stdout: "ignore", stderr: "ignore" }, + ); + await proc.exited; + } catch { + // Ignore browser open failures + } + } + + let interval = Math.max(auth.interval, 1); + let printedWait = false; + + try { + while (true) { + const { status, data } = await requestDeviceToken(auth); + if (status === 200 && data.access_token) { + token = { + access_token: String(data.access_token), + refresh_token: String(data.refresh_token), + expires_at: Date.now() / 1000 + Number(data.expires_in), + scope: String(data.scope ?? ""), + token_type: String(data.token_type ?? "bearer"), + }; + break; + } + const errorCode = String(data.error ?? "unknown_error"); + if (errorCode === "expired_token") throw new OAuthDeviceExpired(); + if (!printedWait) { + const desc = String(data.error_description ?? ""); + yield { + type: "waiting", + message: `Waiting for user authorization...${desc ? ": " + desc.trim() : ""}`, + data: { error: errorCode, error_description: desc }, + }; + printedWait = true; + } + await new Promise((r) => setTimeout(r, interval * 1000)); + } + } catch (err) { + if (err instanceof OAuthDeviceExpired) { + yield { type: "info", message: "Device code expired, restarting login..." }; + continue; // Retry outer loop + } + yield { type: "error", message: `Login failed: ${err}` }; + return; + } + break; // Got token, exit retry loop + } + + if (!token) return; + + // Save token + const oauthRef: OAuthRef = { storage: "file", key: KIMI_CODE_OAUTH_KEY }; + await saveTokens(oauthRef, token); + + // Fetch models + let models: ModelInfo[]; + try { + models = await listModels(plat, token.access_token); + } catch (err) { + logger.error(`Failed to get models: ${err}`); + yield { type: "error", message: `Failed to get models: ${err}` }; + return; + } + + if (!models.length) { + yield { type: "error", message: "No models available for the selected platform." }; + return; + } + + const selection = selectDefaultModelAndThinking(models); + if (!selection) return; + + applyKimiCodeConfig(config, { + models, + selectedModel: selection.model, + thinking: selection.thinking, + oauthRef, + }); + await saveConfig(config); + yield { type: "success", message: "Logged in successfully." }; +} + +/** + * Logout from Kimi Code — delete tokens and clean up config. + * Corresponds to Python logout_kimi_code(). + */ +export async function* logoutKimiCode(config: Config): AsyncGenerator { + // Delete stored tokens (both keyring and file) + await deleteTokens({ storage: "keyring", key: KIMI_CODE_OAUTH_KEY }); + await deleteTokens({ storage: "file", key: KIMI_CODE_OAUTH_KEY }); + + const providerKey = managedProviderKey(KIMI_CODE_PLATFORM_ID); + if (config.providers[providerKey]) { + delete config.providers[providerKey]; + } + + let removedDefault = false; + for (const [key, model] of Object.entries(config.models)) { + if (model.provider !== providerKey) continue; + delete config.models[key]; + if (config.default_model === key) removedDefault = true; + } + if (removedDefault) config.default_model = ""; + + if (config.services) { + (config.services as any).moonshot_search = undefined; + (config.services as any).moonshot_fetch = undefined; + } + + await saveConfig(config); + yield { type: "success", message: "Logged out successfully." }; +} + +// ── OAuthManager ──────────────────────────────────────── + +export class OAuthManager { + private config: { providers: Record }; + private accessTokens = new Map(); + + constructor(config: { providers: Record }) { + this.config = config; + } + + async initialize(): Promise { + for (const provider of Object.values(this.config.providers)) { + if (provider.oauth) { + const token = await loadTokens(provider.oauth); + if (token) this.accessTokens.set(provider.oauth.key, token.access_token); + } + } + } + + async resolveApiKey(apiKey: string, oauth?: OAuthRef): Promise { + if (oauth) { + const cached = this.accessTokens.get(oauth.key); + if (cached) return cached; + const persisted = await loadTokens(oauth); + if (persisted) { + this.accessTokens.set(oauth.key, persisted.access_token); + return persisted.access_token; + } + logger.warn(`OAuth ref present (key=${oauth.key}) but no access token; falling back to api_key`); + } + return apiKey; + } + + async ensureFresh(): Promise { + for (const provider of Object.values(this.config.providers)) { + if (!provider.oauth) continue; + const token = await loadTokens(provider.oauth); + if (!token || !token.refresh_token) continue; + + this.accessTokens.set(provider.oauth.key, token.access_token); + + const now = Date.now() / 1000; + if (token.expires_at && token.expires_at > now && token.expires_at - now >= REFRESH_THRESHOLD_SECONDS) { + continue; + } + try { + const refreshed = await refreshToken(token.refresh_token); + await saveTokens(provider.oauth, refreshed); + this.accessTokens.set(provider.oauth.key, refreshed.access_token); + } catch (err) { + if (err instanceof OAuthUnauthorized) { + this.accessTokens.delete(provider.oauth.key); + await deleteTokens(provider.oauth); + } else { + logger.warn("Failed to refresh OAuth token", err); + } + } + } + } + + /** + * Background refresh loop — corresponds to Python OAuthManager.refreshing(). + * Periodically calls ensureFresh() until the returned abort function is called. + * Returns an AbortController; call abort() to stop the background loop. + */ + refreshing(): AbortController { + const controller = new AbortController(); + const signal = controller.signal; + + const run = async () => { + // Initial ensure fresh + try { + await this.ensureFresh(); + } catch (err) { + logger.warn(`Failed initial OAuth token refresh: ${err}`); + } + + while (!signal.aborted) { + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, REFRESH_INTERVAL_SECONDS * 1000); + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted")); + }, { once: true }); + }); + } catch { + break; // Aborted + } + + try { + await this.ensureFresh(); + } catch (err) { + logger.warn(`Failed to refresh OAuth token in background: ${err}`); + } + } + }; + + // Fire-and-forget background loop + run().catch(() => {}); + + return controller; + } +} diff --git a/src/kimi_cli_ts/auth/platforms.ts b/src/kimi_cli_ts/auth/platforms.ts new file mode 100644 index 000000000..8e00e9f4a --- /dev/null +++ b/src/kimi_cli_ts/auth/platforms.ts @@ -0,0 +1,230 @@ +/** + * Platform definitions and model management. + * Corresponds to Python's auth/platforms.py. + */ + +import type { Config, OAuthRef } from "../config.ts"; +import type { ModelCapability } from "../types.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Constants ──────────────────────────────────────────── + +export const KIMI_CODE_PLATFORM_ID = "kimi-code"; +export const MANAGED_PROVIDER_PREFIX = "managed:"; + +// ── Types ──────────────────────────────────────────────── + +export interface ModelInfo { + id: string; + contextLength: number; + supportsReasoning: boolean; + supportsImageIn: boolean; + supportsVideoIn: boolean; +} + +export function deriveModelCapabilities(model: ModelInfo): Set { + const caps = new Set(); + if (model.supportsReasoning) caps.add("thinking"); + if (model.id.toLowerCase().includes("thinking")) { + caps.add("thinking"); + caps.add("always_thinking"); + } + if (model.supportsImageIn) caps.add("image_in"); + if (model.supportsVideoIn) caps.add("video_in"); + if (model.id.toLowerCase().includes("kimi-k2.5")) { + caps.add("thinking"); + caps.add("image_in"); + caps.add("video_in"); + } + return caps; +} + +export interface Platform { + id: string; + name: string; + baseUrl: string; + searchUrl?: string; + fetchUrl?: string; + allowedPrefixes?: string[]; +} + +// ── Platform registry ──────────────────────────────────── + +function kimiCodeBaseUrl(): string { + return process.env.KIMI_CODE_BASE_URL ?? "https://api.kimi.com/coding/v1"; +} + +export const PLATFORMS: Platform[] = [ + { + id: KIMI_CODE_PLATFORM_ID, + name: "Kimi Code", + baseUrl: kimiCodeBaseUrl(), + searchUrl: `${kimiCodeBaseUrl()}/search`, + fetchUrl: `${kimiCodeBaseUrl()}/fetch`, + }, + { + id: "moonshot-cn", + name: "Moonshot AI Open Platform (moonshot.cn)", + baseUrl: "https://api.moonshot.cn/v1", + allowedPrefixes: ["kimi-k"], + }, + { + id: "moonshot-ai", + name: "Moonshot AI Open Platform (moonshot.ai)", + baseUrl: "https://api.moonshot.ai/v1", + allowedPrefixes: ["kimi-k"], + }, +]; + +const _platformById = new Map(PLATFORMS.map((p) => [p.id, p])); +const _platformByName = new Map(PLATFORMS.map((p) => [p.name, p])); + +export function getPlatformById(platformId: string): Platform | undefined { + return _platformById.get(platformId); +} + +export function getPlatformByName(name: string): Platform | undefined { + return _platformByName.get(name); +} + +// ── Key helpers ────────────────────────────────────────── + +export function managedProviderKey(platformId: string): string { + return `${MANAGED_PROVIDER_PREFIX}${platformId}`; +} + +export function managedModelKey(platformId: string, modelId: string): string { + return `${platformId}/${modelId}`; +} + +export function parseManagedProviderKey(providerKey: string): string | null { + if (!providerKey.startsWith(MANAGED_PROVIDER_PREFIX)) return null; + return providerKey.slice(MANAGED_PROVIDER_PREFIX.length); +} + +export function isManagedProviderKey(providerKey: string): boolean { + return providerKey.startsWith(MANAGED_PROVIDER_PREFIX); +} + +export function getPlatformNameForProvider(providerKey: string): string | null { + const platformId = parseManagedProviderKey(providerKey); + if (!platformId) return null; + const platform = getPlatformById(platformId); + return platform?.name ?? null; +} + +// ── Model listing ──────────────────────────────────────── + +export async function listModels(platform: Platform, apiKey: string): Promise { + const modelsUrl = `${platform.baseUrl.replace(/\/+$/, "")}/models`; + const res = await fetch(modelsUrl, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!res.ok) { + throw new Error(`Failed to list models (HTTP ${res.status})`); + } + const json = (await res.json()) as { data?: unknown[] }; + const data = json.data; + if (!Array.isArray(data)) { + throw new Error(`Unexpected models response for ${platform.baseUrl}`); + } + + const models: ModelInfo[] = []; + for (const item of data as Record[]) { + const modelId = item.id; + if (!modelId) continue; + models.push({ + id: String(modelId), + contextLength: Number(item.context_length ?? 0), + supportsReasoning: Boolean(item.supports_reasoning), + supportsImageIn: Boolean(item.supports_image_in), + supportsVideoIn: Boolean(item.supports_video_in), + }); + } + + if (platform.allowedPrefixes) { + const prefixes = platform.allowedPrefixes; + return models.filter((m) => prefixes.some((p) => m.id.startsWith(p))); + } + return models; +} + +// ── Refresh managed models ─────────────────────────────── + +export async function refreshManagedModels(config: Config): Promise { + // Lazy import to avoid circular dependency (oauth.ts imports from platforms.ts) + const { loadTokens } = await import("./oauth.ts"); + + let changed = false; + for (const [providerKey, provider] of Object.entries(config.providers)) { + const platformId = parseManagedProviderKey(providerKey); + if (!platformId) continue; + const platform = getPlatformById(platformId); + if (!platform) continue; + + let apiKey = provider.api_key; + if (!apiKey && provider.oauth) { + const token = await loadTokens(provider.oauth); + if (token) apiKey = token.access_token; + } + if (!apiKey) continue; + + try { + const models = await listModels(platform, apiKey); + if (applyModels(config, providerKey, platformId, models)) { + changed = true; + } + } catch (err) { + logger.error(`Failed to refresh models for ${platformId}: ${err}`); + } + } + return changed; +} + +function applyModels( + config: Config, + providerKey: string, + platformId: string, + models: ModelInfo[], +): boolean { + let changed = false; + const modelKeys = new Set(); + + for (const model of models) { + const modelKey = managedModelKey(platformId, model.id); + modelKeys.add(modelKey); + + const existing = config.models[modelKey]; + const capabilities = deriveModelCapabilities(model); + const capsArray = capabilities.size > 0 ? [...capabilities] as ModelCapability[] : undefined; + + if (!existing) { + config.models[modelKey] = { + provider: providerKey, + model: model.id, + max_context_size: model.contextLength, + capabilities: capsArray, + }; + changed = true; + continue; + } + + if (existing.provider !== providerKey) { + existing.provider = providerKey; + changed = true; + } + } + + // Remove stale models + for (const [key, model] of Object.entries(config.models)) { + if (model.provider !== providerKey) continue; + if (modelKeys.has(key)) continue; + delete config.models[key]; + if (config.default_model === key) { + config.default_model = ""; + } + changed = true; + } + + return changed; +} diff --git a/src/kimi_cli_ts/background/ids.ts b/src/kimi_cli_ts/background/ids.ts new file mode 100644 index 000000000..185518556 --- /dev/null +++ b/src/kimi_cli_ts/background/ids.ts @@ -0,0 +1,21 @@ +/** + * Background task ID generation — corresponds to Python background/ids.py + */ + +import type { TaskKind } from "./models.ts"; + +const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; + +const TASK_ID_PREFIXES: Record = { + bash: "bash", + agent: "agent", +}; + +export function generateTaskId(kind: TaskKind): string { + const prefix = TASK_ID_PREFIXES[kind]; + let suffix = ""; + for (let i = 0; i < 8; i++) { + suffix += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + } + return `${prefix}-${suffix}`; +} diff --git a/src/kimi_cli_ts/background/manager.ts b/src/kimi_cli_ts/background/manager.ts new file mode 100644 index 000000000..70f144bf1 --- /dev/null +++ b/src/kimi_cli_ts/background/manager.ts @@ -0,0 +1,367 @@ +/** + * Background task manager — corresponds to Python background/manager.py + * Manages task lifecycle: create, list, stop, recover. + */ + +import { join } from "node:path"; +import { logger } from "../utils/logging.ts"; +import type { BackgroundConfig } from "../config.ts"; +import type { Session } from "../session.ts"; +import { generateTaskId } from "./ids.ts"; +import { + type TaskSpec, + type TaskRuntime, + type TaskView, + type TaskOutputChunk, + type TaskStatus, + isTerminalStatus, +} from "./models.ts"; +import { BackgroundTaskStore } from "./store.ts"; + +export class BackgroundTaskManager { + private _session: Session; + private _config: BackgroundConfig; + private _ownerRole: string; + private _store: BackgroundTaskStore; + + constructor( + session: Session, + config: BackgroundConfig, + opts?: { ownerRole?: string }, + ) { + this._session = session; + this._config = config; + this._ownerRole = opts?.ownerRole ?? "root"; + // Store tasks dir next to context file + const tasksDir = join(session.dir, "tasks"); + this._store = new BackgroundTaskStore(tasksDir); + } + + get store(): BackgroundTaskStore { + return this._store; + } + + get role(): string { + return this._ownerRole; + } + + private ensureRoot(): void { + if (this._ownerRole !== "root") { + throw new Error("Background tasks are only supported from the root agent."); + } + } + + private activeTaskCount(): number { + return this._store.listViews().filter((v) => !isTerminalStatus(v.runtime.status)).length; + } + + createBashTask(opts: { + command: string; + description: string; + timeoutS: number; + toolCallId: string; + shellName: string; + shellPath: string; + cwd: string; + }): TaskView { + this.ensureRoot(); + + if (this.activeTaskCount() >= this._config.max_running_tasks) { + throw new Error("Too many background tasks are already running."); + } + + const taskId = generateTaskId("bash"); + const now = Date.now() / 1000; + const spec: TaskSpec = { + version: 1, + id: taskId, + kind: "bash", + sessionId: this._session.id, + description: opts.description, + toolCallId: opts.toolCallId, + ownerRole: "root", + createdAt: now, + command: opts.command, + shellName: opts.shellName, + shellPath: opts.shellPath, + cwd: opts.cwd, + timeoutS: opts.timeoutS, + }; + this._store.createTask(spec); + + // Launch worker subprocess + const taskDir = this._store.taskDir(taskId); + let runtime = this._store.readRuntime(taskId); + try { + const workerPid = this.launchWorker(taskDir); + runtime = this._store.readRuntime(taskId); + if ( + runtime.finishedAt == null && + (runtime.status === "created" || + (runtime.status === "starting" && runtime.workerPid == null)) + ) { + runtime.status = "starting"; + runtime.workerPid = workerPid; + runtime.updatedAt = Date.now() / 1000; + this._store.writeRuntime(taskId, runtime); + } + } catch (err) { + runtime.status = "failed"; + runtime.failureReason = `Failed to launch worker: ${err}`; + runtime.finishedAt = Date.now() / 1000; + runtime.updatedAt = runtime.finishedAt; + this._store.writeRuntime(taskId, runtime); + throw err; + } + + return this._store.mergedView(taskId); + } + + private launchWorker(taskDir: string): number { + const proc = Bun.spawn( + [process.execPath, "--run", "background-worker", "--task-dir", taskDir], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + cwd: taskDir, + }, + ); + return proc.pid; + } + + createAgentTask(opts: { + agentId: string; + subagentType: string; + prompt: string; + description: string; + toolCallId: string; + modelOverride?: string; + timeoutS?: number; + resumed?: boolean; + }): TaskView { + this.ensureRoot(); + + if (this.activeTaskCount() >= this._config.max_running_tasks) { + throw new Error("Too many background tasks are already running."); + } + + const taskId = generateTaskId("agent"); + const now = Date.now() / 1000; + const spec: TaskSpec = { + version: 1, + id: taskId, + kind: "agent", + sessionId: this._session.id, + description: opts.description, + toolCallId: opts.toolCallId, + ownerRole: "root", + createdAt: now, + kindPayload: { + agent_id: opts.agentId, + subagent_type: opts.subagentType, + prompt: opts.prompt, + model_override: opts.modelOverride, + launch_mode: "background", + }, + }; + this._store.createTask(spec); + + const runtime = this._store.readRuntime(taskId); + runtime.status = "starting"; + runtime.updatedAt = Date.now() / 1000; + this._store.writeRuntime(taskId, runtime); + + return this._store.mergedView(taskId); + } + + listTasks(opts?: { status?: TaskStatus; limit?: number }): TaskView[] { + let tasks = this._store.listViews(); + if (opts?.status != null) { + tasks = tasks.filter((t) => t.runtime.status === opts.status); + } + const limit = opts?.limit ?? 20; + return tasks.slice(0, limit); + } + + getTask(taskId: string): TaskView | undefined { + try { + return this._store.mergedView(taskId); + } catch { + return undefined; + } + } + + readOutput(taskId: string, opts?: { offset?: number; maxBytes?: number }): TaskOutputChunk { + const view = this._store.mergedView(taskId); + return this._store.readOutput( + taskId, + opts?.offset ?? 0, + opts?.maxBytes ?? this._config.read_max_bytes, + view.runtime.status, + ); + } + + tailOutput(taskId: string, opts?: { maxBytes?: number; maxLines?: number }): string { + this._store.mergedView(taskId); // validate existence + return this._store.tailOutput( + taskId, + opts?.maxBytes ?? this._config.read_max_bytes, + opts?.maxLines ?? this._config.notification_tail_lines, + ); + } + + async wait(taskId: string, timeoutS = 30): Promise { + const endTime = performance.now() + timeoutS * 1000; + while (true) { + const view = this._store.mergedView(taskId); + if (isTerminalStatus(view.runtime.status)) return view; + if (performance.now() >= endTime) return view; + await Bun.sleep(this._config.wait_poll_interval_ms); + } + } + + kill(taskId: string, reason = "Killed by user"): TaskView { + this.ensureRoot(); + const view = this._store.mergedView(taskId); + if (isTerminalStatus(view.runtime.status)) return view; + + if (view.spec.kind === "agent") { + this.markTaskKilled(taskId, reason); + return this._store.mergedView(taskId); + } + + // Bash: write control file, best-effort signal + const control = { ...view.control }; + control.killRequestedAt = Date.now() / 1000; + control.killReason = reason; + control.force = false; + this._store.writeControl(taskId, control); + this.bestEffortKill(view.runtime); + return this._store.mergedView(taskId); + } + + killAllActive(reason = "CLI session ended"): string[] { + const killed: string[] = []; + for (const view of this._store.listViews()) { + if (isTerminalStatus(view.runtime.status)) continue; + try { + this.kill(view.spec.id, reason); + killed.push(view.spec.id); + } catch { + logger.error(`Failed to kill task ${view.spec.id} during shutdown`); + } + } + return killed; + } + + recover(): void { + const now = Date.now() / 1000; + const staleAfter = this._config.worker_stale_after_ms / 1000; + + for (const view of this._store.listViews()) { + if (isTerminalStatus(view.runtime.status)) continue; + + if (view.spec.kind === "agent") { + // Agent tasks without live runner are lost + const runtime = { ...view.runtime }; + runtime.finishedAt = now; + runtime.updatedAt = now; + runtime.status = "lost"; + runtime.failureReason = "In-process background agent is no longer running"; + this._store.writeRuntime(view.spec.id, runtime); + continue; + } + + const lastProgressAt = + view.runtime.heartbeatAt ?? + view.runtime.startedAt ?? + view.runtime.updatedAt ?? + view.spec.createdAt; + if (now - lastProgressAt <= staleAfter) continue; + + // Re-read to narrow race window + const freshRuntime = this._store.readRuntime(view.spec.id); + if (isTerminalStatus(freshRuntime.status)) continue; + const freshProgress = + freshRuntime.heartbeatAt ?? + freshRuntime.startedAt ?? + freshRuntime.updatedAt ?? + view.spec.createdAt; + if (now - freshProgress <= staleAfter) continue; + + const runtime = { ...freshRuntime }; + runtime.finishedAt = now; + runtime.updatedAt = now; + if (view.control.killRequestedAt != null) { + runtime.status = "killed"; + runtime.interrupted = true; + runtime.failureReason = view.control.killReason ?? "Killed during recovery"; + } else { + runtime.status = "lost"; + runtime.failureReason = + freshRuntime.heartbeatAt == null + ? "Background worker never heartbeat after startup" + : "Background worker heartbeat expired"; + } + this._store.writeRuntime(view.spec.id, runtime); + } + } + + reconcile(): void { + this.recover(); + } + + // ── Internal status helpers ── + + markTaskRunning(taskId: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "running"; + runtime.updatedAt = Date.now() / 1000; + runtime.heartbeatAt = runtime.updatedAt; + runtime.failureReason = undefined; + this._store.writeRuntime(taskId, runtime); + } + + markTaskCompleted(taskId: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "completed"; + runtime.updatedAt = Date.now() / 1000; + runtime.finishedAt = runtime.updatedAt; + runtime.failureReason = undefined; + this._store.writeRuntime(taskId, runtime); + } + + markTaskFailed(taskId: string, reason: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "failed"; + runtime.updatedAt = Date.now() / 1000; + runtime.finishedAt = runtime.updatedAt; + runtime.failureReason = reason; + this._store.writeRuntime(taskId, runtime); + } + + markTaskKilled(taskId: string, reason: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "killed"; + runtime.updatedAt = Date.now() / 1000; + runtime.finishedAt = runtime.updatedAt; + runtime.interrupted = true; + runtime.failureReason = reason; + this._store.writeRuntime(taskId, runtime); + } + + private bestEffortKill(runtime: TaskRuntime): void { + try { + const pid = runtime.childPgid ?? runtime.childPid ?? runtime.workerPid; + if (pid == null) return; + process.kill(pid, "SIGTERM"); + } catch { + // Process may already be gone + } + } +} diff --git a/src/kimi_cli_ts/background/models.ts b/src/kimi_cli_ts/background/models.ts new file mode 100644 index 000000000..5c9a4744b --- /dev/null +++ b/src/kimi_cli_ts/background/models.ts @@ -0,0 +1,212 @@ +/** + * Background task models — corresponds to Python background/models.py + */ + +export type TaskKind = "bash" | "agent"; +export type TaskStatus = + | "created" + | "starting" + | "running" + | "awaiting_approval" + | "completed" + | "failed" + | "killed" + | "lost"; +export type TaskOwnerRole = "root" | "subagent"; + +export const TERMINAL_TASK_STATUSES: readonly TaskStatus[] = [ + "completed", + "failed", + "killed", + "lost", +] as const; + +export function isTerminalStatus(status: TaskStatus): boolean { + return (TERMINAL_TASK_STATUSES as readonly string[]).includes(status); +} + +export interface TaskSpec { + version: number; + id: string; + kind: TaskKind; + sessionId: string; + description: string; + toolCallId: string; + ownerRole: TaskOwnerRole; + createdAt: number; + // Bash-specific + command?: string; + shellName?: string; + shellPath?: string; + cwd?: string; + timeoutS?: number; + // Generic payload for other task types + kindPayload?: Record; +} + +export interface TaskRuntime { + status: TaskStatus; + workerPid?: number; + childPid?: number; + childPgid?: number; + startedAt?: number; + heartbeatAt?: number; + updatedAt: number; + finishedAt?: number; + exitCode?: number; + interrupted: boolean; + timedOut: boolean; + failureReason?: string; +} + +export interface TaskControl { + killRequestedAt?: number; + killReason?: string; + force: boolean; +} + +export interface TaskConsumerState { + lastSeenOutputSize: number; + lastViewedAt?: number; +} + +export interface TaskView { + spec: TaskSpec; + runtime: TaskRuntime; + control: TaskControl; + consumer: TaskConsumerState; +} + +export interface TaskOutputChunk { + taskId: string; + offset: number; + nextOffset: number; + text: string; + eof: boolean; + status: TaskStatus; +} + +// ── JSON serialization helpers (snake_case ↔ camelCase) ── + +export function taskSpecToJson(spec: TaskSpec): Record { + return { + version: spec.version, + id: spec.id, + kind: spec.kind, + session_id: spec.sessionId, + description: spec.description, + tool_call_id: spec.toolCallId, + owner_role: spec.ownerRole, + created_at: spec.createdAt, + command: spec.command, + shell_name: spec.shellName, + shell_path: spec.shellPath, + cwd: spec.cwd, + timeout_s: spec.timeoutS, + kind_payload: spec.kindPayload, + }; +} + +export function taskSpecFromJson(data: Record): TaskSpec { + let ownerRole = String(data.owner_role ?? "root"); + if (ownerRole === "fixed_subagent" || ownerRole === "dynamic_subagent") { + ownerRole = "subagent"; + } + return { + version: Number(data.version ?? 1), + id: String(data.id), + kind: String(data.kind) as TaskKind, + sessionId: String(data.session_id), + description: String(data.description ?? ""), + toolCallId: String(data.tool_call_id ?? ""), + ownerRole: ownerRole as TaskOwnerRole, + createdAt: Number(data.created_at ?? Date.now() / 1000), + command: data.command != null ? String(data.command) : undefined, + shellName: data.shell_name != null ? String(data.shell_name) : undefined, + shellPath: data.shell_path != null ? String(data.shell_path) : undefined, + cwd: data.cwd != null ? String(data.cwd) : undefined, + timeoutS: data.timeout_s != null ? Number(data.timeout_s) : undefined, + kindPayload: data.kind_payload as Record | undefined, + }; +} + +export function taskRuntimeToJson(rt: TaskRuntime): Record { + return { + status: rt.status, + worker_pid: rt.workerPid, + child_pid: rt.childPid, + child_pgid: rt.childPgid, + started_at: rt.startedAt, + heartbeat_at: rt.heartbeatAt, + updated_at: rt.updatedAt, + finished_at: rt.finishedAt, + exit_code: rt.exitCode, + interrupted: rt.interrupted, + timed_out: rt.timedOut, + failure_reason: rt.failureReason, + }; +} + +export function taskRuntimeFromJson(data: Record): TaskRuntime { + return { + status: (String(data.status ?? "created")) as TaskStatus, + workerPid: data.worker_pid != null ? Number(data.worker_pid) : undefined, + childPid: data.child_pid != null ? Number(data.child_pid) : undefined, + childPgid: data.child_pgid != null ? Number(data.child_pgid) : undefined, + startedAt: data.started_at != null ? Number(data.started_at) : undefined, + heartbeatAt: data.heartbeat_at != null ? Number(data.heartbeat_at) : undefined, + updatedAt: Number(data.updated_at ?? Date.now() / 1000), + finishedAt: data.finished_at != null ? Number(data.finished_at) : undefined, + exitCode: data.exit_code != null ? Number(data.exit_code) : undefined, + interrupted: Boolean(data.interrupted ?? false), + timedOut: Boolean(data.timed_out ?? false), + failureReason: data.failure_reason != null ? String(data.failure_reason) : undefined, + }; +} + +export function taskControlToJson(ctrl: TaskControl): Record { + return { + kill_requested_at: ctrl.killRequestedAt, + kill_reason: ctrl.killReason, + force: ctrl.force, + }; +} + +export function taskControlFromJson(data: Record): TaskControl { + return { + killRequestedAt: data.kill_requested_at != null ? Number(data.kill_requested_at) : undefined, + killReason: data.kill_reason != null ? String(data.kill_reason) : undefined, + force: Boolean(data.force ?? false), + }; +} + +export function taskConsumerToJson(cs: TaskConsumerState): Record { + return { + last_seen_output_size: cs.lastSeenOutputSize, + last_viewed_at: cs.lastViewedAt, + }; +} + +export function taskConsumerFromJson(data: Record): TaskConsumerState { + return { + lastSeenOutputSize: Number(data.last_seen_output_size ?? 0), + lastViewedAt: data.last_viewed_at != null ? Number(data.last_viewed_at) : undefined, + }; +} + +export function newTaskRuntime(): TaskRuntime { + return { + status: "created", + updatedAt: Date.now() / 1000, + interrupted: false, + timedOut: false, + }; +} + +export function newTaskControl(): TaskControl { + return { force: false }; +} + +export function newTaskConsumerState(): TaskConsumerState { + return { lastSeenOutputSize: 0 }; +} diff --git a/src/kimi_cli_ts/background/store.ts b/src/kimi_cli_ts/background/store.ts new file mode 100644 index 000000000..066e1c1b7 --- /dev/null +++ b/src/kimi_cli_ts/background/store.ts @@ -0,0 +1,251 @@ +/** + * Background task store — corresponds to Python background/store.py + * File-based persistence: per-task directory with spec.json, runtime.json, etc. + */ + +import { join } from "node:path"; +import { mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"; +import { logger } from "../utils/logging.ts"; +import { + type TaskSpec, + type TaskRuntime, + type TaskControl, + type TaskConsumerState, + type TaskView, + type TaskOutputChunk, + type TaskStatus, + taskSpecToJson, + taskSpecFromJson, + taskRuntimeToJson, + taskRuntimeFromJson, + taskControlToJson, + taskControlFromJson, + taskConsumerToJson, + taskConsumerFromJson, + newTaskRuntime, + newTaskControl, + newTaskConsumerState, +} from "./models.ts"; + +const VALID_TASK_ID = /^[a-z0-9][a-z0-9\-]{1,24}$/; + +function validateTaskId(taskId: string): void { + if (!VALID_TASK_ID.test(taskId)) { + throw new Error(`Invalid task_id: ${taskId}`); + } +} + +function atomicJsonWrite(data: Record, filePath: string): void { + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + // Bun.fs.renameSync is atomic on same filesystem + const { renameSync } = require("node:fs"); + renameSync(tmpPath, filePath); +} + +export class BackgroundTaskStore { + static readonly SPEC_FILE = "spec.json"; + static readonly RUNTIME_FILE = "runtime.json"; + static readonly CONTROL_FILE = "control.json"; + static readonly CONSUMER_FILE = "consumer.json"; + static readonly OUTPUT_FILE = "output.log"; + + private _root: string; + + constructor(root: string) { + this._root = root; + } + + get root(): string { + return this._root; + } + + private ensureRoot(): string { + if (!existsSync(this._root)) { + mkdirSync(this._root, { recursive: true }); + } + return this._root; + } + + taskDir(taskId: string): string { + validateTaskId(taskId); + const path = join(this.ensureRoot(), taskId); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } + return path; + } + + taskPath(taskId: string): string { + validateTaskId(taskId); + return join(this._root, taskId); + } + + specPath(taskId: string): string { + return join(this.taskPath(taskId), BackgroundTaskStore.SPEC_FILE); + } + + runtimePath(taskId: string): string { + return join(this.taskPath(taskId), BackgroundTaskStore.RUNTIME_FILE); + } + + controlPath(taskId: string): string { + return join(this.taskPath(taskId), BackgroundTaskStore.CONTROL_FILE); + } + + consumerPath(taskId: string): string { + return join(this.taskPath(taskId), BackgroundTaskStore.CONSUMER_FILE); + } + + outputPath(taskId: string): string { + return join(this.taskPath(taskId), BackgroundTaskStore.OUTPUT_FILE); + } + + createTask(spec: TaskSpec): void { + const dir = this.taskDir(spec.id); + atomicJsonWrite(taskSpecToJson(spec), join(dir, BackgroundTaskStore.SPEC_FILE)); + atomicJsonWrite(taskRuntimeToJson(newTaskRuntime()), join(dir, BackgroundTaskStore.RUNTIME_FILE)); + atomicJsonWrite(taskControlToJson(newTaskControl()), join(dir, BackgroundTaskStore.CONTROL_FILE)); + atomicJsonWrite(taskConsumerToJson(newTaskConsumerState()), join(dir, BackgroundTaskStore.CONSUMER_FILE)); + // Touch output file + writeFileSync(join(dir, BackgroundTaskStore.OUTPUT_FILE), "", "utf-8"); + } + + listTaskIds(): string[] { + if (!existsSync(this._root)) return []; + const taskIds: string[] = []; + for (const entry of readdirSync(this._root).sort()) { + const dirPath = join(this._root, entry); + try { + if (!statSync(dirPath).isDirectory()) continue; + } catch { + continue; + } + if (!existsSync(join(dirPath, BackgroundTaskStore.SPEC_FILE))) continue; + taskIds.push(entry); + } + return taskIds; + } + + writeSpec(spec: TaskSpec): void { + atomicJsonWrite(taskSpecToJson(spec), this.specPath(spec.id)); + } + + readSpec(taskId: string): TaskSpec { + const data = JSON.parse(readFileSync(this.specPath(taskId), "utf-8")); + return taskSpecFromJson(data); + } + + writeRuntime(taskId: string, runtime: TaskRuntime): void { + atomicJsonWrite(taskRuntimeToJson(runtime), this.runtimePath(taskId)); + } + + readRuntime(taskId: string): TaskRuntime { + const path = this.runtimePath(taskId); + if (!existsSync(path)) return newTaskRuntime(); + try { + const data = JSON.parse(readFileSync(path, "utf-8")); + return taskRuntimeFromJson(data); + } catch (err) { + logger.warn(`Corrupted runtime file for task ${taskId}, using default: ${err}`); + return newTaskRuntime(); + } + } + + writeControl(taskId: string, control: TaskControl): void { + atomicJsonWrite(taskControlToJson(control), this.controlPath(taskId)); + } + + readControl(taskId: string): TaskControl { + const path = this.controlPath(taskId); + if (!existsSync(path)) return newTaskControl(); + try { + const data = JSON.parse(readFileSync(path, "utf-8")); + return taskControlFromJson(data); + } catch (err) { + logger.warn(`Corrupted control file for task ${taskId}, using default: ${err}`); + return newTaskControl(); + } + } + + writeConsumer(taskId: string, consumer: TaskConsumerState): void { + atomicJsonWrite(taskConsumerToJson(consumer), this.consumerPath(taskId)); + } + + readConsumer(taskId: string): TaskConsumerState { + const path = this.consumerPath(taskId); + if (!existsSync(path)) return newTaskConsumerState(); + try { + const data = JSON.parse(readFileSync(path, "utf-8")); + return taskConsumerFromJson(data); + } catch (err) { + logger.warn(`Corrupted consumer file for task ${taskId}, using default: ${err}`); + return newTaskConsumerState(); + } + } + + mergedView(taskId: string): TaskView { + return { + spec: this.readSpec(taskId), + runtime: this.readRuntime(taskId), + control: this.readControl(taskId), + consumer: this.readConsumer(taskId), + }; + } + + listViews(): TaskView[] { + const views: TaskView[] = []; + for (const id of this.listTaskIds()) { + try { + views.push(this.mergedView(id)); + } catch (err) { + logger.warn(`Skipping corrupted task ${id}: ${err}`); + } + } + views.sort((a, b) => (b.runtime.updatedAt || b.spec.createdAt) - (a.runtime.updatedAt || a.spec.createdAt)); + return views; + } + + readOutput( + taskId: string, + offset: number, + maxBytes: number, + status: TaskStatus, + ): TaskOutputChunk { + const path = this.outputPath(taskId); + if (!existsSync(path)) { + return { taskId, offset, nextOffset: offset, text: "", eof: true, status }; + } + + const buf = readFileSync(path); + const totalSize = buf.length; + const boundedOffset = Math.min(Math.max(offset, 0), totalSize); + const content = buf.subarray(boundedOffset, boundedOffset + maxBytes); + const nextOffset = boundedOffset + content.length; + + return { + taskId, + offset: boundedOffset, + nextOffset, + text: content.toString("utf-8"), + eof: nextOffset >= totalSize, + status, + }; + } + + tailOutput(taskId: string, maxBytes: number, maxLines: number): string { + const path = this.outputPath(taskId); + if (!existsSync(path)) return ""; + + const buf = readFileSync(path); + const totalSize = buf.length; + const start = Math.max(0, totalSize - maxBytes); + const content = buf.subarray(start); + const text = content.toString("utf-8"); + let lines = text.split("\n"); + if (lines.length > maxLines) { + lines = lines.slice(-maxLines); + } + return lines.join("\n"); + } +} diff --git a/src/kimi_cli_ts/background/summary.ts b/src/kimi_cli_ts/background/summary.ts new file mode 100644 index 000000000..525a5c209 --- /dev/null +++ b/src/kimi_cli_ts/background/summary.ts @@ -0,0 +1,79 @@ +/** + * Background task summary/formatting — corresponds to Python background/summary.py + */ + +import type { TaskView } from "./models.ts"; +import { isTerminalStatus } from "./models.ts"; +import type { BackgroundTaskManager } from "./manager.ts"; + +export function listTaskViews( + manager: BackgroundTaskManager, + opts?: { activeOnly?: boolean; limit?: number }, +): TaskView[] { + const activeOnly = opts?.activeOnly ?? true; + const limit = opts?.limit ?? 20; + let views = manager.listTasks({ limit: undefined }); + if (activeOnly) { + views = views.filter((v) => !isTerminalStatus(v.runtime.status)); + } + return views.slice(0, limit); +} + +export function formatTask(view: TaskView, opts?: { includeCommand?: boolean }): string { + const lines = [ + `task_id: ${view.spec.id}`, + `kind: ${view.spec.kind}`, + `status: ${view.runtime.status}`, + `description: ${view.spec.description}`, + ]; + + if (view.spec.kind === "agent" && view.spec.kindPayload) { + const agentId = view.spec.kindPayload.agent_id; + if (agentId) lines.push(`agent_id: ${agentId}`); + const subagentType = view.spec.kindPayload.subagent_type; + if (subagentType) lines.push(`subagent_type: ${subagentType}`); + } + + if (opts?.includeCommand && view.spec.command) { + lines.push(`command: ${view.spec.command}`); + } + if (view.runtime.exitCode != null) { + lines.push(`exit_code: ${view.runtime.exitCode}`); + } + if (view.runtime.failureReason) { + lines.push(`reason: ${view.runtime.failureReason}`); + } + return lines.join("\n"); +} + +export function formatTaskList( + views: TaskView[], + opts?: { activeOnly?: boolean; includeCommand?: boolean }, +): string { + const activeOnly = opts?.activeOnly ?? true; + const includeCommand = opts?.includeCommand ?? true; + const header = activeOnly ? "active_background_tasks" : "background_tasks"; + + if (views.length === 0) { + return `${header}: 0\n[no tasks]`; + } + + const lines = [`${header}: ${views.length}`, ""]; + for (let i = 0; i < views.length; i++) { + lines.push(`[${i + 1}]`, formatTask(views[i]!, { includeCommand }), ""); + } + return lines.join("\n").trimEnd(); +} + +export function buildActiveTaskSnapshot( + manager: BackgroundTaskManager, + opts?: { limit?: number }, +): string | undefined { + const views = listTaskViews(manager, { activeOnly: true, limit: opts?.limit ?? 20 }); + if (views.length === 0) return undefined; + return [ + "", + formatTaskList(views, { activeOnly: true, includeCommand: false }), + "", + ].join("\n"); +} diff --git a/src/kimi_cli_ts/background/worker.ts b/src/kimi_cli_ts/background/worker.ts new file mode 100644 index 000000000..42693509b --- /dev/null +++ b/src/kimi_cli_ts/background/worker.ts @@ -0,0 +1,193 @@ +/** + * Background task worker — corresponds to Python background/worker.py + * Runs a bash command in a subprocess with heartbeat and kill polling. + */ + +import { join } from "node:path"; +import { existsSync, openSync, closeSync } from "node:fs"; +import { BackgroundTaskStore } from "./store.ts"; +import { isTerminalStatus } from "./models.ts"; + +export async function runBackgroundTaskWorker( + taskDir: string, + opts?: { + heartbeatIntervalMs?: number; + controlPollIntervalMs?: number; + killGracePeriodMs?: number; + }, +): Promise { + const heartbeatIntervalMs = opts?.heartbeatIntervalMs ?? 5000; + const controlPollIntervalMs = opts?.controlPollIntervalMs ?? 500; + const killGracePeriodMs = opts?.killGracePeriodMs ?? 2000; + + const taskId = taskDir.split("/").pop()!; + const storeRoot = join(taskDir, ".."); + const store = new BackgroundTaskStore(storeRoot); + const spec = store.readSpec(taskId); + let runtime = store.readRuntime(taskId); + + const now = Date.now() / 1000; + runtime.status = "starting"; + runtime.workerPid = process.pid; + runtime.startedAt = now; + runtime.heartbeatAt = now; + runtime.updatedAt = now; + store.writeRuntime(taskId, runtime); + + // Check if already killed before launch + const control = store.readControl(taskId); + if (control.killRequestedAt != null) { + runtime.status = "killed"; + runtime.interrupted = true; + runtime.finishedAt = Date.now() / 1000; + runtime.updatedAt = runtime.finishedAt; + runtime.failureReason = control.killReason ?? "Killed before command start"; + store.writeRuntime(taskId, runtime); + return; + } + + if (!spec.command || !spec.shellPath || !spec.cwd) { + runtime.status = "failed"; + runtime.finishedAt = Date.now() / 1000; + runtime.updatedAt = runtime.finishedAt; + runtime.failureReason = "Task spec is incomplete for bash worker"; + store.writeRuntime(taskId, runtime); + return; + } + + let timedOut = false; + let timeoutReason: string | undefined; + let killSentAt: number | undefined; + + const outputPath = store.outputPath(taskId); + const outputFd = openSync(outputPath, "a"); + + let proc: ReturnType | undefined; + let heartbeatTimer: ReturnType | undefined; + let controlTimer: ReturnType | undefined; + + try { + const args = + spec.shellName === "Windows PowerShell" + ? [spec.shellPath, "-command", spec.command] + : [spec.shellPath, "-c", spec.command]; + + proc = Bun.spawn(args, { + stdin: "ignore", + stdout: outputFd, + stderr: outputFd, + cwd: spec.cwd, + }); + + runtime = store.readRuntime(taskId); + runtime.status = "running"; + runtime.childPid = proc.pid; + runtime.childPgid = proc.pid; + runtime.updatedAt = Date.now() / 1000; + runtime.heartbeatAt = runtime.updatedAt; + store.writeRuntime(taskId, runtime); + + // Heartbeat loop + heartbeatTimer = setInterval(() => { + try { + const current = store.readRuntime(taskId); + if (current.finishedAt != null) return; + current.heartbeatAt = Date.now() / 1000; + current.updatedAt = current.heartbeatAt; + store.writeRuntime(taskId, current); + } catch { + // Ignore + } + }, heartbeatIntervalMs); + + // Control poll loop + controlTimer = setInterval(() => { + try { + const ctrl = store.readControl(taskId); + if (ctrl.killRequestedAt != null && proc) { + try { + proc.kill(); + } catch { + // Process may be gone + } + if ( + killSentAt != null && + proc.exitCode == null && + Date.now() / 1000 - killSentAt >= killGracePeriodMs / 1000 + ) { + try { + proc.kill(9); // SIGKILL + } catch { + // Ignore + } + } + if (killSentAt == null) { + killSentAt = Date.now() / 1000; + } + } + } catch { + // Ignore + } + }, controlPollIntervalMs); + + // Wait for process with optional timeout + let exitCode: number; + if (spec.timeoutS != null) { + const timeoutPromise = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), spec.timeoutS! * 1000), + ); + const result = await Promise.race([proc.exited, timeoutPromise]); + if (result === "timeout") { + timedOut = true; + timeoutReason = `Command timed out after ${spec.timeoutS}s`; + proc.kill(); + try { + exitCode = await proc.exited; + } catch { + exitCode = -1; + } + } else { + exitCode = result; + } + } else { + exitCode = await proc.exited; + } + + // Write final runtime + const finalControl = store.readControl(taskId); + const finalRuntime = store.readRuntime(taskId); + finalRuntime.finishedAt = Date.now() / 1000; + finalRuntime.updatedAt = finalRuntime.finishedAt; + finalRuntime.exitCode = exitCode; + finalRuntime.heartbeatAt = finalRuntime.finishedAt; + + if (timedOut) { + finalRuntime.status = "failed"; + finalRuntime.interrupted = true; + finalRuntime.timedOut = true; + finalRuntime.failureReason = timeoutReason; + } else if (finalControl.killRequestedAt != null) { + finalRuntime.status = "killed"; + finalRuntime.interrupted = true; + finalRuntime.failureReason = finalControl.killReason ?? "Killed"; + } else if (exitCode === 0) { + finalRuntime.status = "completed"; + finalRuntime.failureReason = undefined; + } else { + finalRuntime.status = "failed"; + finalRuntime.failureReason = `Command failed with exit code ${exitCode}`; + } + store.writeRuntime(taskId, finalRuntime); + } catch (err) { + runtime = store.readRuntime(taskId); + runtime.status = "failed"; + runtime.finishedAt = Date.now() / 1000; + runtime.updatedAt = runtime.finishedAt; + runtime.failureReason = String(err); + store.writeRuntime(taskId, runtime); + } finally { + if (heartbeatTimer) clearInterval(heartbeatTimer); + if (controlTimer) clearInterval(controlTimer); + closeSync(outputFd); + } +} diff --git a/src/kimi_cli_ts/cli/export.ts b/src/kimi_cli_ts/cli/export.ts new file mode 100644 index 000000000..77a7a10ef --- /dev/null +++ b/src/kimi_cli_ts/cli/export.ts @@ -0,0 +1,185 @@ +/** + * CLI export command — corresponds to Python cli/export.py + * Exports a session as a ZIP archive. + */ + +import { Command } from "commander"; +import { join, resolve } from "node:path"; +import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { Session } from "../session.ts"; +import { getShareDir } from "../config.ts"; +import { parseWireFileLine, type WireFileMetadata, type WireMessageRecord } from "../wire/file.ts"; +import { fromEnvelope } from "../wire/types.ts"; +import * as readline from "node:readline"; + +// ── Helpers ────────────────────────────────────────────── + +function lastUserMessageTimestamp(sessionDir: string): number | null { + const wireFile = join(sessionDir, "wire.jsonl"); + if (!existsSync(wireFile)) return null; + + let lastTurnBegin: number | null = null; + try { + const content = readFileSync(wireFile, "utf-8"); + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + const parsed = parseWireFileLine(line); + if ("type" in parsed && parsed.type === "metadata") continue; + const record = parsed as WireMessageRecord; + try { + const { typeName } = fromEnvelope(record.message); + if (typeName === "TurnBegin") { + lastTurnBegin = record.timestamp; + } + } catch { + continue; + } + } catch { + continue; + } + } + } catch { + return null; + } + + return lastTurnBegin; +} + +function formatMessageTimestamp(timestamp: number | null): string { + if (timestamp === null) return "(no user message)"; + return new Date(timestamp * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"); +} + +function confirmPrompt(message: string): Promise { + return new Promise((res) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + res(answer.trim().toLowerCase() === "y"); + }); + }); +} + +function confirmPreviousSession(session: Session): Promise { + const lastUserMessage = formatMessageTimestamp(lastUserMessageTimestamp(session.dir)); + + console.log("About to export the previous session for this working directory:"); + console.log(); + console.log(`Work dir: ${session.workDir}`); + console.log(`Session ID: ${session.id}`); + console.log(`Title: ${session.title}`); + console.log(`Last user message: ${lastUserMessage}`); + console.log(); + return confirmPrompt("Export this session?"); +} + +function findSessionById(sessionId: string): string | null { + const sessionsRoot = join(getShareDir(), "sessions"); + if (!existsSync(sessionsRoot)) return null; + + try { + for (const workDirHash of readdirSync(sessionsRoot)) { + const workDirHashDir = join(sessionsRoot, workDirHash); + try { + if (!statSync(workDirHashDir).isDirectory()) continue; + } catch { + continue; + } + const candidate = join(workDirHashDir, sessionId); + try { + if (statSync(candidate).isDirectory()) return candidate; + } catch { + continue; + } + } + } catch { + // ignore + } + return null; +} + +export const exportCommand = new Command("export") + .description("Export a session as a ZIP archive.") + .argument("[session-id]", "Session ID to export. Defaults to the previous session.") + .option("-o, --output ", "Output ZIP file path. Default: session-{id}.zip in current directory.") + .option("-y, --yes", "Skip confirmation when exporting the previous session by default.") + .action(async (sessionId: string | undefined, options: { output?: string; yes?: boolean }) => { + const workDir = resolve(process.cwd()); + + let resolvedSessionId: string; + let sessionDir: string; + + if (!sessionId) { + // No session ID provided — use the previous session for this work dir + const session = await Session.continue_(workDir); + if (!session) { + console.error("Error: no previous session found for the working directory."); + process.exit(1); + } + if (!options.yes) { + const confirmed = await confirmPreviousSession(session); + if (!confirmed) { + console.log("Export cancelled."); + return; + } + } + resolvedSessionId = session.id; + sessionDir = session.dir; + } else { + // Explicit session ID — try work-dir-scoped find first, then global + const session = await Session.find(workDir, sessionId); + if (session) { + resolvedSessionId = session.id; + sessionDir = session.dir; + } else { + const found = findSessionById(sessionId); + if (!found) { + console.error(`Error: session '${sessionId}' not found.`); + process.exit(1); + } + resolvedSessionId = sessionId; + sessionDir = found; + } + } + + // Collect files + let files: string[]; + try { + files = readdirSync(sessionDir) + .filter((f) => { + try { + return statSync(join(sessionDir, f)).isFile(); + } catch { + return false; + } + }) + .sort(); + } catch { + files = []; + } + + if (files.length === 0) { + console.error(`Error: session '${resolvedSessionId}' has no files.`); + process.exit(1); + } + + // Determine output path + const outputPath = options.output + ? resolve(options.output) + : resolve(process.cwd(), `session-${resolvedSessionId}.zip`); + + // Use shell zip command + try { + const outputDir = outputPath.substring(0, outputPath.lastIndexOf("/")); + mkdirSync(outputDir, { recursive: true }); + + const fileArgs = files.map((f) => join(sessionDir, f)); + await Bun.$`zip -j ${outputPath} ${fileArgs}`.quiet(); + console.log(outputPath); + } catch (err) { + console.error(`Error creating ZIP: ${err}`); + process.exit(1); + } + }); diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts new file mode 100644 index 000000000..9e9e65d86 --- /dev/null +++ b/src/kimi_cli_ts/cli/index.ts @@ -0,0 +1,518 @@ +/** + * CLI router — corresponds to Python cli/__init__.py + * Uses Commander.js (replaces Typer) + */ + +import { Command } from "commander"; +import React from "react"; +import { render } from "ink"; +import { KimiCLI } from "../app.ts"; +import type { SoulCallbacks } from "../soul/kimisoul.ts"; +import { Shell } from "../ui/shell/Shell.tsx"; +import type { WireUIEvent } from "../ui/shell/events.ts"; +import chalk from "chalk"; + +// ── Re-exports from Python cli/__init__.py ────────────── + +export class Reload extends Error { + sessionId: string | null; + prefillText: string | null; + constructor(sessionId: string | null = null, prefillText: string | null = null) { + super("reload"); + this.name = "Reload"; + this.sessionId = sessionId; + this.prefillText = prefillText; + } +} + +/** Return true if an unknown value is a Reload sentinel. */ +function isReload(err: unknown): err is Reload { + return err instanceof Reload || (err instanceof Error && err.name === "Reload"); +} + +export class SwitchToWeb extends Error { + sessionId: string | null; + constructor(sessionId: string | null = null) { + super("switch_to_web"); + this.name = "SwitchToWeb"; + this.sessionId = sessionId; + } +} + +export class SwitchToVis extends Error { + sessionId: string | null; + constructor(sessionId: string | null = null) { + super("switch_to_vis"); + this.name = "SwitchToVis"; + this.sessionId = sessionId; + } +} + +export type UIMode = "shell" | "print" | "acp" | "wire"; +export type InputFormat = "text" | "stream-json"; +export type OutputFormat = "text" | "stream-json"; + +export const ExitCode = { + SUCCESS: 0, + FAILURE: 1, + RETRYABLE: 75, // EX_TEMPFAIL from sysexits.h +} as const; + +// ── Helpers ───────────────────────────────────────── + +/** + * Remove the trailing ` (session_id)` suffix from a session title, if present. + */ +function stripSessionIdSuffix(title: string, sessionId: string): string { + const suffix = ` (${sessionId})`; + return title.endsWith(suffix) ? title.slice(0, -suffix.length) : title; +} + +/** + * Print a hint for resuming the session after exit. + */ +function printResumeHint(session: { id: string; isEmpty?: () => Promise }): void { + console.error(chalk.dim(`\nTo resume this session: kimi -r ${session.id}`)); +} + +// ── Subcommands ────────────────────────────────────────── + +import { loginCommand } from "./login.ts"; +import { logoutCommand } from "./logout.ts"; +import { infoCommand } from "./info.ts"; +import { exportCommand } from "./export.ts"; +import { mcpCommand } from "./mcp.ts"; +import { pluginCommand } from "./plugin.ts"; +import { visCommand } from "./vis.ts"; +import { webCommand } from "./web.ts"; + +// ── Version callback ───────────────────────────────────── + +function getVersionString(): string { + try { + const { getVersion } = require("../constant.ts"); + return getVersion(); + } catch { + return "0.0.0"; + } +} + +// ── Program ────────────────────────────────────────────── + +const program = new Command() + .name("kimi") + .description("Kimi, your next CLI agent.") + .version(getVersionString(), "-V, --version") + .addCommand(loginCommand) + .addCommand(logoutCommand) + .addCommand(infoCommand) + .addCommand(exportCommand) + .addCommand(mcpCommand) + .addCommand(pluginCommand) + .addCommand(visCommand) + .addCommand(webCommand); + +// Main chat command (default) +program + .argument("[prompt...]", "Initial prompt to send") + .option("-m, --model ", "Model to use") + .option("--thinking", "Enable thinking mode") + .option("--no-thinking", "Disable thinking mode") + .option("--yolo", "Auto-approve all tool calls") + .option("-y, --yes", "Alias for --yolo (auto-approve all tool calls)") + .option("--plan", "Start in plan mode") + .option("--print", "Print mode (non-interactive)") + .option("-w, --work-dir ", "Working directory") + .option("--add-dir ", "Add additional directories to the workspace") + .option("--max-steps-per-turn ", "Max steps per turn", parseInt) + .option("--max-retries-per-step ", "Max retries per step", parseInt) + .option("--config-file ", "Config TOML/JSON file to load") + .option("--config ", "Config TOML/JSON string to load") + .option( + "-S, --session [id]", + "Resume a session. With ID: resume that session. Without ID: interactively pick a session.", + ) + .option("-r, --resume [id]", "Alias for --session") + .option("-C, --continue", "Continue the most recent session") + .option("--input-format ", "Input format (text, stream-json). Print mode only.") + .option("--output-format ", "Output format (text, stream-json). Print mode only.") + .option("--quiet", "Alias for --print --output-format text --final-message-only") + .option("--final-message-only", "Only print the final assistant message (print UI)") + .option("-p, --prompt ", "User prompt to the agent") + .option("--verbose", "Verbose output") + .option("--debug", "Debug mode") + .option("--wire", "Run as Wire server (experimental)") + .option("--agent ", "Builtin agent specification to use") + .option("--agent-file ", "Custom agent specification file") + .option("--mcp-config-file ", "MCP config file(s) to load") + .option("--mcp-config ", "MCP config JSON to load") + .option("--command ", "Run a single shell command and exit") + .option("--skills-dir ", "Custom skills directories (repeatable)") + .option("--max-ralph-iterations ", "Max ralph loop iterations", parseInt) + .action( + async ( + promptParts: string[], + options: { + model?: string; + thinking?: boolean; + yolo?: boolean; + yes?: boolean; + plan?: boolean; + print?: boolean; + workDir?: string; + addDir?: string[]; + maxStepsPerTurn?: number; + maxRetriesPerStep?: number; + configFile?: string; + config?: string; + session?: string | true; + resume?: string | true; + continue?: boolean; + inputFormat?: string; + outputFormat?: string; + quiet?: boolean; + finalMessageOnly?: boolean; + prompt?: string; + verbose?: boolean; + debug?: boolean; + wire?: boolean; + agent?: string; + agentFile?: string; + mcpConfigFile?: string[]; + mcpConfig?: string[]; + command?: string; + skillsDir?: string[]; + maxRalphIterations?: number; + }, + ) => { + // Handle --yes alias for --yolo + if (options.yes) options.yolo = true; + + // Merge --resume into --session (they are aliases) + if (options.resume !== undefined && options.session === undefined) { + options.session = options.resume; + } + + // session states: + // undefined → not provided (new session) + // true → --session/--resume without value (picker mode) + // "ID" → --session ID (resume specific session) + const pickerMode = options.session === true; + let resolvedSessionId: string | undefined = pickerMode + ? undefined + : (options.session as string | undefined); + + // Handle --quiet alias + if (options.quiet) { + options.print = true; + options.outputFormat = "text"; + options.finalMessageOnly = true; + } + + // Resolve prompt from either positional args or --prompt option + const prompt = + promptParts.length > 0 + ? promptParts.join(" ") + : options.prompt ?? undefined; + + // Determine config source: --config-file takes precedence over legacy --config as path + const configFile = options.configFile ?? undefined; + + // Validate: picker mode only works in shell (interactive) mode + if (pickerMode && options.print) { + console.error( + "Error: --session without a session ID is only supported for shell UI", + ); + process.exit(2); + } + + // If picker mode, run interactive session picker before starting the app + if (pickerMode) { + const { Session } = await import("../session.ts"); + const workDir = options.workDir ?? process.cwd(); + const allSessions = await Session.list(workDir); + if (allSessions.length === 0) { + console.error("No sessions found for the working directory."); + process.exit(0); + } + + // Use simple numbered list for session picking + console.log("Select a session to resume:\n"); + for (let i = 0; i < allSessions.length; i++) { + const s = allSessions[i]!; + const shortId = s.id.slice(0, 8); + const name = stripSessionIdSuffix(s.title, s.id); + console.log(` ${i + 1}. ${name} (${shortId})`); + } + console.log(""); + + // Read user selection + const readline = await import("node:readline"); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await new Promise((resolve) => { + rl.question("Enter number (or Ctrl+C to cancel): ", resolve); + }); + rl.close(); + + const idx = parseInt(answer, 10) - 1; + if (isNaN(idx) || idx < 0 || idx >= allSessions.length) { + console.error("Invalid selection."); + process.exit(1); + } + resolvedSessionId = allSessions[idx]!.id; + } + + // Ink unmount function — captured in interactive mode for error-path cleanup + let unmount: (() => void) | undefined; + // pushEvent — hoisted so the outer catch block can push errors to the UI + let pushEvent: ((event: WireUIEvent) => void) | null = null; + + try { + if (options.print) { + // ── Print mode: callbacks write directly to stdout/stderr ── + const callbacks: SoulCallbacks = { + onTextDelta: (text) => process.stdout.write(text), + onThinkDelta: (text) => process.stderr.write(chalk.dim(text)), + onError: (err) => + process.stderr.write(chalk.red(`[ERROR] ${err.message}\n`)), + onTurnEnd: () => process.stdout.write("\n"), + onStatusUpdate: (status) => { + if (options.verbose && status.tokenUsage) { + process.stderr.write( + chalk.dim( + `[tokens] in=${status.tokenUsage.inputTokens} out=${status.tokenUsage.outputTokens}\n`, + ), + ); + } + }, + }; + + const app = await KimiCLI.create({ + workDir: options.workDir, + additionalDirs: options.addDir, + configFile, + modelName: options.model, + thinking: options.thinking, + yolo: options.yolo ?? true, // print mode implies yolo + planMode: options.plan, + sessionId: resolvedSessionId, + continueSession: options.continue, + maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, + callbacks, + }); + + if (prompt) await app.runPrint(prompt); + printResumeHint(app.session); + await app.shutdown(); + } else if (options.wire) { + // ── Wire mode ── + const app = await KimiCLI.create({ + workDir: options.workDir, + additionalDirs: options.addDir, + configFile, + modelName: options.model, + thinking: options.thinking, + yolo: options.yolo, + planMode: options.plan, + sessionId: resolvedSessionId, + continueSession: options.continue, + maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, + callbacks: {}, + }); + // Wire mode not yet implemented + console.error("Wire mode is not yet implemented."); + await app.shutdown(); + process.exit(1); + } else { + // ── Interactive mode with reload loop ── + // /undo and /fork trigger a reload by storing the new session info + // and unmounting Ink, which causes waitUntilExit() to resolve. + let currentSessionId: string | undefined = resolvedSessionId; + let currentPrefillText: string | undefined = undefined; + let currentPrompt: string | undefined = prompt; + + // eslint-disable-next-line no-constant-condition + while (true) { + // Pending reload info — set by onReload, checked after waitUntilExit + let pendingReload: { sessionId: string; prefillText?: string } | null = null; + + // pushEvent will be set by Shell's onWireReady callback + pushEvent = null; + + const callbacks: SoulCallbacks = { + onTurnBegin: (userInput) => { + const text = + typeof userInput === "string" + ? userInput + : "[complex input]"; + pushEvent?.({ type: "turn_begin", userInput: text }); + }, + onTurnEnd: () => { + pushEvent?.({ type: "turn_end" }); + }, + onStepBegin: (n) => { + pushEvent?.({ type: "step_begin", n }); + }, + onTextDelta: (text) => { + pushEvent?.({ type: "text_delta", text }); + }, + onThinkDelta: (text) => { + pushEvent?.({ type: "think_delta", text }); + }, + onToolCall: (tc) => { + pushEvent?.({ + type: "tool_call", + id: tc.id, + name: tc.name, + arguments: tc.arguments, + }); + }, + onToolResult: (toolCallId, result) => { + pushEvent?.({ + type: "tool_result", + toolCallId, + result: { + tool_call_id: toolCallId, + return_value: { + isError: result.isError, + output: result.output, + message: result.message, + }, + display: [], + }, + }); + }, + onStatusUpdate: (status) => { + pushEvent?.({ + type: "status_update", + status: { + context_usage: status.contextUsage ?? null, + context_tokens: status.contextTokens ?? null, + max_context_tokens: status.maxContextTokens ?? null, + token_usage: status.tokenUsage ?? null, + message_id: null, + plan_mode: status.planMode ?? null, + mcp_status: null, + }, + }); + }, + onCompactionBegin: () => { + pushEvent?.({ type: "compaction_begin" }); + }, + onCompactionEnd: () => { + pushEvent?.({ type: "compaction_end" }); + }, + onError: (err) => { + pushEvent?.({ type: "error", message: err.message }); + }, + onNotification: (title, body) => { + pushEvent?.({ type: "notification", title, body }); + }, + }; + + const app = await KimiCLI.create({ + workDir: options.workDir, + additionalDirs: options.addDir, + configFile, + modelName: options.model, + thinking: options.thinking, + yolo: options.yolo, + sessionId: currentSessionId, + continueSession: !currentSessionId ? options.continue : undefined, + resumed: !!currentSessionId, + maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, + callbacks, + }); + + const { waitUntilExit, unmount: inkUnmount } = render( + React.createElement(Shell, { + modelName: app.soul.modelName, + workDir: options.workDir ?? process.cwd(), + sessionId: app.session.id, + sessionDir: app.session.dir, + sessionTitle: app.session.title, + thinking: app.soul.thinking, + prefillText: currentPrefillText, + onSubmit: (input: string) => { + app.soul.run(input).catch((err: Error) => { + pushEvent?.({ type: "error", message: err.message }); + }); + }, + onInterrupt: () => { + app.soul.abort(); + }, + onPlanModeToggle: async () => { + return app.soul.togglePlanModeFromManual(); + }, + onWireReady: (push) => { + pushEvent = push; + }, + onReload: (newSessionId: string, prefill?: string) => { + // Print resume hint for the old session before switching + printResumeHint(app.session); + // Store reload info and unmount Ink to break out of waitUntilExit + pendingReload = { sessionId: newSessionId, prefillText: prefill }; + inkUnmount(); + }, + extraSlashCommands: app.soul.availableSlashCommands, + }), + { exitOnCtrlC: false, incrementalRendering: true }, + ); + unmount = inkUnmount; + + // Run initial prompt if provided (only on first iteration) + if (currentPrompt) { + app.soul.run(currentPrompt).catch((err: Error) => { + pushEvent?.({ type: "error", message: err.message }); + }); + } + + await waitUntilExit(); + await app.shutdown(); + + // Check if this was a reload (/undo or /fork) + if (pendingReload) { + currentSessionId = pendingReload.sessionId; + currentPrefillText = pendingReload.prefillText; + currentPrompt = undefined; // Don't re-run the initial prompt + continue; + } + + // Normal exit + printResumeHint(app.session); + break; + } + } + } catch (err) { + if (isReload(err)) { + // Shouldn't happen with the new loop, but handle gracefully + console.error("Unexpected reload — restarting is not supported here."); + process.exit(ExitCode.FAILURE); + } + const errMsg = err instanceof Error ? err.message : String(err); + // Push error to UI so the user can see it — don't crash the app + const push = pushEvent as ((event: WireUIEvent) => void) | null; + if (push) { + push({ type: "error", message: errMsg }); + } else { + // No UI available (e.g. during startup) — fall back to stderr + exit + if (typeof unmount === "function") unmount(); + console.error("Error:", errMsg); + process.exit(ExitCode.FAILURE); + } + } + }, + ); + +export async function cli(argv: string[]): Promise { + try { + await program.parseAsync(argv); + return ExitCode.SUCCESS; + } catch (error) { + console.error("Fatal error:", error); + return ExitCode.FAILURE; + } +} diff --git a/src/kimi_cli_ts/cli/info.ts b/src/kimi_cli_ts/cli/info.ts new file mode 100644 index 000000000..c4447168f --- /dev/null +++ b/src/kimi_cli_ts/cli/info.ts @@ -0,0 +1,48 @@ +/** + * CLI info command — corresponds to Python cli/info.py + * Displays version, agent spec, wire protocol, and runtime info. + */ + +import { Command } from "commander"; + +interface InfoData { + kimi_cli_version: string; + wire_protocol_version: string; + runtime: string; + runtime_version: string; +} + +function collectInfo(): InfoData { + // Lazy imports to avoid circular dependencies at module load time + const { getVersion } = require("../constant.ts"); + let wireProtocolVersion = "unknown"; + try { + const wire = require("../wire/protocol.ts"); + wireProtocolVersion = wire.WIRE_PROTOCOL_VERSION ?? "unknown"; + } catch { + // wire module may not be available + } + + return { + kimi_cli_version: getVersion(), + wire_protocol_version: wireProtocolVersion, + runtime: "bun", + runtime_version: typeof Bun !== "undefined" ? Bun.version : process.version, + }; +} + +export const infoCommand = new Command("info") + .description("Show version and protocol information.") + .option("--json", "Output information as JSON.", false) + .action((options: { json?: boolean }) => { + const info = collectInfo(); + + if (options.json) { + console.log(JSON.stringify(info, null, 2)); + return; + } + + console.log(`kimi-cli version: ${info.kimi_cli_version}`); + console.log(`wire protocol: ${info.wire_protocol_version}`); + console.log(`runtime: ${info.runtime} ${info.runtime_version}`); + }); diff --git a/src/kimi_cli_ts/cli/login.ts b/src/kimi_cli_ts/cli/login.ts new file mode 100644 index 000000000..49bbb1178 --- /dev/null +++ b/src/kimi_cli_ts/cli/login.ts @@ -0,0 +1,53 @@ +/** + * CLI login command — corresponds to Python cli login() command + * Device-code OAuth flow with console/JSON output. + */ + +import { Command } from "commander"; + +export const loginCommand = new Command("login") + .description("Login to your Kimi account.") + .option("--json", "Emit OAuth events as JSON lines.", false) + .action(async (options: { json?: boolean }) => { + const { loginKimiCode } = await import("../auth/oauth.ts"); + const { loadConfig } = await import("../config.ts"); + + const { config } = await loadConfig(); + let ok = true; + + if (options.json) { + for await (const event of loginKimiCode(config)) { + console.log( + JSON.stringify({ + type: event.type, + message: event.message, + ...(event.data ? { data: event.data } : {}), + }), + ); + if (event.type === "error") ok = false; + } + } else { + let waiting = false; + for await (const event of loginKimiCode(config)) { + if (event.type === "waiting") { + if (!waiting) { + process.stderr.write("Waiting for user authorization...\n"); + waiting = true; + } + continue; + } + waiting = false; + const color = + event.type === "error" + ? "\x1b[31m" + : event.type === "success" + ? "\x1b[32m" + : ""; + const reset = color ? "\x1b[0m" : ""; + console.log(`${color}${event.message}${reset}`); + if (event.type === "error") ok = false; + } + } + + if (!ok) process.exit(1); + }); diff --git a/src/kimi_cli_ts/cli/logout.ts b/src/kimi_cli_ts/cli/logout.ts new file mode 100644 index 000000000..0023aa888 --- /dev/null +++ b/src/kimi_cli_ts/cli/logout.ts @@ -0,0 +1,44 @@ +/** + * CLI logout command — corresponds to Python cli logout() command + * Clears OAuth tokens and config. + */ + +import { Command } from "commander"; + +export const logoutCommand = new Command("logout") + .description("Logout from your Kimi account.") + .option("--json", "Emit OAuth events as JSON lines.", false) + .action(async (options: { json?: boolean }) => { + const { logoutKimiCode } = await import("../auth/oauth.ts"); + const { loadConfig } = await import("../config.ts"); + + const { config } = await loadConfig(); + let ok = true; + + if (options.json) { + for await (const event of logoutKimiCode(config)) { + console.log( + JSON.stringify({ + type: event.type, + message: event.message, + ...(event.data ? { data: event.data } : {}), + }), + ); + if (event.type === "error") ok = false; + } + } else { + for await (const event of logoutKimiCode(config)) { + const color = + event.type === "error" + ? "\x1b[31m" + : event.type === "success" + ? "\x1b[32m" + : ""; + const reset = color ? "\x1b[0m" : ""; + console.log(`${color}${event.message}${reset}`); + if (event.type === "error") ok = false; + } + } + + if (!ok) process.exit(1); + }); diff --git a/src/kimi_cli_ts/cli/mcp.ts b/src/kimi_cli_ts/cli/mcp.ts new file mode 100644 index 000000000..12e177890 --- /dev/null +++ b/src/kimi_cli_ts/cli/mcp.ts @@ -0,0 +1,218 @@ +/** + * MCP server management CLI — corresponds to Python cli/mcp.py + */ + +import { Command } from "commander"; +import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +function getGlobalMcpConfigFile(): string { + const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "."; + return join(homeDir, ".kimi", "mcp.json"); +} + +function loadMcpConfig(): Record { + const mcpFile = getGlobalMcpConfigFile(); + if (!existsSync(mcpFile)) return { mcpServers: {} }; + try { + return JSON.parse(readFileSync(mcpFile, "utf-8")) as Record; + } catch (err) { + console.error(`Invalid JSON in MCP config file '${mcpFile}': ${err}`); + process.exit(1); + } +} + +function saveMcpConfig(config: Record): void { + const mcpFile = getGlobalMcpConfigFile(); + writeFileSync(mcpFile, JSON.stringify(config, null, 2), "utf-8"); +} + +function getMcpServer(name: string): Record { + const config = loadMcpConfig(); + const servers = (config.mcpServers ?? {}) as Record>; + if (!(name in servers)) { + console.error(`MCP server '${name}' not found.`); + process.exit(1); + } + return servers[name]!; +} + +function parseKeyValuePairs( + items: string[], + optionName: string, + separator = "=", +): Record { + const parsed: Record = {}; + for (const item of items) { + const idx = item.indexOf(separator); + if (idx === -1) { + console.error(`Invalid ${optionName} format: ${item} (expected KEY${separator}VALUE).`); + process.exit(1); + } + const key = separator === ":" ? item.slice(0, idx).trim() : item.slice(0, idx); + const value = separator === ":" ? item.slice(idx + 1).trim() : item.slice(idx + 1); + if (!key) { + console.error(`Invalid ${optionName} format: ${item} (empty key).`); + process.exit(1); + } + parsed[key] = value; + } + return parsed; +} + +function collect(val: string, prev: string[]): string[] { + return [...prev, val]; +} + +export const mcpCommand = new Command("mcp").description("Manage MCP server configurations."); + +mcpCommand + .command("add") + .description("Add an MCP server.") + .argument("", "Name of the MCP server to add") + .argument("[args...]", "For http: server URL. For stdio: command to run (prefix with `--`).") + .option("-t, --transport ", "Transport type (stdio or http)", "stdio") + .option("-e, --env ", "Environment variables in KEY=VALUE format", collect, []) + .option("-H, --header ", "HTTP headers in KEY:VALUE format", collect, []) + .option("-a, --auth ", "Authorization type (e.g., 'oauth')") + .action( + ( + name: string, + serverArgs: string[], + options: { + transport: string; + env: string[]; + header: string[]; + auth?: string; + }, + ) => { + const config = loadMcpConfig(); + const { transport, env, header, auth } = options; + + let serverConfig: Record; + + if (transport === "stdio") { + if (serverArgs.length === 0) { + console.error("For stdio transport, provide the command after `--`."); + process.exit(1); + } + if (header.length > 0) { + console.error("--header is only valid for http transport."); + process.exit(1); + } + if (auth) { + console.error("--auth is only valid for http transport."); + process.exit(1); + } + const [command, ...cmdArgs] = serverArgs; + serverConfig = { command, args: cmdArgs }; + if (env.length > 0) { + serverConfig.env = parseKeyValuePairs(env, "env"); + } + } else if (transport === "http") { + if (env.length > 0) { + console.error("--env is only supported for stdio transport."); + process.exit(1); + } + if (serverArgs.length === 0) { + console.error("URL is required for http transport."); + process.exit(1); + } + if (serverArgs.length > 1) { + console.error("Supply a single URL for http transport."); + process.exit(1); + } + serverConfig = { url: serverArgs[0], transport: "http" }; + if (header.length > 0) { + serverConfig.headers = parseKeyValuePairs(header, "header", ":"); + } + if (auth) serverConfig.auth = auth; + } else { + console.error(`Unsupported transport: ${transport}`); + process.exit(1); + } + + const servers = ((config.mcpServers ?? {}) as Record); + servers[name] = serverConfig; + config.mcpServers = servers; + saveMcpConfig(config); + console.log(`Added MCP server '${name}' to ${getGlobalMcpConfigFile()}.`); + }, + ); + +mcpCommand + .command("remove") + .description("Remove an MCP server.") + .argument("", "Name of the MCP server to remove") + .action((name: string) => { + getMcpServer(name); // Validates it exists + const config = loadMcpConfig(); + const servers = config.mcpServers as Record; + delete servers[name]; + saveMcpConfig(config); + console.log(`Removed MCP server '${name}' from ${getGlobalMcpConfigFile()}.`); + }); + +mcpCommand + .command("list") + .description("List all MCP servers.") + .action(() => { + const configFile = getGlobalMcpConfigFile(); + const config = loadMcpConfig(); + const servers = (config.mcpServers ?? {}) as Record>; + + console.log(`MCP config file: ${configFile}`); + if (Object.keys(servers).length === 0) { + console.log("No MCP servers configured."); + return; + } + + for (const [name, server] of Object.entries(servers)) { + let line: string; + if ("command" in server) { + const cmdArgs = ((server.args as string[]) ?? []).join(" "); + line = `${name} (stdio): ${server.command} ${cmdArgs}`.trimEnd(); + } else if ("url" in server) { + let transport = (server.transport as string) ?? "http"; + if (transport === "streamable-http") transport = "http"; + line = `${name} (${transport}): ${server.url}`; + } else { + line = `${name}: ${JSON.stringify(server)}`; + } + console.log(` ${line}`); + } + }); + +mcpCommand + .command("test") + .description("Test connection to an MCP server and list available tools.") + .argument("", "Name of the MCP server to test") + .action((name: string) => { + getMcpServer(name); + // TODO: Implement MCP client test when fastmcp TS equivalent is available + console.log(`Testing MCP server '${name}' is not yet implemented in TypeScript.`); + }); + +mcpCommand + .command("auth") + .description("Authorize with an OAuth-enabled MCP server.") + .argument("", "Name of the MCP server to authorize") + .action((name: string) => { + const server = getMcpServer(name); + if (server.auth !== "oauth") { + console.error(`MCP server '${name}' does not use OAuth. Add with --auth oauth.`); + process.exit(1); + } + // TODO: Implement OAuth flow when fastmcp TS equivalent is available + console.log(`OAuth authorization for MCP server '${name}' is not yet implemented.`); + }); + +mcpCommand + .command("reset-auth") + .description("Reset OAuth authorization for an MCP server.") + .argument("", "Name of the MCP server to reset authorization") + .action((name: string) => { + getMcpServer(name); + // TODO: Implement OAuth token clearing + console.log(`OAuth token reset for '${name}' is not yet implemented.`); + }); diff --git a/src/kimi_cli_ts/cli/plugin.ts b/src/kimi_cli_ts/cli/plugin.ts new file mode 100644 index 000000000..acd90b9ee --- /dev/null +++ b/src/kimi_cli_ts/cli/plugin.ts @@ -0,0 +1,385 @@ +/** + * Plugin management CLI — corresponds to Python cli/plugin.py + */ + +import { Command } from "commander"; +import { join, resolve } from "node:path"; +import { + existsSync, + statSync, + readdirSync, + rmSync, + mkdtempSync, +} from "node:fs"; +import { tmpdir } from "node:os"; + +import { + getPluginsDir, + installPlugin, + listPlugins, + removePlugin, + parsePluginJson, + PluginError, + PLUGIN_JSON, +} from "../plugin/manager.ts"; +import { collectHostValues } from "../plugin/tool.ts"; + +// ── Git URL parsing ────────────────────────────────────── + +function parseGitUrl(target: string): { + cloneUrl: string; + subpath: string | undefined; + branch: string | undefined; +} { + // Path 1: URL contains .git followed by / or end-of-string + const idx = target.indexOf(".git/"); + if (idx === -1 && target.endsWith(".git")) { + return { cloneUrl: target, subpath: undefined, branch: undefined }; + } + if (idx !== -1) { + const cloneUrl = target.slice(0, idx + 4); + const rest = + target + .slice(idx + 5) + .replace(/^\/+|\/+$/g, "") + .trim() || undefined; + return { cloneUrl, subpath: rest, branch: undefined }; + } + + // Path 2: GitHub/GitLab short URL (no .git) + let url: URL; + try { + url = new URL(target); + } catch { + return { cloneUrl: target, subpath: undefined, branch: undefined }; + } + + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length < 2) { + return { cloneUrl: target, subpath: undefined, branch: undefined }; + } + + const ownerRepo = segments.slice(0, 2).join("/"); + const cloneUrl = `${url.protocol}//${url.host}/${ownerRepo}`; + let restSegments = segments.slice(2); + + // GitLab uses /-/tree/{branch}/ + if (restSegments.length > 0 && restSegments[0] === "-") { + restSegments = restSegments.slice(1); + } + + // Strip tree/{branch}/ prefix + let branch: string | undefined; + if (restSegments.length >= 2 && restSegments[0] === "tree") { + branch = restSegments[1]; + restSegments = restSegments.slice(2); + } + + const subpath = restSegments.join("/") || undefined; + return { cloneUrl, subpath, branch }; +} + +function isGitUrl(target: string): boolean { + return ( + (target.startsWith("https://") || + target.startsWith("git@") || + target.startsWith("http://")) && + (target.includes(".git/") || + target.endsWith(".git") || + target.includes("github.com/") || + target.includes("gitlab.com/")) + ); +} + +// ── Source resolution ──────────────────────────────────── + +function resolveSource(target: string): { + sourceDir: string; + tmpDir: string | null; +} { + const { execSync } = require("node:child_process"); + + // --- Git URL --- + if (isGitUrl(target)) { + const { cloneUrl, subpath, branch } = parseGitUrl(target); + const tmp = mkdtempSync(join(tmpdir(), "kimi-plugin-")); + const repoDir = join(tmp, "repo"); + + console.log(`Cloning ${cloneUrl}...`); + const cloneCmd = ["git", "clone", "--depth", "1"]; + if (branch) cloneCmd.push("--branch", branch); + cloneCmd.push(cloneUrl, repoDir); + + try { + execSync(cloneCmd.join(" "), { stdio: "pipe" }); + } catch (err: unknown) { + rmSync(tmp, { recursive: true, force: true }); + const stderr = + err && typeof err === "object" && "stderr" in err + ? String((err as { stderr: Buffer }).stderr).trim() + : String(err); + console.error(`Error: git clone failed: ${stderr}`); + process.exit(1); + } + + if (subpath) { + const source = resolve(join(repoDir, subpath)); + if (!source.startsWith(resolve(repoDir))) { + rmSync(tmp, { recursive: true, force: true }); + console.error(`Error: subpath escapes repository: ${subpath}`); + process.exit(1); + } + if (!existsSync(source) || !statSync(source).isDirectory()) { + rmSync(tmp, { recursive: true, force: true }); + console.error(`Error: subpath '${subpath}' not found in repository`); + process.exit(1); + } + if (!existsSync(join(source, PLUGIN_JSON))) { + rmSync(tmp, { recursive: true, force: true }); + console.error(`Error: no plugin.json in '${subpath}'`); + process.exit(1); + } + return { sourceDir: source, tmpDir: tmp }; + } + + // No subpath — check root first + if (existsSync(join(repoDir, PLUGIN_JSON))) { + return { sourceDir: repoDir, tmpDir: tmp }; + } + + // Scan one level for available plugins + const available = readdirSync(repoDir) + .filter((d) => { + try { + return ( + statSync(join(repoDir, d)).isDirectory() && + existsSync(join(repoDir, d, PLUGIN_JSON)) + ); + } catch { + return false; + } + }) + .sort(); + + if (available.length > 0) { + const names = available.map((n) => ` - ${n}`).join("\n"); + console.error( + `Error: No plugin.json at repository root. Available plugins:\n${names}\n` + + `Use: kimi plugin install /`, + ); + } else { + console.error("Error: No plugin.json found in repository"); + } + rmSync(tmp, { recursive: true, force: true }); + process.exit(1); + } + + const p = resolve(target); + + // --- Zip file --- + if (existsSync(p) && statSync(p).isFile() && p.endsWith(".zip")) { + const tmp = mkdtempSync(join(tmpdir(), "kimi-plugin-")); + console.log(`Extracting ${target}...`); + + try { + execSync(`unzip -q "${p}" -d "${tmp}"`, { stdio: "pipe" }); + } catch (err) { + rmSync(tmp, { recursive: true, force: true }); + console.error(`Error: failed to extract zip: ${err}`); + process.exit(1); + } + + // Find directory containing plugin.json (may be nested one level) + const candidates = [ + tmp, + ...readdirSync(tmp) + .sort() + .map((d) => join(tmp, d)) + .filter((d) => { + try { + return statSync(d).isDirectory(); + } catch { + return false; + } + }), + ]; + + for (const candidate of candidates) { + if (existsSync(join(candidate, PLUGIN_JSON))) { + return { sourceDir: candidate, tmpDir: tmp }; + } + } + + // Check for __MACOSX and similar artifacts + const dirs = readdirSync(tmp) + .filter((d) => !d.startsWith("_")) + .map((d) => join(tmp, d)) + .filter((d) => { + try { + return statSync(d).isDirectory(); + } catch { + return false; + } + }); + + if (dirs.length === 1 && existsSync(join(dirs[0]!, PLUGIN_JSON))) { + return { sourceDir: dirs[0]!, tmpDir: tmp }; + } + + rmSync(tmp, { recursive: true, force: true }); + console.error("Error: No plugin.json found in zip"); + process.exit(1); + } + + // --- Local directory --- + if (existsSync(p) && statSync(p).isDirectory()) { + return { sourceDir: p, tmpDir: null }; + } + + console.error( + `Error: ${target} is not a directory, zip file, or git URL`, + ); + process.exit(1); +} + +// ── Command ────────────────────────────────────────────── + +export const pluginCommand = new Command("plugin").description( + "Manage plugins.", +); + +pluginCommand + .command("install") + .description("Install a plugin and inject host configuration.") + .argument("", "Plugin source: directory, .zip, or git URL") + .action(async (target: string) => { + const { sourceDir, tmpDir } = resolveSource(target); + + try { + const { loadConfig } = await import("../config.ts"); + const { VERSION } = await import("../constant.ts"); + + const { config } = await loadConfig(); + + // Collect host values for config injection + let hostValues: Record = {}; + try { + const { OAuthManager } = await import("../auth/oauth.ts"); + const oauth = new OAuthManager(config); + hostValues = collectHostValues( + config as Parameters[0], + oauth, + ); + } catch { + // OAuth may not be available + } + + if (!hostValues["api_key"]) { + console.error( + "Warning: No LLM provider configured. " + + "Plugins requiring API key injection will fail. " + + "Run 'kimi login' or configure a provider first.", + ); + } + + const spec = installPlugin({ + source: sourceDir, + pluginsDir: getPluginsDir(), + hostValues, + hostName: "kimi-code", + hostVersion: VERSION, + }); + + console.log(`Installed plugin '${spec.name}' v${spec.version}`); + if (spec.runtime) { + console.log( + ` runtime: host=${spec.runtime.host}, version=${spec.runtime.hostVersion}`, + ); + } + } catch (err) { + if (err instanceof PluginError) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + throw err; + } finally { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + } + }); + +pluginCommand + .command("list") + .description("List installed plugins.") + .action(() => { + const plugins = listPlugins(getPluginsDir()); + if (plugins.length === 0) { + console.log("No plugins installed."); + return; + } + for (const p of plugins) { + const status = p.runtime ? "installed" : "not configured"; + console.log(` ${p.name} v${p.version} (${status})`); + } + }); + +pluginCommand + .command("remove") + .description("Remove an installed plugin.") + .argument("", "Plugin name to remove") + .action((name: string) => { + try { + removePlugin(name, getPluginsDir()); + } catch (err) { + if (err instanceof PluginError) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + throw err; + } + console.log(`Removed plugin '${name}'`); + }); + +pluginCommand + .command("info") + .description("Show plugin details.") + .argument("", "Plugin name") + .action((name: string) => { + const pluginJson = join(getPluginsDir(), name, PLUGIN_JSON); + if (!existsSync(pluginJson)) { + console.error(`Error: Plugin '${name}' not found`); + process.exit(1); + } + + let spec; + try { + spec = parsePluginJson(pluginJson); + } catch (err) { + if (err instanceof PluginError) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + throw err; + } + + console.log(`Name: ${spec.name}`); + console.log(`Version: ${spec.version}`); + console.log(`Description: ${spec.description || "(none)"}`); + console.log(`Config file: ${spec.configFile || "(none)"}`); + + if (Object.keys(spec.inject).length > 0) { + const pairs = Object.entries(spec.inject) + .map(([k, v]) => `${k} <- ${v}`) + .join(", "); + console.log(`Inject: ${pairs}`); + } + + if (spec.runtime) { + console.log( + `Runtime: host=${spec.runtime.host}, version=${spec.runtime.hostVersion}`, + ); + } else { + console.log("Runtime: (not installed via host)"); + } + }); diff --git a/src/kimi_cli_ts/cli/vis.ts b/src/kimi_cli_ts/cli/vis.ts new file mode 100644 index 000000000..184c845bd --- /dev/null +++ b/src/kimi_cli_ts/cli/vis.ts @@ -0,0 +1,33 @@ +/** + * Vis command — corresponds to Python cli/vis.py + * Launches the Kimi Agent Tracing Visualizer. + */ + +import { Command } from "commander"; + +export const visCommand = new Command("vis") + .description("Run Kimi Agent Tracing Visualizer.") + .option("-h, --host ", "Bind to specific IP address") + .option("-n, --network", "Enable network access (bind to 0.0.0.0)") + .option("-p, --port ", "Port to bind to", "5495") + .option("--open", "Open browser automatically (default)", true) + .option("--no-open", "Don't open browser automatically") + .option("--reload", "Enable auto-reload") + .action( + (options: { + host?: string; + network?: boolean; + port: string; + open: boolean; + reload?: boolean; + }) => { + const bindHost = options.host ?? (options.network ? "0.0.0.0" : "127.0.0.1"); + const port = parseInt(options.port, 10); + + // TODO: Implement vis server when vis/app.ts is available + console.log( + `Kimi Agent Tracing Visualizer would start at ${bindHost}:${port}`, + ); + console.log("(Not yet implemented in TypeScript)"); + }, + ); diff --git a/src/kimi_cli_ts/cli/web.ts b/src/kimi_cli_ts/cli/web.ts new file mode 100644 index 000000000..c8f990842 --- /dev/null +++ b/src/kimi_cli_ts/cli/web.ts @@ -0,0 +1,52 @@ +/** + * Web command — corresponds to Python cli/web.py + * Launches the Kimi Code CLI web interface. + */ + +import { Command } from "commander"; + +export const webCommand = new Command("web") + .description("Run Kimi Code CLI web interface.") + .option("-h, --host ", "Bind to specific IP address") + .option("-n, --network", "Enable network access (bind to 0.0.0.0)") + .option("-p, --port ", "Port to bind to", "5494") + .option("--open", "Open browser automatically (default)", true) + .option("--no-open", "Don't open browser automatically") + .option("--reload", "Enable auto-reload") + .option("--auth-token ", "Bearer token for API authentication") + .option("--allowed-origins ", "Comma-separated list of allowed Origin values") + .option( + "--dangerously-omit-auth", + "Disable auth checks (dangerous in public networks)", + ) + .option( + "--restrict-sensitive-apis", + "Disable sensitive APIs (config write, open-in, file access limits)", + ) + .option("--no-restrict-sensitive-apis", "Allow sensitive APIs") + .option("--lan-only", "Only allow access from local network (default)", true) + .option("--public", "Allow public access") + .action( + (options: { + host?: string; + network?: boolean; + port: string; + open: boolean; + reload?: boolean; + authToken?: string; + allowedOrigins?: string; + dangerouslyOmitAuth?: boolean; + restrictSensitiveApis?: boolean; + lanOnly?: boolean; + public?: boolean; + }) => { + const bindHost = options.host ?? (options.network ? "0.0.0.0" : "127.0.0.1"); + const port = parseInt(options.port, 10); + const lanOnly = options.public ? false : (options.lanOnly ?? true); + + // TODO: Implement web server when web/app.ts is available + console.log(`Kimi Code CLI web interface would start at ${bindHost}:${port}`); + console.log(` LAN only: ${lanOnly}`); + console.log("(Not yet implemented in TypeScript)"); + }, + ); diff --git a/src/kimi_cli_ts/config.ts b/src/kimi_cli_ts/config.ts new file mode 100644 index 000000000..cc6398e64 --- /dev/null +++ b/src/kimi_cli_ts/config.ts @@ -0,0 +1,305 @@ +/** + * Configuration module — corresponds to Python config.py + * Loads/saves TOML config with Zod validation. + */ + +import { z } from "zod/v4"; +import TOML from "@iarna/toml"; +import { ModelCapability } from "./types.ts"; + +// ── Sub-schemas ───────────────────────────────────────── + +export const OAuthRef = z.object({ + storage: z.enum(["keyring", "file"]).default("file"), + key: z.string(), +}); +export type OAuthRef = z.infer; + +export const ProviderType = z.enum([ + "kimi", + "openai_legacy", + "openai_responses", + "anthropic", + "google_genai", + "gemini", + "vertexai", + "_echo", + "_scripted_echo", + "_chaos", +]); +export type ProviderType = z.infer; + +export const LLMProvider = z.object({ + type: ProviderType, + base_url: z.string(), + api_key: z.string(), + env: z.record(z.string(), z.string()).optional(), + custom_headers: z.record(z.string(), z.string()).optional(), + oauth: OAuthRef.optional(), +}); +export type LLMProvider = z.infer; + +export const LLMModel = z.object({ + provider: z.string(), + model: z.string(), + max_context_size: z.number().int(), + capabilities: z.array(ModelCapability).optional(), +}); +export type LLMModel = z.infer; + +export const LoopControl = z.object({ + max_steps_per_turn: z.number().int().min(1).default(100), + max_retries_per_step: z.number().int().min(1).default(3), + max_ralph_iterations: z.number().int().min(-1).default(0), + reserved_context_size: z.number().int().min(1000).default(50_000), + compaction_trigger_ratio: z.number().min(0.5).max(0.99).default(0.85), +}); +export type LoopControl = z.infer; + +export const BackgroundConfig = z.object({ + max_running_tasks: z.number().int().min(1).default(4), + read_max_bytes: z.number().int().min(1024).default(30_000), + notification_tail_lines: z.number().int().min(1).default(20), + notification_tail_chars: z.number().int().min(256).default(3_000), + wait_poll_interval_ms: z.number().int().min(50).default(500), + worker_heartbeat_interval_ms: z.number().int().min(100).default(5_000), + worker_stale_after_ms: z.number().int().min(1000).default(15_000), + kill_grace_period_ms: z.number().int().min(100).default(2_000), + keep_alive_on_exit: z.boolean().default(false), + agent_task_timeout_s: z.number().int().min(60).default(900), +}); +export type BackgroundConfig = z.infer; + +export const NotificationConfig = z.object({ + claim_stale_after_ms: z.number().int().min(1000).default(15_000), +}); +export type NotificationConfig = z.infer; + +export const MoonshotSearchConfig = z.object({ + base_url: z.string(), + api_key: z.string(), + custom_headers: z.record(z.string(), z.string()).optional(), + oauth: OAuthRef.optional(), +}); +export type MoonshotSearchConfig = z.infer; + +export const MoonshotFetchConfig = z.object({ + base_url: z.string(), + api_key: z.string(), + custom_headers: z.record(z.string(), z.string()).optional(), + oauth: OAuthRef.optional(), +}); +export type MoonshotFetchConfig = z.infer; + +export const Services = z.object({ + moonshot_search: MoonshotSearchConfig.optional(), + moonshot_fetch: MoonshotFetchConfig.optional(), +}); +export type Services = z.infer; + +export const MCPClientConfig = z.object({ + tool_call_timeout_ms: z.number().int().default(60000), +}); +export type MCPClientConfig = z.infer; + +export const MCPConfig = z.object({ + client: MCPClientConfig.default({} as any), +}); +export type MCPConfig = z.infer; + +export const HookEventType = z.enum([ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "UserPromptSubmit", + "Stop", + "StopFailure", + "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", +]); +export type HookEventType = z.infer; + +export const HookDef = z.object({ + event: HookEventType, + command: z.string(), + matcher: z.string().default(""), + timeout: z.number().int().min(1).max(600).default(30), +}); +export type HookDef = z.infer; + +export const Config = z + .object({ + default_model: z.string().default(""), + default_thinking: z.boolean().default(false), + default_yolo: z.boolean().default(false), + default_plan_mode: z.boolean().default(false), + default_editor: z.string().default(""), + theme: z.enum(["dark", "light"]).default("dark"), + models: z.record(z.string(), LLMModel).default({}), + providers: z.record(z.string(), LLMProvider).default({}), + loop_control: LoopControl.default({} as any), + background: BackgroundConfig.default({} as any), + notifications: NotificationConfig.default({} as any), + services: Services.default({}), + mcp: MCPConfig.default({} as any), + hooks: z.array(HookDef).default([]), + merge_all_available_skills: z.boolean().default(false), + }) + .refine( + (cfg) => { + if (cfg.default_model && !(cfg.default_model in cfg.models)) return false; + for (const m of Object.values(cfg.models) as LLMModel[]) { + if (!(m.provider in cfg.providers)) return false; + } + return true; + }, + { message: "default_model must exist in models, and all model providers must exist in providers" }, + ); + +export type Config = z.infer; + +/** Runtime metadata attached after loading (not persisted). */ +export interface ConfigMeta { + isFromDefaultLocation: boolean; + sourceFile: string | null; +} + +// ── Paths ─────────────────────────────────────────────── + +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { ConfigError } from "./exception.ts"; + +export { ConfigError }; + +export function getShareDir(): string { + return process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi"); +} + +export function getConfigFile(): string { + return join(getShareDir(), "config.toml"); +} + +// ── Secret masking helper ──────────────────────────────── + +/** Mask a secret string for safe logging (shows first 4 chars + ***). */ +export function maskSecret(value: string): string { + if (!value || value.length <= 4) return "***"; + return value.slice(0, 4) + "***"; +} + +// ── JSON → TOML migration ─────────────────────────────── + +async function migrateJsonConfigToToml(): Promise { + const oldJsonConfigFile = join(getShareDir(), "config.json"); + const newTomlConfigFile = join(getShareDir(), "config.toml"); + + const oldFile = Bun.file(oldJsonConfigFile); + const newFile = Bun.file(newTomlConfigFile); + if (!(await oldFile.exists())) return; + if (await newFile.exists()) return; + + try { + const data = await oldFile.json(); + const config = Config.parse(data); + await saveConfig(config, newTomlConfigFile); + // Backup old file + const backupPath = oldJsonConfigFile.replace(/\.json$/, ".json.bak"); + await Bun.$`mv ${oldJsonConfigFile} ${backupPath}`.quiet(); + } catch (err) { + // If migration fails, continue with default config + } +} + +export function getDefaultConfig(): Config { + return Config.parse({}); +} + +export async function loadConfig( + configFile?: string, +): Promise<{ config: Config; meta: ConfigMeta }> { + const defaultConfigFile = resolve(getConfigFile()); + const resolvedPath = configFile ? resolve(configFile) : defaultConfigFile; + const isDefault = resolvedPath === defaultConfigFile; + + // If using default config and it doesn't exist, try migrating from JSON + if (isDefault) { + const file = Bun.file(resolvedPath); + if (!(await file.exists())) { + await migrateJsonConfigToToml(); + } + } + + const file = Bun.file(resolvedPath); + if (!(await file.exists())) { + const config = getDefaultConfig(); + await saveConfig(config, resolvedPath); + return { config, meta: { isFromDefaultLocation: isDefault, sourceFile: resolvedPath } }; + } + + try { + const text = await file.text(); + let data: unknown; + if (resolvedPath.toLowerCase().endsWith(".json")) { + data = JSON.parse(text); + } else { + const rawData = TOML.parse(text); + // @iarna/toml adds Symbol properties that break Zod validation — strip them via JSON roundtrip + data = JSON.parse(JSON.stringify(rawData)); + } + const config = Config.parse(data); + + // Environment variable overrides + if (process.env.KIMI_MODEL_NAME) config.default_model = process.env.KIMI_MODEL_NAME; + + return { config, meta: { isFromDefaultLocation: isDefault, sourceFile: resolvedPath } }; + } catch (err) { + if (err instanceof z.ZodError) { + throw new ConfigError(`Invalid configuration file ${resolvedPath}: ${err.message}`); + } + throw new ConfigError(`Failed to parse configuration file ${resolvedPath}: ${err}`); + } +} + +export async function loadConfigFromString(text: string): Promise<{ config: Config; meta: ConfigMeta }> { + if (!text.trim()) throw new ConfigError("Configuration text cannot be empty"); + + let data: unknown; + try { + data = JSON.parse(text); + } catch { + try { + data = TOML.parse(text); + } catch (tomlErr) { + throw new ConfigError(`Invalid configuration text: ${tomlErr}`); + } + } + + try { + const config = Config.parse(data); + return { config, meta: { isFromDefaultLocation: false, sourceFile: null } }; + } catch (err) { + throw new ConfigError(`Invalid configuration text: ${err}`); + } +} + +export async function saveConfig(config: Config, configFile?: string): Promise { + const filePath = configFile ?? getConfigFile(); + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + await Bun.$`mkdir -p ${dir}`.quiet(); + + // Strip undefined/null values for clean output + const data = JSON.parse(JSON.stringify(config)); + + if (filePath.toLowerCase().endsWith(".json")) { + await Bun.write(filePath, JSON.stringify(data, null, 2)); + } else { + const tomlStr = TOML.stringify(data as any); + await Bun.write(filePath, tomlStr); + } +} diff --git a/src/kimi_cli_ts/constant.ts b/src/kimi_cli_ts/constant.ts new file mode 100644 index 000000000..57847f2c9 --- /dev/null +++ b/src/kimi_cli_ts/constant.ts @@ -0,0 +1,30 @@ +/** + * Constants module — corresponds to Python constant.py + * Exports NAME, VERSION, USER_AGENT, and helper functions. + */ + +import { join } from "node:path"; + +export const NAME = "Kimi Code CLI"; + +let _version: string | null = null; + +export function getVersion(): string { + if (_version) return _version; + try { + // Read version from package.json at build/runtime + const pkgPath = join(import.meta.dir, "../../../package.json"); + const pkg = require(pkgPath); + _version = String(pkg.version ?? "0.0.0"); + } catch { + _version = "0.0.0"; + } + return _version; +} + +export function getUserAgent(): string { + return `KimiCLI/${getVersion()}`; +} + +export const VERSION: string = getVersion(); +export const USER_AGENT: string = getUserAgent(); diff --git a/src/kimi_cli_ts/exception.ts b/src/kimi_cli_ts/exception.ts new file mode 100644 index 000000000..a158b9d15 --- /dev/null +++ b/src/kimi_cli_ts/exception.ts @@ -0,0 +1,60 @@ +/** + * Exception hierarchy — corresponds to Python exception.py + * All custom error classes for kimi-cli. + */ + +/** Base exception class for Kimi Code CLI. */ +export class KimiCLIException extends Error { + constructor(message: string) { + super(message); + this.name = "KimiCLIException"; + } +} + +/** Configuration error. */ +export class ConfigError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +/** Agent specification error. */ +export class AgentSpecError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "AgentSpecError"; + } +} + +/** Invalid tool error. */ +export class InvalidToolError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "InvalidToolError"; + } +} + +/** System prompt template error. */ +export class SystemPromptTemplateError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "SystemPromptTemplateError"; + } +} + +/** MCP config error. */ +export class MCPConfigError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "MCPConfigError"; + } +} + +/** MCP runtime error. */ +export class MCPRuntimeError extends KimiCLIException { + constructor(message: string) { + super(message); + this.name = "MCPRuntimeError"; + } +} diff --git a/src/kimi_cli_ts/hooks/config.ts b/src/kimi_cli_ts/hooks/config.ts new file mode 100644 index 000000000..54b103728 --- /dev/null +++ b/src/kimi_cli_ts/hooks/config.ts @@ -0,0 +1,23 @@ +/** + * Hook configuration — corresponds to Python hooks/config.py + * HookDef and HookEventType are already defined in config.ts, + * re-exported here for convenience. + */ + +export { HookDef, HookEventType } from "../config.ts"; + +export const HOOK_EVENT_TYPES: string[] = [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "UserPromptSubmit", + "Stop", + "StopFailure", + "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "Notification", +]; diff --git a/src/kimi_cli_ts/hooks/engine.ts b/src/kimi_cli_ts/hooks/engine.ts new file mode 100644 index 000000000..826327292 --- /dev/null +++ b/src/kimi_cli_ts/hooks/engine.ts @@ -0,0 +1,352 @@ +/** + * Hook engine — corresponds to Python hooks/engine.py + * Runs matching hooks (shell commands) in parallel on lifecycle events. + */ + +import type { HookDef, HookEventType } from "./config.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Types ─────────────────────────────────────────────── + +export interface HookResult { + action: "allow" | "block"; + reason: string; + stdout?: string; + stderr?: string; + exitCode?: number; + timedOut?: boolean; +} + +export interface WireHookSubscription { + id: string; + event: string; + matcher: string; + timeout: number; +} + +export type OnTriggered = (event: string, target: string, hookCount: number) => void; +export type OnResolved = (event: string, target: string, action: string, reason: string, durationMs: number) => void; +export type OnWireHookRequest = (handle: WireHookHandle) => Promise; + +// ── Wire hook handle ──────────────────────────────────── + +let _handleIdCounter = 0; + +export class WireHookHandle { + readonly id: string; + readonly subscriptionId: string; + readonly event: string; + readonly target: string; + readonly inputData: Record; + + private _resolve?: (result: HookResult) => void; + private _promise: Promise; + + constructor(opts: { + subscriptionId: string; + event: string; + target: string; + inputData: Record; + }) { + this.id = `wh${(++_handleIdCounter).toString(36)}`; + this.subscriptionId = opts.subscriptionId; + this.event = opts.event; + this.target = opts.target; + this.inputData = opts.inputData; + this._promise = new Promise((resolve) => { + this._resolve = resolve; + }); + } + + wait(): Promise { + return this._promise; + } + + resolve(action: "allow" | "block" = "allow", reason = ""): void { + this._resolve?.({ action, reason }); + } +} + +// ── Hook runner ───────────────────────────────────────── + +async function runHook( + command: string, + inputData: Record, + opts?: { timeout?: number; cwd?: string }, +): Promise { + const timeout = (opts?.timeout ?? 30) * 1000; + try { + const proc = Bun.spawn(["sh", "-c", command], { + stdin: new Blob([JSON.stringify(inputData)]), + stdout: "pipe", + stderr: "pipe", + cwd: opts?.cwd, + }); + + const timer = setTimeout(() => proc.kill(), timeout); + + const exitCode = await proc.exited; + clearTimeout(timer); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + // Exit 2 = block + if (exitCode === 2) { + return { + action: "block", + reason: stderr.trim(), + stdout, + stderr, + exitCode: 2, + }; + } + + // Exit 0 + JSON stdout = structured decision + if (exitCode === 0 && stdout.trim()) { + try { + const parsed = JSON.parse(stdout.trim()); + if (parsed && typeof parsed === "object") { + // Direct action field (e.g. {"action":"block","reason":"..."}) + if (parsed.action === "block") { + return { + action: "block", + reason: String(parsed.reason ?? ""), + stdout, + stderr, + exitCode: 0, + }; + } + // Claude Code-style hookSpecificOutput + const hookOutput = parsed.hookSpecificOutput; + if (hookOutput?.permissionDecision === "deny") { + return { + action: "block", + reason: String(hookOutput.permissionDecisionReason ?? ""), + stdout, + stderr, + exitCode: 0, + }; + } + } + } catch { + // Not JSON — that's fine + } + } + + return { action: "allow", reason: "", stdout, stderr, exitCode: exitCode ?? 0 }; + } catch { + return { action: "allow", reason: "" }; + } +} + +// ── Engine ────────────────────────────────────────────── + +export class HookEngine { + private hooks: HookDef[]; + private wireSubs: WireHookSubscription[] = []; + private cwd?: string; + private onTriggered?: OnTriggered; + private onResolved?: OnResolved; + private onWireHook?: OnWireHookRequest; + private byEvent = new Map(); + private wireByEvent = new Map(); + + constructor(opts?: { + hooks?: HookDef[]; + cwd?: string; + onTriggered?: OnTriggered; + onResolved?: OnResolved; + onWireHook?: OnWireHookRequest; + }) { + this.hooks = opts?.hooks ? [...opts.hooks] : []; + this.cwd = opts?.cwd; + this.onTriggered = opts?.onTriggered; + this.onResolved = opts?.onResolved; + this.onWireHook = opts?.onWireHook; + this.rebuildIndex(); + } + + private rebuildIndex(): void { + this.byEvent.clear(); + for (const h of this.hooks) { + const list = this.byEvent.get(h.event) ?? []; + list.push(h); + this.byEvent.set(h.event, list); + } + this.wireByEvent.clear(); + for (const s of this.wireSubs) { + const list = this.wireByEvent.get(s.event) ?? []; + list.push(s); + this.wireByEvent.set(s.event, list); + } + } + + addHooks(hooks: HookDef[]): void { + this.hooks.push(...hooks); + this.rebuildIndex(); + } + + addWireSubscriptions(subs: WireHookSubscription[]): void { + this.wireSubs.push(...subs); + this.rebuildIndex(); + } + + setCallbacks(opts: { onTriggered?: OnTriggered; onResolved?: OnResolved; onWireHook?: OnWireHookRequest }): void { + this.onTriggered = opts.onTriggered; + this.onResolved = opts.onResolved; + this.onWireHook = opts.onWireHook; + } + + get hasHooks(): boolean { + return this.hooks.length > 0 || this.wireSubs.length > 0; + } + + hasHooksFor(event: HookEventType): boolean { + return (this.byEvent.get(event)?.length ?? 0) > 0 || (this.wireByEvent.get(event)?.length ?? 0) > 0; + } + + get summary(): Record { + const counts: Record = {}; + for (const [event, hooks] of this.byEvent) { + counts[event] = (counts[event] ?? 0) + hooks.length; + } + for (const [event, subs] of this.wireByEvent) { + counts[event] = (counts[event] ?? 0) + subs.length; + } + return counts; + } + + private matchRegex(pattern: string, value: string): boolean { + if (!pattern) return true; + try { + return new RegExp(pattern).test(value); + } catch { + logger.warn(`Invalid regex in hook matcher: ${pattern}`); + return false; + } + } + + async trigger( + event: HookEventType, + opts: { matcherValue?: string; inputData: Record }, + ): Promise { + const matcherValue = opts.matcherValue ?? ""; + + // Match server-side hooks + const seenCommands = new Set(); + const serverMatched: HookDef[] = []; + for (const h of this.byEvent.get(event) ?? []) { + if (!this.matchRegex(h.matcher, matcherValue)) continue; + if (seenCommands.has(h.command)) continue; + seenCommands.add(h.command); + serverMatched.push(h); + } + + // Match wire subscriptions + const wireMatched: WireHookSubscription[] = []; + for (const s of this.wireByEvent.get(event) ?? []) { + if (!this.matchRegex(s.matcher, matcherValue)) continue; + wireMatched.push(s); + } + + const total = serverMatched.length + wireMatched.length; + if (total === 0) return []; + + try { + return await this.executeHooks(event, matcherValue, serverMatched, wireMatched, opts.inputData); + } catch { + logger.warn(`Hook engine error for ${event}, failing open`); + return []; + } + } + + private async executeHooks( + event: string, + matcherValue: string, + serverMatched: HookDef[], + wireMatched: WireHookSubscription[], + inputData: Record, + ): Promise { + const total = serverMatched.length + wireMatched.length; + + if (this.onTriggered) { + try { + this.onTriggered(event, matcherValue, total); + } catch { + // ignore + } + } + + const t0 = performance.now(); + + // Server-side: run shell commands + const tasks: Promise[] = serverMatched.map((h) => + runHook(h.command, inputData, { timeout: h.timeout, cwd: this.cwd }), + ); + + // Wire-side: dispatch to client + for (const s of wireMatched) { + tasks.push(this.dispatchWireHook(s.id, event, matcherValue, inputData, s.timeout)); + } + + const results = await Promise.all(tasks); + const durationMs = Math.round(performance.now() - t0); + + let action = "allow"; + let reason = ""; + for (const r of results) { + if (r.action === "block") { + action = "block"; + reason = r.reason; + break; + } + } + + if (this.onResolved) { + try { + this.onResolved(event, matcherValue, action, reason, durationMs); + } catch { + // ignore + } + } + + return results; + } + + private async dispatchWireHook( + subscriptionId: string, + event: string, + target: string, + inputData: Record, + timeout: number = 30, + ): Promise { + if (!this.onWireHook) { + return { action: "allow", reason: "" }; + } + + const handle = new WireHookHandle({ + subscriptionId, + event, + target, + inputData, + }); + + const hookPromise = this.onWireHook(handle); + hookPromise.catch(() => {}); // Suppress unhandled rejection + + try { + const timeoutMs = timeout * 1000; + const result = await Promise.race([ + handle.wait(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutMs), + ), + ]); + return result; + } catch { + logger.warn(`Wire hook timed out: ${event} ${target}`); + return { action: "allow", reason: "", timedOut: true }; + } + } +} diff --git a/src/kimi_cli_ts/hooks/events.ts b/src/kimi_cli_ts/hooks/events.ts new file mode 100644 index 000000000..c836c2655 --- /dev/null +++ b/src/kimi_cli_ts/hooks/events.ts @@ -0,0 +1,176 @@ +/** + * Hook event payload builders — corresponds to Python hooks/events.py + * Each function returns a payload dict for a specific hook event. + */ + +function _base(event: string, sessionId: string, cwd: string): Record { + return { hook_event_name: event, session_id: sessionId, cwd }; +} + +export function preToolUse(opts: { + sessionId: string; + cwd: string; + toolName: string; + toolInput: Record; + toolCallId?: string; +}): Record { + return { + ..._base("PreToolUse", opts.sessionId, opts.cwd), + tool_name: opts.toolName, + tool_input: opts.toolInput, + tool_call_id: opts.toolCallId ?? "", + }; +} + +export function postToolUse(opts: { + sessionId: string; + cwd: string; + toolName: string; + toolInput: Record; + toolOutput?: string; + toolCallId?: string; +}): Record { + return { + ..._base("PostToolUse", opts.sessionId, opts.cwd), + tool_name: opts.toolName, + tool_input: opts.toolInput, + tool_output: opts.toolOutput ?? "", + tool_call_id: opts.toolCallId ?? "", + }; +} + +export function postToolUseFailure(opts: { + sessionId: string; + cwd: string; + toolName: string; + toolInput: Record; + error: string; + toolCallId?: string; +}): Record { + return { + ..._base("PostToolUseFailure", opts.sessionId, opts.cwd), + tool_name: opts.toolName, + tool_input: opts.toolInput, + error: opts.error, + tool_call_id: opts.toolCallId ?? "", + }; +} + +export function userPromptSubmit(opts: { + sessionId: string; + cwd: string; + prompt: string; +}): Record { + return { ..._base("UserPromptSubmit", opts.sessionId, opts.cwd), prompt: opts.prompt }; +} + +export function stop(opts: { + sessionId: string; + cwd: string; + stopHookActive?: boolean; +}): Record { + return { + ..._base("Stop", opts.sessionId, opts.cwd), + stop_hook_active: opts.stopHookActive ?? false, + }; +} + +export function stopFailure(opts: { + sessionId: string; + cwd: string; + errorType: string; + errorMessage: string; +}): Record { + return { + ..._base("StopFailure", opts.sessionId, opts.cwd), + error_type: opts.errorType, + error_message: opts.errorMessage, + }; +} + +export function sessionStart(opts: { + sessionId: string; + cwd: string; + source: string; +}): Record { + return { ..._base("SessionStart", opts.sessionId, opts.cwd), source: opts.source }; +} + +export function sessionEnd(opts: { + sessionId: string; + cwd: string; + reason: string; +}): Record { + return { ..._base("SessionEnd", opts.sessionId, opts.cwd), reason: opts.reason }; +} + +export function subagentStart(opts: { + sessionId: string; + cwd: string; + agentName: string; + prompt: string; +}): Record { + return { + ..._base("SubagentStart", opts.sessionId, opts.cwd), + agent_name: opts.agentName, + prompt: opts.prompt, + }; +} + +export function subagentStop(opts: { + sessionId: string; + cwd: string; + agentName: string; + response?: string; +}): Record { + return { + ..._base("SubagentStop", opts.sessionId, opts.cwd), + agent_name: opts.agentName, + response: opts.response ?? "", + }; +} + +export function preCompact(opts: { + sessionId: string; + cwd: string; + trigger: string; + tokenCount: number; +}): Record { + return { + ..._base("PreCompact", opts.sessionId, opts.cwd), + trigger: opts.trigger, + token_count: opts.tokenCount, + }; +} + +export function postCompact(opts: { + sessionId: string; + cwd: string; + trigger: string; + estimatedTokenCount: number; +}): Record { + return { + ..._base("PostCompact", opts.sessionId, opts.cwd), + trigger: opts.trigger, + estimated_token_count: opts.estimatedTokenCount, + }; +} + +export function notification(opts: { + sessionId: string; + cwd: string; + sink: string; + notificationType: string; + title?: string; + body?: string; + severity?: string; +}): Record { + return { + ..._base("Notification", opts.sessionId, opts.cwd), + sink: opts.sink, + notification_type: opts.notificationType, + title: opts.title ?? "", + body: opts.body ?? "", + severity: opts.severity ?? "info", + }; +} diff --git a/src/kimi_cli_ts/index.ts b/src/kimi_cli_ts/index.ts new file mode 100644 index 000000000..c5bfcbe9b --- /dev/null +++ b/src/kimi_cli_ts/index.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env bun +/** + * Kimi CLI - AI Agent for Terminal + * Entry point (corresponds to Python __main__.py) + */ + +import { cli } from "./cli/index.ts"; + +const exitCode = await cli(process.argv); +process.exit(exitCode); diff --git a/src/kimi_cli_ts/llm.ts b/src/kimi_cli_ts/llm.ts new file mode 100644 index 000000000..b75460aa7 --- /dev/null +++ b/src/kimi_cli_ts/llm.ts @@ -0,0 +1,722 @@ +/** + * LLM abstraction layer — corresponds to Python's llm.py + * Provides a unified interface for multiple LLM providers. + */ + +import type { Message, ModelCapability, TokenUsage } from "./types"; + +// ── Provider Types ───────────────────────────────────────── + +export type ProviderType = + | "kimi" + | "openai_legacy" + | "openai_responses" + | "anthropic" + | "google_genai" + | "gemini" + | "vertexai" + | "_echo" + | "_scripted_echo" + | "_chaos"; + +// ── Stream Chunk Types ───────────────────────────────────── + +export interface TextChunk { + type: "text"; + text: string; +} + +export interface ThinkChunk { + type: "think"; + text: string; +} + +export interface ToolCallChunk { + type: "tool_call"; + id: string; + name: string; + arguments: string; +} + +export interface UsageChunk { + type: "usage"; + usage: TokenUsage; +} + +export interface DoneChunk { + type: "done"; + messageId?: string; +} + +export type StreamChunk = + | TextChunk + | ThinkChunk + | ToolCallChunk + | UsageChunk + | DoneChunk; + +// ── LLM Provider Interface ──────────────────────────────── + +export interface LLMProviderConfig { + type: ProviderType; + baseUrl: string; + apiKey: string; + customHeaders?: Record; + env?: Record; + oauth?: string | null; +} + +export interface LLMModelConfig { + model: string; + provider: string; + maxContextSize: number; + capabilities?: ModelCapability[]; +} + +export interface ChatOptions { + /** System prompt */ + system?: string; + /** Generation temperature */ + temperature?: number; + /** Top-p nucleus sampling */ + topP?: number; + /** Maximum output tokens */ + maxTokens?: number; + /** Enable/disable thinking */ + thinking?: "high" | "low" | "off"; + /** Tool definitions for the model */ + tools?: ToolDefinition[]; + /** Abort signal for cancellation */ + signal?: AbortSignal; +} + +export interface ToolDefinition { + name: string; + description: string; + parameters: Record; +} + +/** + * Abstract interface for LLM providers. + * Each provider (Anthropic, OpenAI, Kimi, etc.) implements this. + */ +export interface LLMProvider { + readonly modelName: string; + + /** + * Send a chat completion request and return a stream of chunks. + */ + chat( + messages: Message[], + options?: ChatOptions + ): AsyncIterable; +} + +// ── LLM Class ────────────────────────────────────────────── + +/** + * Wraps an LLM provider with model capabilities and context limits. + */ +export class LLM { + readonly provider: LLMProvider; + readonly maxContextSize: number; + readonly capabilities: Set; + readonly modelConfig: LLMModelConfig | null; + readonly providerConfig: LLMProviderConfig | null; + + constructor(opts: { + provider: LLMProvider; + maxContextSize: number; + capabilities: Set; + modelConfig?: LLMModelConfig | null; + providerConfig?: LLMProviderConfig | null; + }) { + this.provider = opts.provider; + this.maxContextSize = opts.maxContextSize; + this.capabilities = opts.capabilities; + this.modelConfig = opts.modelConfig ?? null; + this.providerConfig = opts.providerConfig ?? null; + } + + get modelName(): string { + return this.provider.modelName; + } + + /** + * Check if the model has a specific capability. + */ + hasCapability(cap: ModelCapability): boolean { + return this.capabilities.has(cap); + } + + /** + * Stream a chat completion. + */ + chat( + messages: Message[], + options?: ChatOptions + ): AsyncIterable { + return this.provider.chat(messages, options); + } +} + +// ── Model Display Name ───────────────────────────────────── + +export function modelDisplayName(modelName: string | null): string { + if (!modelName) return ""; + if (modelName === "kimi-for-coding" || modelName === "kimi-code") { + return `${modelName} (powered by kimi-k2.5)`; + } + return modelName; +} + +// ── Capability Detection ─────────────────────────────────── + +const ALL_MODEL_CAPABILITIES: Set = new Set([ + "image_in", + "video_in", + "thinking", + "always_thinking", +]); + +/** + * Derive model capabilities from model config. + */ +export function deriveModelCapabilities( + model: LLMModelConfig +): Set { + const capabilities = new Set(model.capabilities ?? []); + const lowerName = model.model.toLowerCase(); + + if (lowerName.includes("thinking") || lowerName.includes("reason")) { + capabilities.add("thinking"); + capabilities.add("always_thinking"); + } else if ( + model.model === "kimi-for-coding" || + model.model === "kimi-code" + ) { + capabilities.add("thinking"); + capabilities.add("image_in"); + capabilities.add("video_in"); + } + + return capabilities; +} + +// ── Environment Variable Overrides ───────────────────────── + +/** + * Override provider/model settings from environment variables. + * Returns a mapping of env vars that were applied. + */ +export function augmentProviderWithEnvVars( + provider: LLMProviderConfig, + model: LLMModelConfig +): Record { + const applied: Record = {}; + + switch (provider.type) { + case "kimi": { + const baseUrl = Bun.env.KIMI_BASE_URL; + if (baseUrl) { + provider.baseUrl = baseUrl; + applied["KIMI_BASE_URL"] = baseUrl; + } + const apiKey = Bun.env.KIMI_API_KEY; + if (apiKey) { + provider.apiKey = apiKey; + applied["KIMI_API_KEY"] = "******"; + } + const modelName = Bun.env.KIMI_MODEL_NAME; + if (modelName) { + model.model = modelName; + applied["KIMI_MODEL_NAME"] = modelName; + } + const maxCtx = Bun.env.KIMI_MODEL_MAX_CONTEXT_SIZE; + if (maxCtx) { + model.maxContextSize = parseInt(maxCtx, 10); + applied["KIMI_MODEL_MAX_CONTEXT_SIZE"] = maxCtx; + } + const caps = Bun.env.KIMI_MODEL_CAPABILITIES; + if (caps) { + const parsed = caps + .split(",") + .map((c) => c.trim().toLowerCase()) + .filter((c): c is ModelCapability => ALL_MODEL_CAPABILITIES.has(c as ModelCapability)); + model.capabilities = parsed; + applied["KIMI_MODEL_CAPABILITIES"] = caps; + } + break; + } + case "openai_legacy": + case "openai_responses": { + const baseUrl = Bun.env.OPENAI_BASE_URL; + if (baseUrl) provider.baseUrl = baseUrl; + const apiKey = Bun.env.OPENAI_API_KEY; + if (apiKey) provider.apiKey = apiKey; + break; + } + default: + break; + } + + return applied; +} + +// ── Token Estimation ─────────────────────────────────────── + +/** + * Simple token count estimation (~4 chars per token). + */ +export function estimateTokenCount(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Estimate tokens for an array of messages. + */ +export function estimateMessagesTokenCount(messages: Message[]): number { + let total = 0; + for (const msg of messages) { + if (typeof msg.content === "string") { + total += estimateTokenCount(msg.content); + } else { + for (const part of msg.content) { + if ("text" in part) { + total += estimateTokenCount((part as { text: string }).text); + } + } + } + // Overhead per message (role, separators) + total += 4; + } + return total; +} + +// ── Factory (placeholder providers) ──────────────────────── + +/** + * Create an LLM instance from provider and model config. + */ +export function createLLM( + provider: LLMProviderConfig, + model: LLMModelConfig, + options?: { + thinking?: boolean | null; + sessionId?: string | null; + } +): LLM | null { + if ( + provider.type !== "_echo" && + provider.type !== "_scripted_echo" && + (!provider.baseUrl || !model.model) + ) { + return null; + } + + const capabilities = deriveModelCapabilities(model); + + // Determine thinking mode + let thinkingMode: "high" | "off" | undefined; + if ( + capabilities.has("always_thinking") || + (options?.thinking === true && capabilities.has("thinking")) + ) { + thinkingMode = "high"; + } else if (options?.thinking === false) { + thinkingMode = "off"; + } + + // Create real provider based on type + let llmProvider: LLMProvider; + + switch (provider.type) { + case "kimi": + case "openai_legacy": + case "openai_responses": + llmProvider = new OpenAICompatibleProvider({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + modelName: model.model, + customHeaders: provider.customHeaders, + thinkingMode, + }); + break; + + case "_echo": + llmProvider = { + modelName: model.model, + async *chat(messages: Message[]) { + const lastMsg = messages[messages.length - 1]; + const text = lastMsg + ? typeof lastMsg.content === "string" + ? lastMsg.content + : "[echo]" + : "[empty]"; + yield { type: "text" as const, text }; + yield { + type: "usage" as const, + usage: { inputTokens: 10, outputTokens: text.length }, + }; + yield { type: "done" as const }; + }, + }; + break; + + default: + llmProvider = { + modelName: model.model, + async *chat() { + throw new Error( + `LLM provider "${provider.type}" is not yet implemented. Model: ${model.model}` + ); + }, + }; + break; + } + + return new LLM({ + provider: llmProvider, + maxContextSize: model.maxContextSize, + capabilities, + modelConfig: model, + providerConfig: provider, + }); +} + +// ── OpenAI-Compatible Provider ────────────────────────────── +// Works with Kimi API, OpenAI API, and any OpenAI-compatible endpoint. + +interface OpenAICompatibleProviderConfig { + baseUrl: string; + apiKey: string; + modelName: string; + customHeaders?: Record; + thinkingMode?: "high" | "low" | "off"; +} + +interface OpenAIMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: string | OpenAIContentPart[] | null; + tool_calls?: OpenAIToolCall[]; + tool_call_id?: string; +} + +interface OpenAIContentPart { + type: string; + text?: string; + image_url?: { url: string }; +} + +interface OpenAIToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; +} + +interface OpenAITool { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +class OpenAICompatibleProvider implements LLMProvider { + readonly modelName: string; + private baseUrl: string; + private apiKey: string; + private customHeaders: Record; + private thinkingMode?: "high" | "low" | "off"; + + constructor(config: OpenAICompatibleProviderConfig) { + this.modelName = config.modelName; + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.apiKey = config.apiKey; + this.customHeaders = config.customHeaders ?? {}; + this.thinkingMode = config.thinkingMode; + } + + async *chat( + messages: Message[], + options?: ChatOptions, + ): AsyncIterable { + // Convert messages to OpenAI format + const openaiMessages = this.convertMessages(messages, options?.system); + + // Build request body + const body: Record = { + model: this.modelName, + messages: openaiMessages, + stream: true, + stream_options: { include_usage: true }, + }; + + // Default max_tokens if not provided (Python Kimi provider defaults to 32000) + body.max_tokens = options?.maxTokens ?? 32000; + if (options?.temperature != null) body.temperature = options.temperature; + if (options?.topP != null) body.top_p = options.topP; + + // Thinking mode configuration (Kimi-specific) + const thinking = options?.thinking ?? this.thinkingMode; + if (thinking && thinking !== "off") { + body.reasoning_effort = thinking; // "high" | "low" + body.thinking = { type: "enabled" }; + } else if (thinking === "off") { + body.thinking = { type: "disabled" }; + } + + // Tools + if (options?.tools && options.tools.length > 0) { + body.tools = options.tools.map( + (t): OpenAITool => ({ + type: "function", + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + }), + ); + } + + // Fetch streaming response + const url = `${this.baseUrl}/chat/completions`; + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + ...this.customHeaders, + }; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: options?.signal, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `LLM API error ${response.status}: ${text.slice(0, 500)}`, + ); + } + + if (!response.body) { + throw new Error("LLM API returned no body"); + } + + // Parse SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let cacheReadTokens = 0; + const pendingToolCalls = new Map< + number, + { id: string; name: string; arguments: string } + >(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === "data: [DONE]" || trimmed === "data:[DONE]") continue; + // Support both "data: {...}" and "data:{...}" (Kimi API omits the space) + if (!trimmed.startsWith("data:")) continue; + const jsonStr = trimmed.startsWith("data: ") ? trimmed.slice(6) : trimmed.slice(5); + let data: any; + try { + data = JSON.parse(jsonStr); + } catch { + continue; + } + + // Extract usage if present (handle both standard and Kimi-specific formats) + if (data.usage) { + const u = data.usage; + totalInputTokens = u.prompt_tokens ?? u.input_tokens ?? 0; + totalOutputTokens = u.completion_tokens ?? u.output_tokens ?? 0; + // Kimi-specific: cached_tokens at root level + cacheReadTokens = u.cached_tokens ?? u.prompt_tokens_details?.cached_tokens ?? 0; + } + // Kimi may also embed usage in choice + if (data.choices?.[0]?.usage) { + const cu = data.choices[0].usage; + totalInputTokens = cu.prompt_tokens ?? totalInputTokens; + totalOutputTokens = cu.completion_tokens ?? totalOutputTokens; + cacheReadTokens = cu.cached_tokens ?? cacheReadTokens; + } + + const choices = data.choices; + if (!choices || choices.length === 0) continue; + + const delta = choices[0].delta; + if (!delta) continue; + + // Text content + if (delta.content) { + yield { type: "text", text: delta.content }; + } + + // Reasoning/thinking content (Kimi k2.5 specific) + if (delta.reasoning_content) { + yield { type: "think", text: delta.reasoning_content }; + } + + // Tool calls + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (tc.id) { + // New tool call + pendingToolCalls.set(idx, { + id: tc.id, + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }); + } else if (pendingToolCalls.has(idx)) { + // Append to existing + const existing = pendingToolCalls.get(idx)!; + if (tc.function?.name) existing.name += tc.function.name; + if (tc.function?.arguments) + existing.arguments += tc.function.arguments; + } + } + } + + // Check for finish reason + if (choices[0].finish_reason) { + // Emit any pending tool calls + for (const [, tc] of pendingToolCalls) { + yield { + type: "tool_call", + id: tc.id, + name: tc.name, + arguments: tc.arguments, + }; + } + pendingToolCalls.clear(); + } + } + } + } finally { + reader.releaseLock(); + } + + // Emit usage + if (totalInputTokens > 0 || totalOutputTokens > 0) { + yield { + type: "usage", + usage: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}), + }, + }; + } + + yield { type: "done" }; + } + + private convertMessages( + messages: Message[], + system?: string, + ): OpenAIMessage[] { + const result: OpenAIMessage[] = []; + + // System prompt + if (system) { + result.push({ role: "system", content: system }); + } + + for (const msg of messages) { + if (typeof msg.content === "string") { + result.push({ + role: msg.role as "user" | "assistant" | "system", + content: msg.content, + }); + } else { + // Complex content with parts + const textParts: string[] = []; + const toolUseParts: OpenAIToolCall[] = []; + const toolResultParts: { toolCallId: string; content: string }[] = []; + + for (const part of msg.content) { + switch (part.type) { + case "text": + textParts.push(part.text); + break; + case "tool_use": + toolUseParts.push({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: JSON.stringify(part.input), + }, + }); + break; + case "tool_result": + toolResultParts.push({ + toolCallId: part.toolUseId, + content: part.content, + }); + break; + case "image": + // Skip images for now + break; + } + } + + if (msg.role === "assistant" && toolUseParts.length > 0) { + const assistantMsg: OpenAIMessage = { + role: "assistant", + content: textParts.join("\n") || null, + tool_calls: toolUseParts, + }; + // Preserve reasoning_content for multi-turn thinking + if ((msg as any).reasoning_content) { + (assistantMsg as any).reasoning_content = (msg as any).reasoning_content; + } + result.push(assistantMsg); + } else if (msg.role === "assistant") { + const assistantMsg: OpenAIMessage = { + role: "assistant", + content: textParts.join("\n") || null, + }; + // Preserve reasoning_content for multi-turn thinking + if ((msg as any).reasoning_content) { + (assistantMsg as any).reasoning_content = (msg as any).reasoning_content; + } + result.push(assistantMsg); + } else if (toolResultParts.length > 0) { + // Tool results become individual tool messages + for (const tr of toolResultParts) { + result.push({ + role: "tool", + tool_call_id: tr.toolCallId, + content: tr.content, + }); + } + } else { + result.push({ + role: msg.role as "user" | "assistant" | "system", + content: textParts.join("\n"), + }); + } + } + } + + return result; + } +} diff --git a/src/kimi_cli_ts/metadata.ts b/src/kimi_cli_ts/metadata.ts new file mode 100644 index 000000000..2eebf02a1 --- /dev/null +++ b/src/kimi_cli_ts/metadata.ts @@ -0,0 +1,118 @@ +/** + * Metadata module — corresponds to Python metadata.py + * Tracks work directories and their sessions using MD5 hashes for directory names. + */ + +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { getShareDir } from "./config.ts"; +import { logger } from "./utils/logging.ts"; + +// ── Metadata file ──────────────────────────────────────── + +export function getMetadataFile(): string { + return join(getShareDir(), "kimi.json"); +} + +// ── WorkDirMeta ────────────────────────────────────────── + +export interface WorkDirMeta { + /** The full path of the work directory. */ + path: string; + /** The name of the KAOS where the work directory is located. */ + kaos: string; + /** Last session ID of this work directory. */ + lastSessionId: string | null; +} + +/** Compute the sessions directory for a work directory using MD5 hash (compatible with Python). */ +export function getSessionsDir(workDirMeta: WorkDirMeta): string { + const pathMd5 = createHash("md5") + .update(workDirMeta.path, "utf-8") + .digest("hex"); + // For local kaos, just use the MD5 hash; otherwise prefix with kaos name + const localKaos = "local"; + const dirBasename = + workDirMeta.kaos === localKaos + ? pathMd5 + : `${workDirMeta.kaos}_${pathMd5}`; + return join(getShareDir(), "sessions", dirBasename); +} + +// ── Metadata ───────────────────────────────────────────── + +export interface Metadata { + workDirs: WorkDirMeta[]; +} + +/** Get the metadata for a work directory. */ +export function getWorkDirMeta( + metadata: Metadata, + path: string, + kaos = "local", +): WorkDirMeta | null { + for (const wd of metadata.workDirs) { + if (wd.path === path && wd.kaos === kaos) { + return wd; + } + } + return null; +} + +/** Create a new work directory metadata entry. */ +export function newWorkDirMeta( + metadata: Metadata, + path: string, + kaos = "local", +): WorkDirMeta { + const wdMeta: WorkDirMeta = { + path, + kaos, + lastSessionId: null, + }; + metadata.workDirs.push(wdMeta); + return wdMeta; +} + +// ── Load / Save ────────────────────────────────────────── + +export async function loadMetadata(): Promise { + const metadataFile = getMetadataFile(); + logger.debug(`Loading metadata from file: ${metadataFile}`); + const file = Bun.file(metadataFile); + if (!(await file.exists())) { + logger.debug("No metadata file found, creating empty metadata"); + return { workDirs: [] }; + } + try { + const data = await file.json(); + // Map Python-style snake_case to camelCase + const workDirs: WorkDirMeta[] = (data.work_dirs ?? data.workDirs ?? []).map( + (wd: any) => ({ + path: wd.path ?? "", + kaos: wd.kaos ?? "local", + lastSessionId: wd.last_session_id ?? wd.lastSessionId ?? null, + }), + ); + return { workDirs }; + } catch (err) { + logger.warn(`Failed to load metadata: ${err}`); + return { workDirs: [] }; + } +} + +export async function saveMetadata(metadata: Metadata): Promise { + const metadataFile = getMetadataFile(); + logger.debug(`Saving metadata to file: ${metadataFile}`); + const dir = metadataFile.substring(0, metadataFile.lastIndexOf("/")); + await Bun.$`mkdir -p ${dir}`.quiet(); + // Save in Python-compatible snake_case format + const data = { + work_dirs: metadata.workDirs.map((wd) => ({ + path: wd.path, + kaos: wd.kaos, + last_session_id: wd.lastSessionId, + })), + }; + await Bun.write(metadataFile, JSON.stringify(data, null, 2)); +} diff --git a/src/kimi_cli_ts/notifications/index.ts b/src/kimi_cli_ts/notifications/index.ts new file mode 100644 index 000000000..114fec5db --- /dev/null +++ b/src/kimi_cli_ts/notifications/index.ts @@ -0,0 +1,23 @@ +/** + * Notification system — corresponds to Python notifications/ + */ + +export type { + NotificationCategory, + NotificationSeverity, + NotificationSink, + NotificationDeliveryStatus, + NotificationEvent, + NotificationSinkState, + NotificationDelivery, + NotificationView, +} from "./models.ts"; +export { newNotificationEvent, eventToJson, eventFromJson, deliveryToJson, deliveryFromJson } from "./models.ts"; +export { NotificationStore } from "./store.ts"; +export { NotificationManager } from "./manager.ts"; +export type { NotificationConfig } from "./manager.ts"; +export { NotificationWatcher } from "./notifier.ts"; +export { toWireNotification } from "./wire.ts"; +export type { WireNotification } from "./wire.ts"; +export { buildNotificationMessage, extractNotificationIds, isNotificationMessage } from "./llm.ts"; +export type { NotificationRuntime, BackgroundTaskView } from "./llm.ts"; diff --git a/src/kimi_cli_ts/notifications/llm.ts b/src/kimi_cli_ts/notifications/llm.ts new file mode 100644 index 000000000..5d415e581 --- /dev/null +++ b/src/kimi_cli_ts/notifications/llm.ts @@ -0,0 +1,127 @@ +/** + * LLM-facing notification helpers — corresponds to Python notifications/llm.py + * + * Converts notification views into LLM-consumable messages and extracts + * notification IDs from conversation history. + */ + +import type { Message, ContentPart } from "../types.ts"; +import type { NotificationView } from "./models.ts"; + +const NOTIFICATION_ID_RE = /`, + `Title: ${event.title}`, + `Severity: ${event.severity}`, + event.body, + ]; + + if ( + runtime && + event.category === "task" && + event.sourceKind === "background_task" + ) { + const taskView = runtime.backgroundTasks.getTask(event.sourceId); + if (taskView) { + const tail = runtime.backgroundTasks.tailOutput(taskView.spec.id, { + maxBytes: runtime.config.background.notificationTailChars, + maxLines: runtime.config.background.notificationTailLines, + }); + lines.push( + "", + `Task ID: ${taskView.spec.id}`, + `Task Type: ${taskView.spec.kind}`, + `Description: ${taskView.spec.description}`, + `Status: ${taskView.runtime.status}`, + ); + if (taskView.runtime.exitCode != null) { + lines.push(`Exit code: ${taskView.runtime.exitCode}`); + } + if (taskView.runtime.failureReason) { + lines.push(`Failure reason: ${taskView.runtime.failureReason}`); + } + if (tail) { + lines.push("Output tail:", tail); + } + lines.push(""); + } + } + + lines.push(""); + const content: ContentPart[] = [{ type: "text", text: lines.join("\n") }]; + return { role: "user", content }; +} + +/** + * Extract notification IDs from conversation history. + * Scans user messages for `` tags. + */ +export function extractNotificationIds(history: readonly Message[]): Set { + const ids = new Set(); + for (const message of history) { + if (message.role !== "user") continue; + const parts = Array.isArray(message.content) ? message.content : []; + for (const part of parts) { + if ("type" in part && part.type === "text" && "text" in part) { + const text = part.text as string; + // Reset regex lastIndex since we use the global flag + NOTIFICATION_ID_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = NOTIFICATION_ID_RE.exec(text)) !== null) { + ids.add(match[1]!); + } + } + } + } + return ids; +} + +/** + * Check whether a message is a notification injection message. + */ +export function isNotificationMessage(message: Message): boolean { + if (message.role !== "user") return false; + const parts = Array.isArray(message.content) ? message.content : []; + if (parts.length !== 1) return false; + const part = parts[0]; + return ( + part !== undefined && + "type" in part && + part.type === "text" && + "text" in part && + (part.text as string).trimStart().startsWith(") { + this._config = { claimStaleAfterMs: config?.claimStaleAfterMs ?? 15_000 }; + this._store = new NotificationStore(root); + } + + get store(): NotificationStore { + return this._store; + } + + newId(): string { + return `n${randomUUID().replace(/-/g, "").slice(0, 8)}`; + } + + private initialDelivery(event: NotificationEvent): NotificationDelivery { + const sinks: Record = {}; + for (const sink of event.targets) { + sinks[sink] = { status: "pending" }; + } + return { sinks }; + } + + findByDedupeKey(dedupeKey: string): NotificationView | undefined { + for (const view of this._store.listViews()) { + if (view.event.dedupeKey === dedupeKey) { + return view; + } + } + return undefined; + } + + publish(event: NotificationEvent): NotificationView { + if (event.dedupeKey) { + const existing = this.findByDedupeKey(event.dedupeKey); + if (existing) return existing; + } + const delivery = this.initialDelivery(event); + this._store.createNotification(event, delivery); + return { event, delivery }; + } + + recover(): void { + const now = Date.now() / 1000; + const staleAfter = this._config.claimStaleAfterMs / 1000; + for (const view of this._store.listViews()) { + let updated = false; + const delivery = structuredClone(view.delivery); + for (const sinkState of Object.values(delivery.sinks)) { + if (sinkState.status !== "claimed" || sinkState.claimedAt == null) continue; + if (now - sinkState.claimedAt <= staleAfter) continue; + sinkState.status = "pending"; + sinkState.claimedAt = undefined; + updated = true; + } + if (updated) { + this._store.writeDelivery(view.event.id, delivery); + } + } + } + + hasPendingForSink(sink: string): boolean { + for (const view of this._store.listViews()) { + const sinkState = view.delivery.sinks[sink]; + if (sinkState && sinkState.status === "pending") return true; + } + return false; + } + + claimForSink(sink: string, limit = 8): NotificationView[] { + this.recover(); + const claimed: NotificationView[] = []; + const now = Date.now() / 1000; + const views = this._store.listViews(); + // Process in reverse (oldest first) + for (let i = views.length - 1; i >= 0; i--) { + const view = views[i]!; + const sinkState = view.delivery.sinks[sink]; + if (!sinkState || sinkState.status === "acked" || sinkState.status === "claimed") continue; + const delivery = structuredClone(view.delivery); + const targetState = delivery.sinks[sink]!; + targetState.status = "claimed"; + targetState.claimedAt = now; + this._store.writeDelivery(view.event.id, delivery); + claimed.push({ event: view.event, delivery }); + if (claimed.length >= limit) break; + } + return claimed; + } + + async deliverPending( + sink: string, + opts: { + onNotification: (view: NotificationView) => Promise | void; + limit?: number; + beforeClaim?: () => void; + }, + ): Promise { + if (opts.beforeClaim) opts.beforeClaim(); + const delivered: NotificationView[] = []; + for (const view of this.claimForSink(sink, opts.limit ?? 8)) { + try { + const result = opts.onNotification(view); + if (result instanceof Promise) await result; + } catch { + logger.warn(`Notification handler failed for ${sink}/${view.event.id}, leaving claimed`); + continue; + } + delivered.push(this.ack(sink, view.event.id)); + } + return delivered; + } + + ack(sink: string, notificationId: string): NotificationView { + const view = this._store.mergedView(notificationId); + const delivery = structuredClone(view.delivery); + const sinkState = delivery.sinks[sink]; + if (!sinkState) return view; + sinkState.status = "acked"; + sinkState.ackedAt = Date.now() / 1000; + sinkState.claimedAt = undefined; + this._store.writeDelivery(notificationId, delivery); + return { event: view.event, delivery }; + } + + ackIds(sink: string, notificationIds: Set): void { + for (const id of notificationIds) { + try { + this.ack(sink, id); + } catch { + // ignore missing + } + } + } +} diff --git a/src/kimi_cli_ts/notifications/models.ts b/src/kimi_cli_ts/notifications/models.ts new file mode 100644 index 000000000..43b45b94f --- /dev/null +++ b/src/kimi_cli_ts/notifications/models.ts @@ -0,0 +1,132 @@ +/** + * Notification models — corresponds to Python notifications/models.py + */ + +export type NotificationCategory = "task" | "agent" | "system"; +export type NotificationSeverity = "info" | "success" | "warning" | "error"; +export type NotificationSink = "llm" | "wire" | "shell"; +export type NotificationDeliveryStatus = "pending" | "claimed" | "acked"; + +export interface NotificationEvent { + version: number; + id: string; + category: NotificationCategory; + type: string; + sourceKind: string; + sourceId: string; + title: string; + body: string; + severity: NotificationSeverity; + createdAt: number; + payload: Record; + targets: NotificationSink[]; + dedupeKey?: string; +} + +export interface NotificationSinkState { + status: NotificationDeliveryStatus; + claimedAt?: number; + ackedAt?: number; +} + +export interface NotificationDelivery { + sinks: Record; +} + +export interface NotificationView { + event: NotificationEvent; + delivery: NotificationDelivery; +} + +// ── JSON serialization helpers (snake_case ↔ camelCase) ── + +export function eventToJson(e: NotificationEvent): Record { + return { + version: e.version, + id: e.id, + category: e.category, + type: e.type, + source_kind: e.sourceKind, + source_id: e.sourceId, + title: e.title, + body: e.body, + severity: e.severity, + created_at: e.createdAt, + payload: e.payload, + targets: e.targets, + dedupe_key: e.dedupeKey, + }; +} + +export function eventFromJson(data: Record): NotificationEvent { + return { + version: Number(data.version ?? 1), + id: String(data.id), + category: String(data.category ?? "system") as NotificationCategory, + type: String(data.type ?? ""), + sourceKind: String(data.source_kind ?? data.sourceKind ?? ""), + sourceId: String(data.source_id ?? data.sourceId ?? ""), + title: String(data.title ?? ""), + body: String(data.body ?? ""), + severity: String(data.severity ?? "info") as NotificationSeverity, + createdAt: Number(data.created_at ?? data.createdAt ?? Date.now() / 1000), + payload: (data.payload as Record) ?? {}, + targets: (data.targets as NotificationSink[]) ?? ["llm", "wire", "shell"], + dedupeKey: (data.dedupe_key ?? data.dedupeKey) as string | undefined, + }; +} + +export function deliveryToJson(d: NotificationDelivery): Record { + const sinks: Record = {}; + for (const [key, state] of Object.entries(d.sinks)) { + sinks[key] = { + status: state.status, + claimed_at: state.claimedAt, + acked_at: state.ackedAt, + }; + } + return { sinks }; +} + +export function deliveryFromJson(data: Record): NotificationDelivery { + const rawSinks = (data.sinks as Record>) ?? {}; + const sinks: Record = {}; + for (const [key, raw] of Object.entries(rawSinks)) { + sinks[key] = { + status: String(raw.status ?? "pending") as NotificationDeliveryStatus, + claimedAt: raw.claimed_at != null ? Number(raw.claimed_at) : undefined, + ackedAt: raw.acked_at != null ? Number(raw.acked_at) : undefined, + }; + } + return { sinks }; +} + +export function newNotificationEvent(opts: { + id: string; + category: NotificationCategory; + type: string; + sourceKind: string; + sourceId: string; + title: string; + body: string; + severity?: NotificationSeverity; + payload?: Record; + targets?: NotificationSink[]; + dedupeKey?: string; +}): NotificationEvent { + return { + version: 1, + id: opts.id, + category: opts.category, + type: opts.type, + sourceKind: opts.sourceKind, + sourceId: opts.sourceId, + title: opts.title, + body: opts.body, + severity: opts.severity ?? "info", + createdAt: Date.now() / 1000, + payload: opts.payload ?? {}, + targets: opts.targets ?? ["llm", "wire", "shell"], + dedupeKey: opts.dedupeKey, + }; +} diff --git a/src/kimi_cli_ts/notifications/notifier.ts b/src/kimi_cli_ts/notifications/notifier.ts new file mode 100644 index 000000000..9798a7eb3 --- /dev/null +++ b/src/kimi_cli_ts/notifications/notifier.ts @@ -0,0 +1,49 @@ +/** + * Notification watcher — corresponds to Python notifications/notifier.py + * Polls the manager for pending notifications on a given sink. + */ + +import { logger } from "../utils/logging.ts"; +import type { NotificationManager } from "./manager.ts"; +import type { NotificationSink, NotificationView } from "./models.ts"; + +export class NotificationWatcher { + private _manager: NotificationManager; + private _sink: NotificationSink; + private _onNotification: (view: NotificationView) => Promise | void; + private _beforePoll?: () => void; + private _intervalS: number; + + constructor(opts: { + manager: NotificationManager; + sink: NotificationSink; + onNotification: (view: NotificationView) => Promise | void; + beforePoll?: () => void; + intervalS?: number; + }) { + this._manager = opts.manager; + this._sink = opts.sink; + this._onNotification = opts.onNotification; + this._beforePoll = opts.beforePoll; + this._intervalS = opts.intervalS ?? 1.0; + } + + async pollOnce(): Promise { + return this._manager.deliverPending(this._sink, { + onNotification: this._onNotification, + beforeClaim: this._beforePoll, + }); + } + + async runForever(signal?: AbortSignal): Promise { + while (!signal?.aborted) { + try { + await this.pollOnce(); + } catch (err) { + if (signal?.aborted) break; + logger.error("NotificationWatcher poll failed"); + } + await Bun.sleep(this._intervalS * 1000); + } + } +} diff --git a/src/kimi_cli_ts/notifications/store.ts b/src/kimi_cli_ts/notifications/store.ts new file mode 100644 index 000000000..36551e9ee --- /dev/null +++ b/src/kimi_cli_ts/notifications/store.ts @@ -0,0 +1,151 @@ +/** + * Notification store — corresponds to Python notifications/store.py + * File-based persistence for notification events and delivery state. + */ + +import { join } from "node:path"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, +} from "node:fs"; +import { logger } from "../utils/logging.ts"; +import { + type NotificationEvent, + type NotificationDelivery, + type NotificationView, + type NotificationSinkState, + eventToJson, + eventFromJson, + deliveryToJson, + deliveryFromJson, +} from "./models.ts"; + +const VALID_NOTIFICATION_ID = /^[a-z0-9]{2,20}$/; + +function validateNotificationId(id: string): void { + if (!VALID_NOTIFICATION_ID.test(id)) { + throw new Error(`Invalid notification_id: ${id}`); + } +} + +function atomicJsonWrite(data: Record, filePath: string): void { + const tmpPath = filePath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + const { renameSync } = require("node:fs"); + renameSync(tmpPath, filePath); +} + +export class NotificationStore { + static readonly EVENT_FILE = "event.json"; + static readonly DELIVERY_FILE = "delivery.json"; + + private _root: string; + + constructor(root: string) { + this._root = root; + } + + get root(): string { + return this._root; + } + + private ensureRoot(): string { + if (!existsSync(this._root)) { + mkdirSync(this._root, { recursive: true }); + } + return this._root; + } + + notificationDir(notificationId: string): string { + validateNotificationId(notificationId); + const path = join(this.ensureRoot(), notificationId); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } + return path; + } + + notificationPath(notificationId: string): string { + validateNotificationId(notificationId); + return join(this._root, notificationId); + } + + eventPath(notificationId: string): string { + return join(this.notificationPath(notificationId), NotificationStore.EVENT_FILE); + } + + deliveryPath(notificationId: string): string { + return join(this.notificationPath(notificationId), NotificationStore.DELIVERY_FILE); + } + + createNotification(event: NotificationEvent, delivery: NotificationDelivery): void { + const dir = this.notificationDir(event.id); + atomicJsonWrite(eventToJson(event), join(dir, NotificationStore.EVENT_FILE)); + atomicJsonWrite(deliveryToJson(delivery), join(dir, NotificationStore.DELIVERY_FILE)); + } + + listNotificationIds(): string[] { + if (!existsSync(this._root)) return []; + const ids: string[] = []; + for (const entry of readdirSync(this._root).sort()) { + const dirPath = join(this._root, entry); + try { + if (!statSync(dirPath).isDirectory()) continue; + } catch { + continue; + } + if (!existsSync(join(dirPath, NotificationStore.EVENT_FILE))) continue; + ids.push(entry); + } + return ids; + } + + readEvent(notificationId: string): NotificationEvent { + const data = JSON.parse(readFileSync(this.eventPath(notificationId), "utf-8")); + return eventFromJson(data); + } + + writeEvent(event: NotificationEvent): void { + atomicJsonWrite(eventToJson(event), this.eventPath(event.id)); + } + + readDelivery(notificationId: string): NotificationDelivery { + const path = this.deliveryPath(notificationId); + if (!existsSync(path)) return { sinks: {} }; + try { + const data = JSON.parse(readFileSync(path, "utf-8")); + return deliveryFromJson(data); + } catch (err) { + logger.warn(`Corrupted delivery file for notification ${notificationId}, using default: ${err}`); + return { sinks: {} }; + } + } + + writeDelivery(notificationId: string, delivery: NotificationDelivery): void { + atomicJsonWrite(deliveryToJson(delivery), this.deliveryPath(notificationId)); + } + + mergedView(notificationId: string): NotificationView { + return { + event: this.readEvent(notificationId), + delivery: this.readDelivery(notificationId), + }; + } + + listViews(): NotificationView[] { + const views: NotificationView[] = []; + for (const id of this.listNotificationIds()) { + try { + views.push(this.mergedView(id)); + } catch (err) { + logger.warn(`Skipping corrupted notification ${id}: ${err}`); + } + } + views.sort((a, b) => b.event.createdAt - a.event.createdAt); + return views; + } +} diff --git a/src/kimi_cli_ts/notifications/wire.ts b/src/kimi_cli_ts/notifications/wire.ts new file mode 100644 index 000000000..5de3d24aa --- /dev/null +++ b/src/kimi_cli_ts/notifications/wire.ts @@ -0,0 +1,35 @@ +/** + * Wire notification bridge — corresponds to Python notifications/wire.py + * Converts NotificationView to wire protocol Notification. + */ + +import type { NotificationView } from "./models.ts"; + +export interface WireNotification { + id: string; + category: string; + type: string; + source_kind: string; + source_id: string; + title: string; + body: string; + severity: string; + created_at: number; + payload: Record; +} + +export function toWireNotification(view: NotificationView): WireNotification { + const e = view.event; + return { + id: e.id, + category: e.category, + type: e.type, + source_kind: e.sourceKind, + source_id: e.sourceId, + title: e.title, + body: e.body, + severity: e.severity, + created_at: e.createdAt, + payload: e.payload, + }; +} diff --git a/src/kimi_cli_ts/plugin/manager.ts b/src/kimi_cli_ts/plugin/manager.ts new file mode 100644 index 000000000..fdebfa83c --- /dev/null +++ b/src/kimi_cli_ts/plugin/manager.ts @@ -0,0 +1,267 @@ +/** + * Plugin manager — corresponds to Python plugin/manager.py + * Plugin installation, removal, and listing. + */ + +import { join, resolve } from "node:path"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, + statSync, + rmSync, + cpSync, + renameSync, + mkdtempSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { logger } from "../utils/logging.ts"; + +// ── Types ── + +export class PluginError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginError"; + } +} + +export interface PluginRuntime { + host: string; + hostVersion: string; +} + +export interface PluginToolSpec { + name: string; + description: string; + command: string[]; + parameters: Record; +} + +export interface PluginSpec { + name: string; + version: string; + description: string; + configFile?: string; + inject: Record; + tools: PluginToolSpec[]; + runtime?: PluginRuntime; +} + +export const PLUGIN_JSON = "plugin.json"; + +// ── Parsing ── + +export function parsePluginJson(path: string): PluginSpec { + let data: Record; + try { + data = JSON.parse(readFileSync(path, "utf-8")); + } catch (err) { + throw new PluginError(`Failed to read ${path}: ${err}`); + } + + if (!data.name) throw new PluginError(`Missing required field 'name' in ${path}`); + if (!data.version) throw new PluginError(`Missing required field 'version' in ${path}`); + if (data.inject && !data.config_file) { + throw new PluginError(`'inject' requires 'config_file' in ${path}`); + } + + const tools: PluginToolSpec[] = []; + if (Array.isArray(data.tools)) { + for (const t of data.tools) { + tools.push({ + name: String(t.name ?? ""), + description: String(t.description ?? ""), + command: Array.isArray(t.command) ? t.command.map(String) : [], + parameters: (t.parameters as Record) ?? {}, + }); + } + } + + const runtime = data.runtime as Record | undefined; + + return { + name: String(data.name), + version: String(data.version), + description: String(data.description ?? ""), + configFile: data.config_file ? String(data.config_file) : undefined, + inject: (data.inject as Record) ?? {}, + tools, + runtime: runtime + ? { host: String(runtime.host ?? ""), hostVersion: String(runtime.host_version ?? "") } + : undefined, + }; +} + +// ── Directory helpers ── + +export function getPluginsDir(): string { + const shareDir = join(homedir(), ".kimi"); + return join(shareDir, "plugins"); +} + +// ── Config injection ── + +function setNested(obj: Record, dottedPath: string, value: unknown): void { + const keys = dottedPath.split("."); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]!; + if (!(key in current) || typeof current[key] !== "object" || current[key] === null) { + current[key] = {}; + } + current = current[key] as Record; + } + current[keys[keys.length - 1]!] = value; +} + +export function injectConfig( + pluginDir: string, + spec: PluginSpec, + values: Record, +): void { + if (!spec.inject || !spec.configFile) return; + + const configPath = resolve(join(pluginDir, spec.configFile)); + if (!configPath.startsWith(resolve(pluginDir))) { + throw new PluginError(`config_file escapes plugin directory: ${spec.configFile}`); + } + if (!existsSync(configPath)) { + throw new PluginError(`Config file not found: ${configPath}`); + } + + let config: Record; + try { + config = JSON.parse(readFileSync(configPath, "utf-8")); + } catch (err) { + throw new PluginError(`Failed to read config file ${configPath}: ${err}`); + } + + for (const [targetPath, sourceKey] of Object.entries(spec.inject)) { + if (!(sourceKey in values)) { + throw new PluginError(`Host does not provide required inject key '${sourceKey}'`); + } + setNested(config, targetPath, values[sourceKey]!); + } + + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); +} + +export function writeRuntime(pluginDir: string, runtime: PluginRuntime): void { + const pluginJsonPath = join(pluginDir, PLUGIN_JSON); + let data: Record; + try { + data = JSON.parse(readFileSync(pluginJsonPath, "utf-8")); + } catch (err) { + throw new PluginError(`Failed to read ${pluginJsonPath}: ${err}`); + } + data.runtime = { host: runtime.host, host_version: runtime.hostVersion }; + writeFileSync(pluginJsonPath, JSON.stringify(data, null, 2), "utf-8"); +} + +// ── Installation ── + +function validateName(name: string, pluginsDir: string): string { + const dest = resolve(join(pluginsDir, name)); + if (!dest.startsWith(resolve(pluginsDir))) { + throw new PluginError(`Invalid plugin name: ${name}`); + } + return dest; +} + +export function installPlugin(opts: { + source: string; + pluginsDir: string; + hostValues: Record; + hostName: string; + hostVersion: string; +}): PluginSpec { + const sourcePluginJson = join(opts.source, PLUGIN_JSON); + if (!existsSync(sourcePluginJson)) { + throw new PluginError(`No plugin.json found in ${opts.source}`); + } + + const spec = parsePluginJson(sourcePluginJson); + const dest = validateName(spec.name, opts.pluginsDir); + + mkdirSync(opts.pluginsDir, { recursive: true }); + const staging = mkdtempSync(join(opts.pluginsDir, `.${spec.name}-`)); + + try { + const stagingPlugin = join(staging, spec.name); + cpSync(opts.source, stagingPlugin, { recursive: true }); + + injectConfig(stagingPlugin, spec, opts.hostValues); + writeRuntime(stagingPlugin, { host: opts.hostName, hostVersion: opts.hostVersion }); + + if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }); + renameSync(stagingPlugin, dest); + } catch (err) { + rmSync(staging, { recursive: true, force: true }); + throw err; + } finally { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + // Ignore + } + } + + return parsePluginJson(join(dest, PLUGIN_JSON)); +} + +export function refreshPluginConfigs(pluginsDir: string, hostValues: Record): void { + if (!existsSync(pluginsDir)) return; + try { + if (!statSync(pluginsDir).isDirectory()) return; + } catch { + return; + } + + for (const child of readdirSync(pluginsDir).sort()) { + const childPath = join(pluginsDir, child); + const pluginJson = join(childPath, PLUGIN_JSON); + try { + if (!statSync(childPath).isDirectory() || !existsSync(pluginJson)) continue; + const spec = parsePluginJson(pluginJson); + if (spec.inject && spec.configFile) { + injectConfig(childPath, spec, hostValues); + } + } catch { + continue; + } + } +} + +export function listPlugins(pluginsDir: string): PluginSpec[] { + if (!existsSync(pluginsDir)) return []; + try { + if (!statSync(pluginsDir).isDirectory()) return []; + } catch { + return []; + } + + const plugins: PluginSpec[] = []; + for (const child of readdirSync(pluginsDir).sort()) { + const childPath = join(pluginsDir, child); + const pluginJson = join(childPath, PLUGIN_JSON); + try { + if (statSync(childPath).isDirectory() && existsSync(pluginJson)) { + plugins.push(parsePluginJson(pluginJson)); + } + } catch { + continue; + } + } + return plugins; +} + +export function removePlugin(name: string, pluginsDir: string): void { + const dest = validateName(name, pluginsDir); + if (!existsSync(dest)) { + throw new PluginError(`Plugin '${name}' not found in ${pluginsDir}`); + } + rmSync(dest, { recursive: true, force: true }); +} diff --git a/src/kimi_cli_ts/plugin/tool.ts b/src/kimi_cli_ts/plugin/tool.ts new file mode 100644 index 000000000..82fd31b37 --- /dev/null +++ b/src/kimi_cli_ts/plugin/tool.ts @@ -0,0 +1,194 @@ +/** + * Plugin tool wrapper — corresponds to Python plugin/tool.py + * Runs plugin-declared tools as subprocesses. + */ + +import { join } from "node:path"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { logger } from "../utils/logging.ts"; +import type { Approval } from "../soul/approval.ts"; +import { + type PluginToolSpec, + type PluginSpec, + PluginError, + PLUGIN_JSON, + parsePluginJson, +} from "./manager.ts"; + +export interface PluginToolResult { + ok: boolean; + output: string; + brief?: string; +} + +/** + * Collect host values (api_key, base_url) for plugin injection. + * Resolves credentials from the default provider, handling OAuth tokens + * and static API keys. + */ +export interface PluginConfig { + defaultModel?: string; + models: Record; + providers: Record; +} + +export interface OAuthManager { + resolveApiKey(apiKey: string | undefined, oauth: unknown): string | undefined; +} + +export function collectHostValues(config: PluginConfig, oauth: OAuthManager): Record { + const values: Record = {}; + if (!config.defaultModel || !(config.defaultModel in config.models)) return values; + const model = config.models[config.defaultModel]!; + if (!(model.provider in config.providers)) return values; + const provider = config.providers[model.provider]!; + const apiKey = oauth.resolveApiKey(provider.apiKey, provider.oauth); + if (apiKey) values["api_key"] = apiKey; + values["base_url"] = provider.baseUrl; + return values; +} + +export class PluginTool { + readonly name: string; + readonly description: string; + readonly parameters: Record; + private _command: string[]; + private _pluginDir: string; + private _inject: Record; + private _getHostValues?: () => Record; + private _approval?: Approval; + + constructor(opts: { + toolSpec: PluginToolSpec; + pluginDir: string; + inject: Record; + getHostValues?: () => Record; + approval?: Approval; + }) { + this.name = opts.toolSpec.name; + this.description = opts.toolSpec.description; + this.parameters = opts.toolSpec.parameters || { type: "object", properties: {} }; + this._command = opts.toolSpec.command; + this._pluginDir = opts.pluginDir; + this._inject = opts.inject; + this._getHostValues = opts.getHostValues; + this._approval = opts.approval; + } + + private buildEnv(): Record { + const env: Record = { ...process.env } as Record; + if (Object.keys(this._inject).length > 0) { + const hostValues = this._getHostValues?.() ?? {}; + for (const [targetKey, sourceKey] of Object.entries(this._inject)) { + if (sourceKey in hostValues) { + env[targetKey] = hostValues[sourceKey]!; + } + } + } + return env; + } + + async execute(params: Record): Promise { + // Approval check before execution + if (this._approval) { + const description = `Run plugin tool \`${this.name}\`.`; + const result = await this._approval.request(this.name, `plugin:${this.name}`, description); + if (!result.approved) { + return { ok: false, output: "Tool execution rejected by user.", brief: "Rejected" }; + } + } + + const paramsJson = JSON.stringify(params); + + try { + const proc = Bun.spawn(this._command, { + stdin: new Blob([paramsJson]), + stdout: "pipe", + stderr: "pipe", + cwd: this._pluginDir, + env: this.buildEnv(), + }); + + const timer = setTimeout(() => proc.kill(), 120_000); + const exitCode = await proc.exited; + clearTimeout(timer); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const output = stdout.trim(); + const errOutput = stderr.trim(); + + if (exitCode !== 0) { + const errorMsg = errOutput || output || `Exit code ${exitCode}`; + return { + ok: false, + output: `Plugin tool '${this.name}' failed: ${errorMsg}`, + brief: `Exit ${exitCode}`, + }; + } + + if (errOutput) { + logger.debug(`Plugin tool ${this.name} stderr: ${errOutput}`); + } + + return { ok: true, output }; + } catch (err) { + return { ok: false, output: String(err), brief: "Runtime error" }; + } + } +} + +/** + * Scan installed plugins and create PluginTool instances for declared tools. + */ +export function loadPluginTools( + pluginsDir: string, + opts?: { + getHostValues?: () => Record; + approval?: Approval; + }, +): PluginTool[] { + if (!existsSync(pluginsDir)) return []; + try { + if (!statSync(pluginsDir).isDirectory()) return []; + } catch { + return []; + } + + const tools: PluginTool[] = []; + for (const child of readdirSync(pluginsDir).sort()) { + const childPath = join(pluginsDir, child); + const pluginJson = join(childPath, PLUGIN_JSON); + if (!existsSync(pluginJson)) continue; + try { + if (!statSync(childPath).isDirectory()) continue; + } catch { + continue; + } + + let spec: PluginSpec; + try { + spec = parsePluginJson(pluginJson); + } catch { + continue; + } + + for (const toolSpec of spec.tools) { + try { + tools.push( + new PluginTool({ + toolSpec, + pluginDir: childPath, + inject: spec.inject, + getHostValues: opts?.getHostValues, + approval: opts?.approval, + }), + ); + logger.info(`Loaded plugin tool: ${toolSpec.name} (from ${spec.name})`); + } catch { + logger.warn(`Skipping invalid plugin tool: ${toolSpec.name} (from ${spec.name})`); + } + } + } + return tools; +} diff --git a/src/kimi_cli_ts/session.ts b/src/kimi_cli_ts/session.ts new file mode 100644 index 000000000..2e085effe --- /dev/null +++ b/src/kimi_cli_ts/session.ts @@ -0,0 +1,337 @@ +/** + * Session module — corresponds to Python session.py + session_state.py + * Manages per-workdir sessions with context files and state persistence. + */ + +import { z } from "zod/v4"; +import { join, resolve } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { getShareDir } from "./config.ts"; +import { logger } from "./utils/logging.ts"; +import { + loadMetadata, + saveMetadata, + getWorkDirMeta, + newWorkDirMeta, + getSessionsDir, + type Metadata, + type WorkDirMeta, +} from "./metadata.ts"; + +// ── Session State ─────────────────────────────────────── + +export const ApprovalStateData = z.object({ + yolo: z.boolean().default(false), + auto_approve_actions: z.array(z.string()).default([]), +}); +export type ApprovalStateData = z.infer; + +export const SessionState = z.object({ + version: z.number().int().default(1), + approval: ApprovalStateData.default({} as any), + additional_dirs: z.array(z.string()).default([]), + custom_title: z.string().nullable().default(null), + title_generated: z.boolean().default(false), + title_generate_attempts: z.number().int().default(0), + plan_mode: z.boolean().default(false), + plan_session_id: z.string().nullable().default(null), + plan_slug: z.string().nullable().default(null), + wire_mtime: z.number().nullable().default(null), + archived: z.boolean().default(false), + archived_at: z.number().nullable().default(null), + auto_archive_exempt: z.boolean().default(false), +}); +export type SessionState = z.infer; + +const STATE_FILE_NAME = "state.json"; + +export async function loadSessionState(sessionDir: string): Promise { + const stateFile = join(sessionDir, STATE_FILE_NAME); + const file = Bun.file(stateFile); + if (!(await file.exists())) { + return SessionState.parse({}); + } + try { + const data = await file.json(); + return SessionState.parse(data); + } catch { + logger.warn(`Corrupted state file, using defaults: ${stateFile}`); + return SessionState.parse({}); + } +} + +export async function saveSessionState(state: SessionState, sessionDir: string): Promise { + const stateFile = join(sessionDir, STATE_FILE_NAME); + await Bun.write(stateFile, JSON.stringify(state, null, 2)); +} + +// ── WorkDir Metadata (uses metadata.ts for Python-compatible MD5 hashing) ── + +function getSessionsBaseDir(workDir: string): string { + // Use MD5 hash of the work directory path, compatible with Python metadata.py + const pathMd5 = createHash("md5").update(workDir, "utf-8").digest("hex"); + return join(getShareDir(), "sessions", pathMd5); +} + +// ── Session class ─────────────────────────────────────── + +export class Session { + readonly id: string; + readonly workDir: string; + readonly sessionsDir: string; + readonly contextFile: string; + readonly wireFile: string; + state: SessionState; + title: string; + updatedAt: number; + + constructor(opts: { + id: string; + workDir: string; + sessionsDir: string; + contextFile: string; + wireFile: string; + state: SessionState; + title?: string; + updatedAt?: number; + }) { + this.id = opts.id; + this.workDir = opts.workDir; + this.sessionsDir = opts.sessionsDir; + this.contextFile = opts.contextFile; + this.wireFile = opts.wireFile; + this.state = opts.state; + this.title = opts.title ?? "Untitled"; + this.updatedAt = opts.updatedAt ?? 0; + } + + get dir(): string { + const path = join(this.sessionsDir, this.id); + // Note: directory creation is handled by save operations (saveState, create) + return path; + } + + get subagentsDir(): string { + return join(this.dir, "subagents"); + } + + /** Ensure the session directory exists (call before writing). */ + async ensureDir(): Promise { + const path = this.dir; + await Bun.$`mkdir -p ${path}`.quiet(); + return path; + } + + async isEmpty(): Promise { + if (this.state.custom_title) return false; + + const contextBunFile = Bun.file(this.contextFile); + if (!(await contextBunFile.exists())) return true; + + try { + const text = await contextBunFile.text(); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed.role === "string" && !parsed.role.startsWith("_")) { + return false; + } + } catch { + continue; + } + } + } catch { + return false; + } + return true; + } + + async saveState(): Promise { + await Bun.$`mkdir -p ${this.dir}`.quiet(); + + // Reload externally-mutable fields from disk first to avoid + // overwriting concurrent changes made by the web API (matches Python behavior). + const fresh = await loadSessionState(this.dir); + this.state.custom_title = fresh.custom_title; + this.state.title_generated = fresh.title_generated; + this.state.title_generate_attempts = fresh.title_generate_attempts; + this.state.archived = fresh.archived; + this.state.archived_at = fresh.archived_at; + this.state.auto_archive_exempt = fresh.auto_archive_exempt; + + await saveSessionState(this.state, this.dir); + } + + async delete(): Promise { + const sessionDir = join(this.sessionsDir, this.id); + const file = Bun.file(sessionDir); + if (await file.exists()) { + await Bun.$`rm -rf ${sessionDir}`.quiet(); + } + } + + async refresh(): Promise { + this.title = "Untitled"; + const contextBunFile = Bun.file(this.contextFile); + if (await contextBunFile.exists()) { + const stat = await Bun.$`stat -f %m ${this.contextFile} 2>/dev/null || stat -c %Y ${this.contextFile} 2>/dev/null`.quiet().text(); + this.updatedAt = Number.parseFloat(stat.trim()) || 0; + } else { + this.updatedAt = 0; + } + + if (this.state.custom_title) { + this.title = this.state.custom_title; + return; + } + + // Try to derive title from wire file first turn + const wireBunFile = Bun.file(this.wireFile); + if (await wireBunFile.exists()) { + try { + const text = await wireBunFile.text(); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed); + if (record.type === "turn_begin" && record.user_input) { + const raw = typeof record.user_input === "string" + ? record.user_input + : JSON.stringify(record.user_input); + this.title = raw.slice(0, 50); + return; + } + } catch { + continue; + } + } + } catch { + // ignore + } + } + } + + // ── Static factories ─────────────────────────────── + + static async create(workDir: string, sessionId?: string): Promise { + workDir = resolve(workDir); + + // Ensure work dir is tracked in global metadata + const metadata = await loadMetadata(); + let wdMeta = getWorkDirMeta(metadata, workDir); + if (!wdMeta) { + wdMeta = newWorkDirMeta(metadata, workDir); + } + + const sessionsDir = getSessionsBaseDir(workDir); + const id = sessionId ?? randomUUID(); + const sessionDir = join(sessionsDir, id); + await Bun.$`mkdir -p ${sessionDir}`.quiet(); + + const contextFile = join(sessionDir, "context.jsonl"); + // Truncate if exists + await Bun.write(contextFile, ""); + + await saveMetadata(metadata); + + const session = new Session({ + id, + workDir, + sessionsDir, + contextFile, + wireFile: join(sessionDir, "wire.jsonl"), + state: SessionState.parse({}), + }); + await session.refresh(); + return session; + } + + static async find(workDir: string, sessionId: string): Promise { + workDir = resolve(workDir); + const sessionsDir = getSessionsBaseDir(workDir); + const sessionDir = join(sessionsDir, sessionId); + + const dirFile = Bun.file(join(sessionDir, "context.jsonl")); + if (!(await dirFile.exists())) return null; + + const state = await loadSessionState(sessionDir); + const session = new Session({ + id: sessionId, + workDir, + sessionsDir, + contextFile: join(sessionDir, "context.jsonl"), + wireFile: join(sessionDir, "wire.jsonl"), + state, + }); + await session.refresh(); + return session; + } + + static async list(workDir: string): Promise { + workDir = resolve(workDir); + const sessionsDir = getSessionsBaseDir(workDir); + + const dirFile = Bun.file(sessionsDir); + if (!(await dirFile.exists())) return []; + + let entries: string[]; + try { + const output = await Bun.$`ls ${sessionsDir}`.quiet().text(); + entries = output.trim().split("\n").filter(Boolean); + } catch { + return []; + } + + const sessions: Session[] = []; + for (const entry of entries) { + const sessionDir = join(sessionsDir, entry); + const contextFile = join(sessionDir, "context.jsonl"); + const ctxFile = Bun.file(contextFile); + if (!(await ctxFile.exists())) continue; + + const state = await loadSessionState(sessionDir); + const session = new Session({ + id: entry, + workDir, + sessionsDir, + contextFile, + wireFile: join(sessionDir, "wire.jsonl"), + state, + }); + + if (await session.isEmpty()) continue; + await session.refresh(); + sessions.push(session); + } + + sessions.sort((a, b) => b.updatedAt - a.updatedAt); + return sessions; + } + + /** + * Continue the most recent session for a workDir. + * Returns the last session or null if none exists. + */ + static async continue_(workDir: string): Promise { + workDir = resolve(workDir); + + // Try global metadata first (Python-compatible) + const metadata = await loadMetadata(); + const wdMeta = getWorkDirMeta(metadata, workDir); + if (wdMeta?.lastSessionId) { + const session = await Session.find(workDir, wdMeta.lastSessionId); + if (session) return session; + } + + // Fallback: find the most recently updated session + const sessions = await Session.list(workDir); + if (sessions.length > 0) { + return sessions[0]!; + } + + return null; + } +} diff --git a/src/kimi_cli_ts/session_fork.ts b/src/kimi_cli_ts/session_fork.ts new file mode 100644 index 000000000..84794da62 --- /dev/null +++ b/src/kimi_cli_ts/session_fork.ts @@ -0,0 +1,316 @@ +/** + * Session fork utilities — corresponds to Python session_fork.py. + * + * Provides turn enumeration, wire/context truncation, and session forking + * for CLI slash commands (/undo, /fork). + */ + +import { join } from "node:path"; +import { existsSync, readFileSync, mkdirSync, writeFileSync, copyFileSync, readdirSync, statSync } from "node:fs"; +import { Session, loadSessionState, saveSessionState } from "./session.ts"; + +const CHECKPOINT_USER_PATTERN = /^CHECKPOINT \d+<\/system>$/; + +// ── Turn info ─────────────────────────────────────── + +export interface TurnInfo { + /** 0-based turn index. */ + index: number; + /** First-line text of the user message. */ + userText: string; +} + +// ── Turn enumeration ──────────────────────────────── + +/** + * Scan wire.jsonl and return a list of all turns with user message text. + */ +export function enumerateTurns(wirePath: string): TurnInfo[] { + if (!existsSync(wirePath)) return []; + + const content = readFileSync(wirePath, "utf-8"); + const turns: TurnInfo[] = []; + let currentTurn = -1; + + for (const line of content.split("\n")) { + const stripped = line.trim(); + if (!stripped) continue; + + let record: any; + try { + record = JSON.parse(stripped); + } catch { + continue; + } + + if (record.type === "metadata") continue; + + const message = record.message ?? {}; + const msgType: string | undefined = message.type; + + if (msgType === "TurnBegin") { + currentTurn++; + const userInput = message.payload?.user_input ?? ""; + const text = extractUserText(userInput); + turns.push({ index: currentTurn, userText: text }); + } + } + + return turns; +} + +function extractUserText(userInput: string | any[]): string { + if (typeof userInput === "string") return userInput; + + const parts: string[] = []; + for (const part of userInput) { + if (typeof part === "object" && part !== null && typeof part.text === "string") { + parts.push(part.text); + } else if (typeof part === "string") { + parts.push(part); + } + } + return parts.join(" "); +} + +// ── Wire / context truncation ─────────────────────── + +/** + * Read wire.jsonl and return all lines up to and including the given turn. + */ +export function truncateWireAtTurn(wirePath: string, turnIndex: number): string[] { + if (!existsSync(wirePath)) { + throw new Error("wire.jsonl not found"); + } + + const content = readFileSync(wirePath, "utf-8"); + const lines: string[] = []; + let currentTurn = -1; + + for (const line of content.split("\n")) { + const stripped = line.trim(); + if (!stripped) continue; + + let record: any; + try { + record = JSON.parse(stripped); + } catch { + continue; + } + + // Always keep metadata header + if (record.type === "metadata") { + lines.push(stripped); + continue; + } + + const message = record.message ?? {}; + const msgType: string | undefined = message.type; + + if (msgType === "TurnBegin") { + currentTurn++; + if (currentTurn > turnIndex) break; + } + + if (currentTurn <= turnIndex) { + lines.push(stripped); + } + + // Stop after the TurnEnd of the target turn + if (msgType === "TurnEnd" && currentTurn === turnIndex) { + break; + } + } + + if (currentTurn < turnIndex) { + throw new Error(`turn_index ${turnIndex} out of range (max turn: ${currentTurn})`); + } + + return lines; +} + +function isCheckpointUserMessage(record: any): boolean { + if (record.role !== "user") return false; + + const content = record.content; + if (typeof content === "string") { + return CHECKPOINT_USER_PATTERN.test(content.trim()); + } + + if (Array.isArray(content) && content.length === 1 && typeof content[0] === "object") { + const text = content[0].text; + if (typeof text === "string") { + return CHECKPOINT_USER_PATTERN.test(text.trim()); + } + } + + return false; +} + +/** + * Read context.jsonl and return all lines up to and including the given turn. + * Best-effort: if context has fewer user turns, returns all available lines. + */ +export function truncateContextAtTurn(contextPath: string, turnIndex: number): string[] { + if (!existsSync(contextPath)) return []; + + const content = readFileSync(contextPath, "utf-8"); + const lines: string[] = []; + let currentTurn = -1; + + for (const line of content.split("\n")) { + const stripped = line.trim(); + if (!stripped) continue; + + let record: any; + try { + record = JSON.parse(stripped); + } catch { + continue; + } + + if (record.role === "user" && !isCheckpointUserMessage(record)) { + currentTurn++; + if (currentTurn > turnIndex) break; + } + + if (currentTurn <= turnIndex) { + lines.push(stripped); + } + } + + return lines; +} + +// ── Full fork operation ───────────────────────────── + +/** + * Fork a session, creating a new session with history up to the given turn. + * + * @param sourceSessionDir - Path to the source session directory. + * @param workDir - The work directory. + * @param turnIndex - 0-based turn index (inclusive). If undefined, copy all turns. + * @param titlePrefix - Prefix for the forked session title. + * @param sourceTitle - Title of the source session. + * @returns The new session ID. + */ +export async function forkSession(opts: { + sourceSessionDir: string; + workDir: string; + turnIndex?: number; + titlePrefix?: string; + sourceTitle?: string; +}): Promise { + const { + sourceSessionDir, + workDir, + turnIndex, + titlePrefix = "Fork", + } = opts; + + const wirePath = join(sourceSessionDir, "wire.jsonl"); + const contextPath = join(sourceSessionDir, "context.jsonl"); + + let truncatedWireLines: string[]; + let truncatedContextLines: string[]; + + if (turnIndex !== undefined) { + truncatedWireLines = truncateWireAtTurn(wirePath, turnIndex); + truncatedContextLines = truncateContextAtTurn(contextPath, turnIndex); + } else { + truncatedWireLines = readAllLines(wirePath); + truncatedContextLines = readAllLines(contextPath); + } + + const newSession = await Session.create(workDir); + const newSessionDir = newSession.dir; + + // Copy referenced video files + copyReferencedVideos(sourceSessionDir, newSessionDir, truncatedWireLines); + + // Write truncated wire.jsonl + const newWirePath = join(newSessionDir, "wire.jsonl"); + writeFileSync(newWirePath, truncatedWireLines.join("\n") + "\n", "utf-8"); + + // Write truncated context.jsonl (overwrites the empty file from create()) + const newContextPath = join(newSessionDir, "context.jsonl"); + writeFileSync(newContextPath, truncatedContextLines.join("\n") + "\n", "utf-8"); + + // Set title + let sourceTitle = opts.sourceTitle; + if (sourceTitle === undefined) { + const srcState = await loadSessionState(sourceSessionDir); + sourceTitle = srcState.custom_title ?? "Untitled"; + } + + const forkTitle = `${titlePrefix}: ${sourceTitle}`; + const newState = await loadSessionState(newSessionDir); + newState.custom_title = forkTitle; + newState.title_generated = true; + + try { + const stat = statSync(newWirePath); + newState.wire_mtime = stat.mtimeMs / 1000; + } catch { + // ignore + } + + await saveSessionState(newState, newSessionDir); + return newSession.id; +} + +function readAllLines(path: string): string[] { + if (!existsSync(path)) return []; + const content = readFileSync(path, "utf-8"); + return content.split("\n").map((l) => l.trim()).filter(Boolean); +} + +function copyReferencedVideos( + sourceDir: string, + newSessionDir: string, + wireLines: string[], +): void { + const sourceUploads = join(sourceDir, "uploads"); + if (!existsSync(sourceUploads)) return; + + let stat: ReturnType; + try { + stat = statSync(sourceUploads); + if (!stat.isDirectory()) return; + } catch { + return; + } + + const videoPattern = /uploads\/([^"\\<>\s]+)/g; + const referencedVideos = new Set(); + + for (const line of wireLines) { + let match: RegExpExecArray | null; + while ((match = videoPattern.exec(line)) !== null) { + const fname = match[1]!; + const ext = fname.split(".").pop()?.toLowerCase() ?? ""; + if (["mp4", "webm", "mkv", "mov", "avi"].includes(ext)) { + referencedVideos.add(fname); + } + } + } + + const filesToCopy = [...referencedVideos].filter((name) => { + try { + return statSync(join(sourceUploads, name)).isFile(); + } catch { + return false; + } + }); + + if (filesToCopy.length > 0) { + const newUploads = join(newSessionDir, "uploads"); + mkdirSync(newUploads, { recursive: true }); + const copiedNames: string[] = []; + for (const vf of filesToCopy) { + copyFileSync(join(sourceUploads, vf), join(newUploads, vf)); + copiedNames.push(vf); + } + writeFileSync(join(newUploads, ".sent"), JSON.stringify(copiedNames), "utf-8"); + } +} diff --git a/src/kimi_cli_ts/skill/flow/d2.ts b/src/kimi_cli_ts/skill/flow/d2.ts new file mode 100644 index 000000000..f357e62f5 --- /dev/null +++ b/src/kimi_cli_ts/skill/flow/d2.ts @@ -0,0 +1,435 @@ +/** + * D2 flowchart parser — corresponds to Python skill/flow/d2.py + */ + +import { + type Flow, + type FlowEdge, + type FlowNode, + type FlowNodeKind, + FlowParseError, + validateFlow, +} from "./index.ts"; + +const NODE_ID_RE = /^[A-Za-z0-9_][A-Za-z0-9_./-]*/; +const BLOCK_TAG_RE = /^\|md$/; +const PROPERTY_SEGMENTS = new Set([ + "shape", "style", "label", "link", "icon", "near", "width", "height", + "direction", "grid-rows", "grid-columns", "grid-gap", "font-size", + "font-family", "font-color", "stroke", "fill", "opacity", "padding", + "border-radius", "shadow", "sketch", "animated", "multiple", + "constraint", "tooltip", +]); + +interface NodeDef { + node: FlowNode; + explicit: boolean; +} + +export function parseD2Flowchart(text: string): Flow { + const normalized = normalizeMarkdownBlocks(text); + const nodes = new Map(); + const outgoing = new Map(); + + for (const [lineNo, statement] of iterTopLevelStatements(normalized)) { + if (hasUnquotedToken(statement, "->")) { + parseEdgeStatement(statement, lineNo, nodes, outgoing); + } else { + parseNodeStatement(statement, lineNo, nodes); + } + } + + const flowNodes: Record = {}; + for (const [id, def] of nodes) { + flowNodes[id] = def.node; + if (!outgoing.has(id)) outgoing.set(id, []); + } + + const outgoingRecord: Record = {}; + for (const [k, v] of outgoing) outgoingRecord[k] = v; + + const inferred = inferDecisionNodes(flowNodes, outgoingRecord); + const [beginId, endId] = validateFlow(inferred, outgoingRecord); + return { nodes: inferred, outgoing: outgoingRecord, beginId, endId }; +} + +function normalizeMarkdownBlocks(text: string): string { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalized.split("\n"); + const outLines: string[] = []; + let i = 0; + let lineNo = 1; + + while (i < lines.length) { + const line = lines[i]!; + const [prefix, suffix] = splitUnquotedOnce(line, ":"); + + if (suffix == null) { + outLines.push(line); + i++; + lineNo++; + continue; + } + + const suffixClean = stripUnquotedComment(suffix).trim(); + if (!BLOCK_TAG_RE.test(suffixClean)) { + outLines.push(line); + i++; + lineNo++; + continue; + } + + const startLine = lineNo; + const blockLines: string[] = []; + i++; + lineNo++; + while (i < lines.length) { + const blockLine = lines[i]!; + if (blockLine.trim() === "|") break; + blockLines.push(blockLine); + i++; + lineNo++; + } + if (i >= lines.length) { + throw new FlowParseError(lineError(startLine, "Unclosed markdown block")); + } + + const dedented = dedentBlock(blockLines); + if (dedented.length > 0 && dedented.some((l) => l.length > 0)) { + const escaped = dedented.map(escapeQuotedLine); + outLines.push(`${prefix}: "${escaped[0]}`); + for (let j = 1; j < escaped.length; j++) { + outLines.push(escaped[j]!); + } + outLines[outLines.length - 1] = `${outLines[outLines.length - 1]}"`; + outLines.push("", ""); + } else { + outLines.push(`${prefix}: ""`); + outLines.push(""); + } + + i++; + lineNo++; + } + + return outLines.join("\n"); +} + +function stripUnquotedComment(text: string): string { + let inSingle = false; + let inDouble = false; + let escape = false; + for (let idx = 0; idx < text.length; idx++) { + const ch = text[idx]!; + if (escape) { escape = false; continue; } + if (ch === "\\" && (inSingle || inDouble)) { escape = true; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === "#" && !inSingle && !inDouble) return text.slice(0, idx); + } + return text; +} + +function dedentBlock(lines: string[]): string[] { + let indent: number | undefined; + for (const line of lines) { + if (!line.trim()) continue; + const stripped = line.replace(/^[ \t]+/, ""); + const lead = line.length - stripped.length; + if (indent === undefined || lead < indent) indent = lead; + } + if (indent === undefined) return lines.map(() => ""); + return lines.map((line) => (line.length >= indent! ? line.slice(indent!) : "")); +} + +function escapeQuotedLine(line: string): string { + return line.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function* iterTopLevelStatements(text: string): Generator<[number, string]> { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + let braceDepth = 0; + let inSingle = false; + let inDouble = false; + let escape = false; + let dropLine = false; + let buf: string[] = []; + let lineNo = 1; + let stmtLine = 1; + let i = 0; + + while (i < normalized.length) { + const ch = normalized[i]!; + const nextCh = i + 1 < normalized.length ? normalized[i + 1]! : ""; + + if (ch === "\\" && nextCh === "\n") { + i += 2; + lineNo++; + continue; + } + + if (ch === "\n") { + if ((inSingle || inDouble) && braceDepth === 0 && !dropLine) { + buf.push("\n"); + lineNo++; + i++; + continue; + } + if (braceDepth === 0 && !inSingle && !inDouble && !dropLine) { + const statement = buf.join("").trim(); + if (statement) yield [stmtLine, statement]; + } + buf = []; + dropLine = false; + stmtLine = lineNo + 1; + lineNo++; + i++; + continue; + } + + if (!inSingle && !inDouble) { + if (ch === "#") { + while (i < normalized.length && normalized[i] !== "\n") i++; + continue; + } + if (ch === "{") { + if (braceDepth === 0) { + const statement = buf.join("").trim(); + if (statement) yield [stmtLine, statement]; + dropLine = true; + buf = []; + } + braceDepth++; + i++; + continue; + } + if (ch === "}" && braceDepth > 0) { + braceDepth--; + i++; + continue; + } + if (ch === "}" && braceDepth === 0) { + throw new FlowParseError(lineError(lineNo, "Unmatched '}'")); + } + } + + if (ch === "'" && !inDouble && !escape) inSingle = !inSingle; + else if (ch === '"' && !inSingle && !escape) inDouble = !inDouble; + + if (escape) escape = false; + else if (ch === "\\" && (inSingle || inDouble)) escape = true; + + if (braceDepth === 0 && !dropLine) buf.push(ch); + i++; + } + + if (braceDepth !== 0) throw new FlowParseError(lineError(lineNo, "Unclosed '{' block")); + if (inSingle || inDouble) throw new FlowParseError(lineError(lineNo, "Unclosed string")); + + const statement = buf.join("").trim(); + if (statement) yield [stmtLine, statement]; +} + +function hasUnquotedToken(text: string, token: string): boolean { + return splitOnToken(text, token).length > 1; +} + +function parseEdgeStatement( + statement: string, + lineNo: number, + nodes: Map, + outgoing: Map, +): void { + const parts = splitOnToken(statement, "->"); + if (parts.length < 2) throw new FlowParseError(lineError(lineNo, "Expected edge arrow")); + + const lastPart = parts[parts.length - 1]!; + const [targetText, edgeLabel] = splitUnquotedOnce(lastPart, ":"); + parts[parts.length - 1] = targetText; + + const nodeIds: string[] = []; + for (let idx = 0; idx < parts.length; idx++) { + const nodeId = parseNodeId(parts[idx]!, lineNo, idx < parts.length - 1); + nodeIds.push(nodeId); + } + + if (nodeIds.some(isPropertyPath)) return; + if (nodeIds.length < 2) throw new FlowParseError(lineError(lineNo, "Edge must have at least two nodes")); + + const label = edgeLabel != null ? parseLabelText(edgeLabel, lineNo) : undefined; + for (let idx = 0; idx < nodeIds.length - 1; idx++) { + const edge: FlowEdge = { + src: nodeIds[idx]!, + dst: nodeIds[idx + 1]!, + label: idx === nodeIds.length - 2 ? label : undefined, + }; + if (!outgoing.has(edge.src)) outgoing.set(edge.src, []); + outgoing.get(edge.src)!.push(edge); + if (!outgoing.has(edge.dst)) outgoing.set(edge.dst, []); + } + + for (const nodeId of nodeIds) { + addNode(nodes, nodeId, undefined, false, lineNo); + } +} + +function parseNodeStatement(statement: string, lineNo: number, nodes: Map): void { + const [nodeText, labelText] = splitUnquotedOnce(statement, ":"); + if (labelText != null && isPropertyPath(nodeText)) return; + const nodeId = parseNodeId(nodeText, lineNo, false); + let label: string | undefined; + let explicit = false; + if (labelText != null && !labelText.trim()) return; + if (labelText != null) { + label = parseLabelText(labelText, lineNo); + explicit = true; + } + addNode(nodes, nodeId, label, explicit, lineNo); +} + +function parseNodeId(text: string, lineNo: number, allowInlineLabel: boolean): string { + let cleaned = text.trim(); + if (allowInlineLabel && cleaned.includes(":")) { + cleaned = splitUnquotedOnce(cleaned, ":")[0].trim(); + } + if (!cleaned) throw new FlowParseError(lineError(lineNo, "Expected node id")); + const match = cleaned.match(NODE_ID_RE); + if (!match || match[0] !== cleaned) { + throw new FlowParseError(lineError(lineNo, `Invalid node id "${cleaned}"`)); + } + return match[0]!; +} + +function isPropertyPath(nodeId: string): boolean { + if (!nodeId.includes(".")) return false; + const parts = nodeId.split(".").filter(Boolean); + for (let i = 1; i < parts.length; i++) { + if (PROPERTY_SEGMENTS.has(parts[i]!) || parts[i]!.startsWith("style")) return true; + } + return PROPERTY_SEGMENTS.has(parts[parts.length - 1]!); +} + +function parseLabelText(text: string, lineNo: number): string { + const label = text.trim(); + if (!label) throw new FlowParseError(lineError(lineNo, "Label cannot be empty")); + if (label[0] === "'" || label[0] === '"') return parseQuotedLabel(label, lineNo); + return label; +} + +function parseQuotedLabel(text: string, lineNo: number): string { + const quote = text[0]!; + const buf: string[] = []; + let escape = false; + let i = 1; + while (i < text.length) { + const ch = text[i]!; + if (escape) { buf.push(ch); escape = false; i++; continue; } + if (ch === "\\") { escape = true; i++; continue; } + if (ch === quote) { + const trailing = text.slice(i + 1).trim(); + if (trailing) throw new FlowParseError(lineError(lineNo, "Unexpected trailing content")); + return buf.join(""); + } + buf.push(ch); + i++; + } + throw new FlowParseError(lineError(lineNo, "Unclosed quoted label")); +} + +function splitOnToken(text: string, token: string): string[] { + const parts: string[] = []; + let buf: string[] = []; + let inSingle = false; + let inDouble = false; + let escape = false; + let i = 0; + + while (i < text.length) { + if (!inSingle && !inDouble && text.startsWith(token, i)) { + parts.push(buf.join("").trim()); + buf = []; + i += token.length; + continue; + } + const ch = text[i]!; + if (escape) escape = false; + else if (ch === "\\" && (inSingle || inDouble)) escape = true; + else if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + buf.push(ch); + i++; + } + if (inSingle || inDouble) throw new FlowParseError("Unclosed string in statement"); + parts.push(buf.join("").trim()); + return parts; +} + +function splitUnquotedOnce(text: string, token: string): [string, string | undefined] { + let inSingle = false; + let inDouble = false; + let escape = false; + for (let idx = 0; idx < text.length; idx++) { + const ch = text[idx]!; + if (escape) { escape = false; continue; } + if (ch === "\\" && (inSingle || inDouble)) { escape = true; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === token && !inSingle && !inDouble) { + return [text.slice(0, idx).trim(), text.slice(idx + 1).trim()]; + } + } + return [text.trim(), undefined]; +} + +function addNode( + nodes: Map, + nodeId: string, + label: string | undefined, + explicit: boolean, + lineNo: number, +): FlowNode { + const effectiveLabel = label ?? nodeId; + const labelNorm = effectiveLabel.trim().toLowerCase(); + if (!effectiveLabel) throw new FlowParseError(lineError(lineNo, "Node label cannot be empty")); + + let kind: FlowNodeKind = "task"; + if (labelNorm === "begin") kind = "begin"; + else if (labelNorm === "end") kind = "end"; + + const node: FlowNode = { id: nodeId, label: effectiveLabel, kind }; + const existing = nodes.get(nodeId); + + if (!existing) { + nodes.set(nodeId, { node, explicit }); + return node; + } + + if (existing.node.id === node.id && existing.node.label === node.label && existing.node.kind === node.kind) { + return existing.node; + } + + if (!explicit && existing.explicit) return existing.node; + if (explicit && !existing.explicit) { + nodes.set(nodeId, { node, explicit: true }); + return node; + } + + throw new FlowParseError(lineError(lineNo, `Conflicting definition for node "${nodeId}"`)); +} + +function inferDecisionNodes( + nodes: Record, + outgoing: Record, +): Record { + const updated: Record = {}; + for (const [nodeId, node] of Object.entries(nodes)) { + let kind = node.kind; + if (kind === "task" && (outgoing[nodeId]?.length ?? 0) > 1) kind = "decision"; + updated[nodeId] = kind !== node.kind ? { id: node.id, label: node.label, kind } : node; + } + return updated; +} + +function lineError(lineNo: number, message: string): string { + return `Line ${lineNo}: ${message}`; +} diff --git a/src/kimi_cli_ts/skill/flow/index.ts b/src/kimi_cli_ts/skill/flow/index.ts new file mode 100644 index 000000000..87f337ec3 --- /dev/null +++ b/src/kimi_cli_ts/skill/flow/index.ts @@ -0,0 +1,110 @@ +/** + * Flow graph types and validation — corresponds to Python skill/flow/__init__.py + */ + +export type FlowNodeKind = "begin" | "end" | "task" | "decision"; + +export class FlowError extends Error { + constructor(message: string) { + super(message); + this.name = "FlowError"; + } +} + +export class FlowParseError extends FlowError { + constructor(message: string) { + super(message); + this.name = "FlowParseError"; + } +} + +export class FlowValidationError extends FlowError { + constructor(message: string) { + super(message); + this.name = "FlowValidationError"; + } +} + +export interface FlowNode { + readonly id: string; + readonly label: string; + readonly kind: FlowNodeKind; +} + +export interface FlowEdge { + readonly src: string; + readonly dst: string; + readonly label: string | undefined; +} + +export interface Flow { + readonly nodes: Record; + readonly outgoing: Record; + readonly beginId: string; + readonly endId: string; +} + +const CHOICE_RE = /([^<]*)<\/choice>/g; + +export function parseChoice(text: string): string | undefined { + const matches = [...(text || "").matchAll(CHOICE_RE)]; + if (matches.length === 0) return undefined; + return matches[matches.length - 1]![1]!.trim(); +} + +export function validateFlow( + nodes: Record, + outgoing: Record, +): [string, string] { + const beginIds = Object.values(nodes) + .filter((n) => n.kind === "begin") + .map((n) => n.id); + const endIds = Object.values(nodes) + .filter((n) => n.kind === "end") + .map((n) => n.id); + + if (beginIds.length !== 1) { + throw new FlowValidationError(`Expected exactly one BEGIN node, found ${beginIds.length}`); + } + if (endIds.length !== 1) { + throw new FlowValidationError(`Expected exactly one END node, found ${endIds.length}`); + } + + const beginId = beginIds[0]!; + const endId = endIds[0]!; + + // BFS reachability + const reachable = new Set(); + const queue = [beginId]; + while (queue.length > 0) { + const nodeId = queue.pop()!; + if (reachable.has(nodeId)) continue; + reachable.add(nodeId); + for (const edge of outgoing[nodeId] ?? []) { + if (!reachable.has(edge.dst)) queue.push(edge.dst); + } + } + + // Validate decision nodes have labeled, unique edges + for (const node of Object.values(nodes)) { + if (!reachable.has(node.id)) continue; + const edges = outgoing[node.id] ?? []; + if (edges.length <= 1) continue; + const labels: string[] = []; + for (const edge of edges) { + if (!edge.label?.trim()) { + throw new FlowValidationError(`Node "${node.id}" has an unlabeled edge`); + } + labels.push(edge.label); + } + if (new Set(labels).size !== labels.length) { + throw new FlowValidationError(`Node "${node.id}" has duplicate edge labels`); + } + } + + if (!reachable.has(endId)) { + throw new FlowValidationError("END node is not reachable from BEGIN"); + } + + return [beginId, endId]; +} diff --git a/src/kimi_cli_ts/skill/flow/mermaid.ts b/src/kimi_cli_ts/skill/flow/mermaid.ts new file mode 100644 index 000000000..3a816da26 --- /dev/null +++ b/src/kimi_cli_ts/skill/flow/mermaid.ts @@ -0,0 +1,273 @@ +/** + * Mermaid flowchart parser — corresponds to Python skill/flow/mermaid.py + */ + +import { + type Flow, + type FlowEdge, + type FlowNode, + type FlowNodeKind, + FlowParseError, + validateFlow, +} from "./index.ts"; + +interface NodeSpec { + nodeId: string; + label: string | undefined; +} + +interface NodeDef { + node: FlowNode; + explicit: boolean; +} + +const NODE_ID_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*/; +const HEADER_RE = /^(flowchart|graph)\b/i; + +const SHAPES: Record = { "[": "]", "(": ")", "{": "}" }; +const PIPE_LABEL_RE = /\|([^|]*)\|/; +const EDGE_LABEL_RE = /--\s*([^>-][^>]*)\s*-->/; +const ARROW_RE = /[-.=]+>/g; + +export function parseMermaidFlowchart(text: string): Flow { + const nodes = new Map(); + const outgoing = new Map(); + + for (const [lineNo, rawLine] of text.split("\n").entries()) { + const line = stripComment(rawLine).trim(); + if (!line || line.startsWith("%%")) continue; + if (HEADER_RE.test(line)) continue; + if (isStyleLine(line)) continue; + const cleaned = stripStyleTokens(line); + + const edge = tryParseEdgeLine(cleaned, lineNo + 1); + if (edge) { + const [srcSpec, label, dstSpec] = edge; + const srcNode = addNode(nodes, srcSpec, lineNo + 1); + const dstNode = addNode(nodes, dstSpec, lineNo + 1); + const flowEdge: FlowEdge = { src: srcNode.id, dst: dstNode.id, label }; + if (!outgoing.has(flowEdge.src)) outgoing.set(flowEdge.src, []); + outgoing.get(flowEdge.src)!.push(flowEdge); + if (!outgoing.has(flowEdge.dst)) outgoing.set(flowEdge.dst, []); + continue; + } + + const nodeSpec = tryParseNodeLine(cleaned, lineNo + 1); + if (nodeSpec) addNode(nodes, nodeSpec, lineNo + 1); + } + + const flowNodes: Record = {}; + for (const [id, def] of nodes) { + flowNodes[id] = def.node; + if (!outgoing.has(id)) outgoing.set(id, []); + } + + const outgoingRecord: Record = {}; + for (const [k, v] of outgoing) outgoingRecord[k] = v; + + const inferred = inferDecisionNodes(flowNodes, outgoingRecord); + const [beginId, endId] = validateFlow(inferred, outgoingRecord); + return { nodes: inferred, outgoing: outgoingRecord, beginId, endId }; +} + +function tryParseEdgeLine(line: string, lineNo: number): [NodeSpec, string | undefined, NodeSpec] | undefined { + let srcSpec: NodeSpec; + let idx: number; + try { + [srcSpec, idx] = parseNodeToken(line, 0, lineNo); + } catch { + return undefined; + } + + const [normalized, label] = normalizeEdgeLine(line); + idx = skipWs(normalized, idx); + let norm = normalized; + if (!norm.slice(idx).includes(">")) { + if (!norm.slice(idx).includes("---")) return undefined; + norm = norm.slice(0, idx) + norm.slice(idx).replace("---", "-->"); + } + + norm = norm.replace(ARROW_RE, "-->"); + const arrowIdx = norm.lastIndexOf(">"); + if (arrowIdx === -1) return undefined; + + const dstStart = skipWs(norm, arrowIdx + 1); + let dstSpec: NodeSpec; + try { + [dstSpec] = parseNodeToken(norm, dstStart, lineNo); + } catch { + return undefined; + } + + return [srcSpec, label, dstSpec]; +} + +function parseNodeToken(line: string, idx: number, lineNo: number): [NodeSpec, number] { + const match = line.slice(idx).match(NODE_ID_RE); + if (!match) throw new FlowParseError(lineError(lineNo, "Expected node id")); + const nodeId = match[0]!; + idx += match[0]!.length; + + if (idx >= line.length || !(line[idx]! in SHAPES)) { + return [{ nodeId, label: undefined }, idx]; + } + + const closeChar = SHAPES[line[idx]!]!; + idx++; + const [label, newIdx] = parseLabel(line, idx, closeChar, lineNo); + return [{ nodeId, label }, newIdx]; +} + +function parseLabel(line: string, idx: number, closeChar: string, lineNo: number): [string, number] { + if (idx >= line.length) throw new FlowParseError(lineError(lineNo, "Expected node label")); + + if (closeChar === ")" && line[idx] === "[") { + const [label, newIdx] = parseLabel(line, idx + 1, "]", lineNo); + let i = newIdx; + while (i < line.length && line[i] === " ") i++; + if (i >= line.length || line[i] !== ")") { + throw new FlowParseError(lineError(lineNo, "Unclosed node label")); + } + return [label, i + 1]; + } + + if (line[idx] === '"') { + idx++; + const buf: string[] = []; + while (idx < line.length) { + const ch = line[idx]!; + if (ch === '"') { + idx++; + while (idx < line.length && line[idx] === " ") idx++; + if (idx >= line.length || line[idx] !== closeChar) { + throw new FlowParseError(lineError(lineNo, "Unclosed node label")); + } + return [buf.join(""), idx + 1]; + } + if (ch === "\\" && idx + 1 < line.length) { + buf.push(line[idx + 1]!); + idx += 2; + continue; + } + buf.push(ch); + idx++; + } + throw new FlowParseError(lineError(lineNo, "Unclosed quoted label")); + } + + const end = line.indexOf(closeChar, idx); + if (end === -1) throw new FlowParseError(lineError(lineNo, "Unclosed node label")); + const label = line.slice(idx, end).trim(); + if (!label) throw new FlowParseError(lineError(lineNo, "Node label cannot be empty")); + return [label, end + 1]; +} + +function skipWs(line: string, idx: number): number { + while (idx < line.length && line[idx] === " ") idx++; + return idx; +} + +function addNode(nodes: Map, spec: NodeSpec, lineNo: number): FlowNode { + const label = spec.label ?? spec.nodeId; + const labelNorm = label.trim().toLowerCase(); + if (!label) throw new FlowParseError(lineError(lineNo, "Node label cannot be empty")); + + let kind: FlowNodeKind = "task"; + if (labelNorm === "begin") kind = "begin"; + else if (labelNorm === "end") kind = "end"; + + const node: FlowNode = { id: spec.nodeId, label, kind }; + const explicit = spec.label != null; + const existing = nodes.get(spec.nodeId); + + if (!existing) { + nodes.set(spec.nodeId, { node, explicit }); + return node; + } + + if (existing.node.id === node.id && existing.node.label === node.label && existing.node.kind === node.kind) { + return existing.node; + } + + if (!explicit && existing.explicit) return existing.node; + if (explicit && !existing.explicit) { + nodes.set(spec.nodeId, { node, explicit: true }); + return node; + } + + throw new FlowParseError(lineError(lineNo, `Conflicting definition for node "${spec.nodeId}"`)); +} + +function lineError(lineNo: number, message: string): string { + return `Line ${lineNo}: ${message}`; +} + +function stripComment(line: string): string { + if (!line.includes("%%")) return line; + return line.split("%%")[0]!; +} + +function isStyleLine(line: string): boolean { + const lowered = line.toLowerCase(); + if (lowered === "end") return true; + return lowered.startsWith("classdef ") || + lowered.startsWith("class ") || + lowered.startsWith("style ") || + lowered.startsWith("linkstyle ") || + lowered.startsWith("click ") || + lowered.startsWith("subgraph ") || + lowered.startsWith("direction "); +} + +function stripStyleTokens(line: string): string { + return line.replace(/:::[A-Za-z0-9_-]+/g, ""); +} + +function tryParseNodeLine(line: string, lineNo: number): NodeSpec | undefined { + try { + const [spec] = parseNodeToken(line, 0, lineNo); + return spec; + } catch { + return undefined; + } +} + +function normalizeEdgeLine(line: string): [string, string | undefined] { + let label: string | undefined; + let normalized = line; + + const pipeMatch = PIPE_LABEL_RE.exec(normalized); + if (pipeMatch) { + label = pipeMatch[1]!.trim() || undefined; + normalized = normalized.slice(0, pipeMatch.index) + normalized.slice(pipeMatch.index! + pipeMatch[0]!.length); + } + + if (label == null) { + const edgeMatch = EDGE_LABEL_RE.exec(normalized); + if (edgeMatch) { + label = edgeMatch[1]!.trim() || undefined; + normalized = normalized.slice(0, edgeMatch.index) + "-->" + normalized.slice(edgeMatch.index! + edgeMatch[0]!.length); + } + } + + return [normalized, label]; +} + +function inferDecisionNodes( + nodes: Record, + outgoing: Record, +): Record { + const updated: Record = {}; + for (const [nodeId, node] of Object.entries(nodes)) { + let kind = node.kind; + if (kind === "task" && (outgoing[nodeId]?.length ?? 0) > 1) { + kind = "decision"; + } + if (kind !== node.kind) { + updated[nodeId] = { id: node.id, label: node.label, kind }; + } else { + updated[nodeId] = node; + } + } + return updated; +} diff --git a/src/kimi_cli_ts/skill/index.ts b/src/kimi_cli_ts/skill/index.ts new file mode 100644 index 000000000..ee3a8a05c --- /dev/null +++ b/src/kimi_cli_ts/skill/index.ts @@ -0,0 +1,379 @@ +/** + * Skill specification discovery and loading — corresponds to Python skill/__init__.py + */ + +import { join, resolve, dirname } from "node:path"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { logger } from "../utils/logging.ts"; +import type { Flow } from "./flow/index.ts"; +import { FlowError } from "./flow/index.ts"; +import { parseMermaidFlowchart } from "./flow/mermaid.ts"; +import { parseD2Flowchart } from "./flow/d2.ts"; + +export type SkillType = "standard" | "flow"; + +export interface Skill { + readonly name: string; + readonly description: string; + readonly type: SkillType; + readonly dir: string; + readonly flow?: Flow; + readonly skillMdFile: string; +} + +// ── Directory discovery ── + +export function getBuiltinSkillsDir(): string { + return join(dirname(new URL(import.meta.url).pathname), "..", "skills"); +} + +/** + * Brand directories — tool-specific paths that share skills across + * Kimi CLI / Claude / Codex. + */ +function getUserBrandSkillsDirCandidates(): string[] { + const home = homedir(); + return [ + join(home, ".kimi", "skills"), + join(home, ".claude", "skills"), + join(home, ".codex", "skills"), + ]; +} + +/** + * Generic directories — cross-tool standard paths. + */ +function getUserGenericSkillsDirCandidates(): string[] { + const home = homedir(); + return [ + join(home, ".config", "agents", "skills"), + join(home, ".agents", "skills"), + ]; +} + +/** @deprecated Use {@link findUserSkillsDirs} instead. */ +export function getUserSkillsDirCandidates(): string[] { + return [...getUserGenericSkillsDirCandidates(), ...getUserBrandSkillsDirCandidates()]; +} + +function getProjectBrandSkillsDirCandidates(workDir: string): string[] { + return [ + join(workDir, ".kimi", "skills"), + join(workDir, ".claude", "skills"), + join(workDir, ".codex", "skills"), + ]; +} + +function getProjectGenericSkillsDirCandidates(workDir: string): string[] { + return [ + join(workDir, ".agents", "skills"), + ]; +} + +/** @deprecated Use {@link findProjectSkillsDirs} instead. */ +export function getProjectSkillsDirCandidates(workDir: string): string[] { + return [...getProjectGenericSkillsDirCandidates(workDir), ...getProjectBrandSkillsDirCandidates(workDir)]; +} + +export function findFirstExistingDir(candidates: string[]): string | undefined { + for (const candidate of candidates) { + try { + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + return candidate; + } + } catch { + continue; + } + } + return undefined; +} + +function findAllExistingDirs(candidates: string[]): string[] { + const dirs: string[] = []; + for (const candidate of candidates) { + try { + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + dirs.push(candidate); + } + } catch { + continue; + } + } + return dirs; +} + +/** + * Return the user-level skills directories. + * + * Brand and generic groups are searched independently — the first existing + * directory inside each group is selected, then both results are merged so + * that brand-specific skills and generic skills coexist. + * + * When `mergeBrands` is true every existing brand directory is included + * instead of only the first one. + */ +export function findUserSkillsDirs(opts?: { mergeBrands?: boolean }): string[] { + const dirs: string[] = []; + + // Brand group — higher priority (comes first so brand skills win on name conflicts) + if (opts?.mergeBrands) { + dirs.push(...findAllExistingDirs(getUserBrandSkillsDirCandidates())); + } else { + const brandDir = findFirstExistingDir(getUserBrandSkillsDirCandidates()); + if (brandDir) dirs.push(brandDir); + } + + // Generic group + const genericDir = findFirstExistingDir(getUserGenericSkillsDirCandidates()); + if (genericDir) dirs.push(genericDir); + + return dirs; +} + +/** @deprecated Use {@link findUserSkillsDirs} instead. */ +export function findUserSkillsDir(): string | undefined { + return findFirstExistingDir(getUserSkillsDirCandidates()); +} + +/** + * Return the project-level skills directories. + * + * Same two-group strategy as {@link findUserSkillsDirs}. + */ +export function findProjectSkillsDirs(workDir: string, opts?: { mergeBrands?: boolean }): string[] { + const dirs: string[] = []; + + if (opts?.mergeBrands) { + dirs.push(...findAllExistingDirs(getProjectBrandSkillsDirCandidates(workDir))); + } else { + const brandDir = findFirstExistingDir(getProjectBrandSkillsDirCandidates(workDir)); + if (brandDir) dirs.push(brandDir); + } + + const genericDir = findFirstExistingDir(getProjectGenericSkillsDirCandidates(workDir)); + if (genericDir) dirs.push(genericDir); + + return dirs; +} + +/** @deprecated Use {@link findProjectSkillsDirs} instead. */ +export function findProjectSkillsDir(workDir: string): string | undefined { + return findFirstExistingDir(getProjectSkillsDirCandidates(workDir)); +} + +export function resolveSkillsRoots( + workDir: string, + opts?: { skillsDirs?: string[]; mergeBrands?: boolean }, +): string[] { + const roots: string[] = []; + const builtinDir = getBuiltinSkillsDir(); + if (existsSync(builtinDir)) roots.push(builtinDir); + + if (opts?.skillsDirs && opts.skillsDirs.length > 0) { + roots.push(...opts.skillsDirs); + } else { + const userDirs = findUserSkillsDirs({ mergeBrands: opts?.mergeBrands }); + roots.push(...userDirs); + const projectDirs = findProjectSkillsDirs(workDir, { mergeBrands: opts?.mergeBrands }); + roots.push(...projectDirs); + } + return roots; +} + +// ── Skill parsing ── + +export function normalizeSkillName(name: string): string { + return name.toLowerCase(); +} + +export function indexSkills(skills: Skill[]): Map { + const map = new Map(); + for (const skill of skills) { + map.set(normalizeSkillName(skill.name), skill); + } + return map; +} + +export function discoverSkillsFromRoots(skillsDirs: string[]): Skill[] { + const skillsByName = new Map(); + for (const dir of skillsDirs) { + for (const skill of discoverSkills(dir)) { + const key = normalizeSkillName(skill.name); + if (!skillsByName.has(key)) { + skillsByName.set(key, skill); + } + } + } + return [...skillsByName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function readSkillText(skill: Skill): string | undefined { + try { + return readFileSync(skill.skillMdFile, "utf-8").trim(); + } catch { + logger.warn(`Failed to read skill file ${skill.skillMdFile}`); + return undefined; + } +} + +export function discoverSkills(skillsDir: string): Skill[] { + if (!existsSync(skillsDir)) return []; + try { + if (!statSync(skillsDir).isDirectory()) return []; + } catch { + return []; + } + + const skills: Skill[] = []; + for (const entry of readdirSync(skillsDir)) { + const skillDir = join(skillsDir, entry); + try { + if (!statSync(skillDir).isDirectory()) continue; + } catch { + continue; + } + const skillMd = join(skillDir, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + try { + const content = readFileSync(skillMd, "utf-8"); + skills.push(parseSkillText(content, skillDir)); + } catch (err) { + logger.info(`Skipping invalid skill at ${skillMd}: ${err}`); + } + } + return skills.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function parseSkillText(content: string, dirPath: string): Skill { + const frontmatter = parseFrontmatter(content) ?? {}; + const name = (frontmatter.name as string) || dirPath.split("/").pop() || "unknown"; + const description = (frontmatter.description as string) || "No description provided."; + let skillType: SkillType = ((frontmatter.type as string) || "standard") as SkillType; + + if (skillType !== "standard" && skillType !== "flow") { + throw new Error(`Invalid skill type "${skillType}"`); + } + + let flow: Flow | undefined; + if (skillType === "flow") { + try { + flow = parseFlowFromSkill(content); + } catch (err) { + logger.error(`Failed to parse flow skill ${name}: ${err}`); + skillType = "standard"; + flow = undefined; + } + } + + return { + name, + description, + type: skillType, + dir: dirPath, + flow, + skillMdFile: join(dirPath, "SKILL.md"), + }; +} + +function parseFlowFromSkill(content: string): Flow { + for (const [lang, code] of iterFencedCodeblocks(content)) { + if (lang === "mermaid") return parseMermaidFlowchart(code); + if (lang === "d2") return parseD2Flowchart(code); + } + throw new Error("Flow skills require a mermaid or d2 code block in SKILL.md."); +} + +function* iterFencedCodeblocks(content: string): Generator<[string, string]> { + let fence = ""; + let fenceChar = ""; + let lang = ""; + let buf: string[] = []; + let inBlock = false; + + for (const line of content.split("\n")) { + const stripped = line.trimStart(); + if (!inBlock) { + const match = parseFenceOpen(stripped); + if (match) { + [fence, fenceChar, lang] = match; + lang = normalizeCodeLang(lang); + inBlock = true; + buf = []; + } + continue; + } + + if (isFenceClose(stripped, fenceChar, fence.length)) { + yield [lang, buf.join("\n").replace(/^\n+|\n+$/g, "")]; + inBlock = false; + fence = ""; + fenceChar = ""; + lang = ""; + buf = []; + continue; + } + + buf.push(line); + } +} + +function normalizeCodeLang(info: string): string { + if (!info) return ""; + let lang = info.split(/\s+/)[0]!.trim().toLowerCase(); + if (lang.startsWith("{") && lang.endsWith("}")) { + lang = lang.slice(1, -1).trim(); + } + return lang; +} + +function parseFenceOpen(line: string): [string, string, string] | undefined { + if (!line || (line[0] !== "`" && line[0] !== "~")) return undefined; + const fenceChar = line[0]!; + let count = 0; + for (const ch of line) { + if (ch === fenceChar) count++; + else break; + } + if (count < 3) return undefined; + const fence = fenceChar.repeat(count); + const info = line.slice(count).trim(); + return [fence, fenceChar, info]; +} + +function isFenceClose(line: string, fenceChar: string, fenceLen: number): boolean { + if (!fenceChar || !line || line[0] !== fenceChar) return false; + let count = 0; + for (const ch of line) { + if (ch === fenceChar) count++; + else break; + } + if (count < fenceLen) return false; + return !line.slice(count).trim(); +} + +// Simple frontmatter parser +function parseFrontmatter(content: string): Record | undefined { + const lines = content.split("\n"); + if (lines[0]?.trim() !== "---") return undefined; + + const result: Record = {}; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]!; + if (line.trim() === "---") return result; + const colonIdx = line.indexOf(":"); + if (colonIdx > 0) { + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + // Strip quotes + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + result[key] = value.slice(1, -1); + } else { + result[key] = value; + } + } + } + return undefined; // No closing --- +} diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts new file mode 100644 index 000000000..72d1ded9a --- /dev/null +++ b/src/kimi_cli_ts/soul/agent.ts @@ -0,0 +1,471 @@ +/** + * Agent & Runtime — corresponds to Python soul/agent.py + * Runtime execution environment and Agent loading. + */ + +import type { Config, LoopControl } from "../config.ts"; +import type { LLM } from "../llm.ts"; +import type { Session } from "../session.ts"; +import type { HookEngine } from "../hooks/engine.ts"; +import type { ModelCapability } from "../types.ts"; +import { Approval, ApprovalState } from "./approval.ts"; +import { KimiToolset } from "./toolset.ts"; +import { SlashCommandRegistry, createDefaultRegistry } from "./slash.ts"; +import { Context } from "./context.ts"; +import { logger } from "../utils/logging.ts"; +import type { LaborMarket } from "../subagents/registry.ts"; +import type { SubagentStore } from "../subagents/store.ts"; +import type { ApprovalRuntime } from "../approval_runtime/index.ts"; + +// ── Built-in system prompt args ────────────────────── + +export interface BuiltinSystemPromptArgs { + KIMI_NOW: string; + KIMI_WORK_DIR: string; + KIMI_WORK_DIR_LS: string; + KIMI_AGENTS_MD: string; + KIMI_SKILLS: string; + KIMI_ADDITIONAL_DIRS_INFO: string; + KIMI_OS: string; + KIMI_SHELL: string; +} + +// ── Runtime ────────────────────────────────────────── + +export class Runtime { + config: Config; + llm: LLM | null; + session: Session; + approval: Approval; + hookEngine: HookEngine; + builtinArgs: BuiltinSystemPromptArgs; + role: "root" | "subagent"; + additionalDirs: string[]; + laborMarket: LaborMarket | null; + subagentStore: SubagentStore | null; + approvalRuntime: ApprovalRuntime | null; + + constructor(opts: { + config: Config; + llm: LLM | null; + session: Session; + approval: Approval; + hookEngine: HookEngine; + builtinArgs: BuiltinSystemPromptArgs; + role?: "root" | "subagent"; + additionalDirs?: string[]; + laborMarket?: LaborMarket | null; + subagentStore?: SubagentStore | null; + approvalRuntime?: ApprovalRuntime | null; + }) { + this.config = opts.config; + this.llm = opts.llm; + this.session = opts.session; + this.approval = opts.approval; + this.hookEngine = opts.hookEngine; + this.builtinArgs = opts.builtinArgs; + this.role = opts.role ?? "root"; + this.additionalDirs = opts.additionalDirs ?? []; + this.laborMarket = opts.laborMarket ?? null; + this.subagentStore = opts.subagentStore ?? null; + this.approvalRuntime = opts.approvalRuntime ?? null; + } + + get loopControl(): LoopControl { + return this.config.loop_control; + } + + /** Create runtime with defaults. */ + static async create(opts: { + config: Config; + llm: LLM | null; + session: Session; + hookEngine: HookEngine; + }): Promise { + const workDir = opts.session.workDir; + + // Build system prompt args + let workDirLs = ""; + try { + const result = await Bun.$`ls -la ${workDir}`.quiet().text(); + workDirLs = result.trim(); + } catch { + workDirLs = "(unable to list directory)"; + } + + const osType = + process.platform === "darwin" + ? "macOS" + : process.platform === "win32" + ? "Windows" + : "Linux"; + + const shell = process.env.SHELL ?? "/bin/bash"; + + const builtinArgs: BuiltinSystemPromptArgs = { + KIMI_NOW: new Date().toISOString(), + KIMI_WORK_DIR: workDir, + KIMI_WORK_DIR_LS: workDirLs, + KIMI_AGENTS_MD: await loadAgentsMd(workDir) ?? "", + KIMI_SKILLS: "", // TODO: list skills + KIMI_ADDITIONAL_DIRS_INFO: opts.session.state.additional_dirs.length > 0 + ? `Additional directories: ${opts.session.state.additional_dirs.join(", ")}` + : "", + KIMI_OS: osType, + KIMI_SHELL: shell, + }; + + // Restore additional directories from session state + const additionalDirs = opts.session.state.additional_dirs.filter( + (d: string) => { + try { + const { statSync } = require("node:fs"); + return statSync(d).isDirectory(); + } catch { + return false; + } + }, + ); + + // Restore approval state from session + const approvalState = new ApprovalState({ + yolo: + opts.config.default_yolo || opts.session.state.approval.yolo, + autoApproveActions: new Set( + opts.session.state.approval.auto_approve_actions, + ), + }); + + const approval = new Approval({ state: approvalState }); + + return new Runtime({ + config: opts.config, + llm: opts.llm, + session: opts.session, + approval, + hookEngine: opts.hookEngine, + builtinArgs, + additionalDirs, + }); + } + + /** Create a copy for subagents with shared state. */ + copyForSubagent(): Runtime { + return new Runtime({ + config: this.config, + llm: this.llm, + session: this.session, + approval: this.approval.share(), + hookEngine: this.hookEngine, + builtinArgs: { + ...this.builtinArgs, + KIMI_NOW: new Date().toISOString(), + }, + role: "subagent", + // Share the same list reference so /add-dir mutations propagate to all agents + additionalDirs: this.additionalDirs, + laborMarket: this.laborMarket, + subagentStore: this.subagentStore, + approvalRuntime: this.approvalRuntime, + }); + } +} + +// ── Agent ────────────────────────────────────────────── + +export class Agent { + readonly name: string; + readonly systemPrompt: string; + readonly toolset: KimiToolset; + readonly runtime: Runtime; + readonly slashCommands: SlashCommandRegistry; + + constructor(opts: { + name: string; + systemPrompt: string; + toolset: KimiToolset; + runtime: Runtime; + slashCommands?: SlashCommandRegistry; + }) { + this.name = opts.name; + this.systemPrompt = opts.systemPrompt; + this.toolset = opts.toolset; + this.runtime = opts.runtime; + this.slashCommands = opts.slashCommands ?? createDefaultRegistry(); + } + + get modelCapabilities(): Set | null { + return this.runtime.llm?.capabilities ?? null; + } + + get modelName(): string { + return this.runtime.llm?.modelName ?? "unknown"; + } +} + +// ── Agent loader ───────────────────────────────────── + +/** + * Load an agent with its toolset and system prompt. + */ +export async function loadAgent(opts: { + runtime: Runtime; + agentName?: string; + systemPromptOverride?: string; +}): Promise { + const { runtime, agentName = "default" } = opts; + + // Load system prompt + let systemPrompt = opts.systemPromptOverride ?? ""; + if (!systemPrompt) { + systemPrompt = await loadSystemPrompt(agentName, runtime.builtinArgs); + } + + // Create toolset + const toolset = new KimiToolset({ + context: { + workingDir: runtime.session.workDir, + signal: new AbortController().signal, + approval: async (toolName: string, _action: string, description: string) => { + const result = await runtime.approval.request( + toolName, + toolName, + description, + ); + return result.approved ? "approve" : "reject"; + }, + wireEmit: () => {}, // Will be wired by KimiSoul + serviceConfig: { + moonshotSearch: runtime.config.services.moonshot_search + ? { + baseUrl: runtime.config.services.moonshot_search.base_url, + apiKey: runtime.config.services.moonshot_search.api_key, + customHeaders: runtime.config.services.moonshot_search.custom_headers, + } + : undefined, + moonshotFetch: runtime.config.services.moonshot_fetch + ? { + baseUrl: runtime.config.services.moonshot_fetch.base_url, + apiKey: runtime.config.services.moonshot_fetch.api_key, + customHeaders: runtime.config.services.moonshot_fetch.custom_headers, + } + : undefined, + }, + runtime, + }, + hookEngine: runtime.hookEngine, + }); + + // Register built-in tools + await registerBuiltinTools(toolset, runtime); + + return new Agent({ + name: agentName, + systemPrompt, + toolset, + runtime, + }); +} + +async function loadSystemPrompt( + agentName: string, + args: BuiltinSystemPromptArgs, +): Promise { + // Try to load from agents/default/system.md + const paths = [ + `src/kimi_cli/agents/${agentName}/system.md`, + `agents/${agentName}/system.md`, + ]; + + for (const p of paths) { + const file = Bun.file(p); + if (await file.exists()) { + let template = await file.text(); + // Simple template substitution (${VAR} syntax) + for (const [key, value] of Object.entries(args)) { + template = template.replaceAll(`\${${key}}`, String(value)); + } + return template; + } + } + + // Fallback system prompt + return [ + "You are Kimi, an AI assistant running in a terminal.", + `Current working directory: ${args.KIMI_WORK_DIR}`, + `OS: ${args.KIMI_OS}, Shell: ${args.KIMI_SHELL}`, + `Current date: ${args.KIMI_NOW}`, + "", + "You have access to tools for reading/writing files, running shell commands,", + "and searching the web. Use them to help the user with their tasks.", + ].join("\n"); +} + +async function registerBuiltinTools(toolset: KimiToolset, runtime: Runtime): Promise { + // Import and register all built-in tools + const toolModules = [ + () => import("../tools/file/read.ts"), + () => import("../tools/file/write.ts"), + () => import("../tools/file/replace.ts"), + () => import("../tools/file/glob.ts"), + () => import("../tools/file/grep.ts"), + () => import("../tools/shell/shell.ts"), + () => import("../tools/web/fetch.ts"), + () => import("../tools/web/search.ts"), + () => import("../tools/think/think.ts"), + () => import("../tools/ask_user/ask_user.ts"), + () => import("../tools/todo/todo.ts"), + () => import("../tools/plan/plan.ts"), + ]; + + for (const loadModule of toolModules) { + try { + const mod = await loadModule(); + // Find exported classes that look like tools + for (const [_key, value] of Object.entries(mod)) { + if ( + typeof value === "function" && + value.prototype && + typeof value.prototype.execute === "function" && + typeof value.prototype.toDefinition === "function" + ) { + try { + const instance = new (value as new () => any)(); + if (instance.name) { + toolset.add(instance); + } + } catch { + // Some tools need constructor args, skip + } + } + } + } catch (err) { + logger.warn(`Failed to load tool module: ${err}`); + } + } + + // Register Agent tool (needs runtime for description building) + try { + const { AgentTool } = await import("../tools/agent/agent.ts"); + const agentTool = new AgentTool(); + agentTool.buildDescription(runtime); + toolset.add(agentTool); + } catch (err) { + logger.warn(`Failed to load Agent tool: ${err}`); + } +} + +// ── AGENTS.md loader ──────────────────────────────── + +const AGENTS_MD_MAX_BYTES = 32 * 1024; // 32 KiB + +/** + * Find the nearest git root by walking up from workDir. + */ +async function findProjectRoot(workDir: string): Promise { + const { resolve, dirname } = await import("node:path"); + let current = resolve(workDir); + while (true) { + const gitFile = Bun.file(`${current}/.git`); + if (await gitFile.exists()) return current; + const parent = dirname(current); + if (parent === current) return resolve(workDir); + current = parent; + } +} + +/** + * Return the list of directories from projectRoot down to workDir (inclusive). + */ +function dirsRootToLeaf(workDir: string, projectRoot: string): string[] { + const { resolve, dirname } = require("node:path"); + const dirs: string[] = []; + let current = resolve(workDir); + const root = resolve(projectRoot); + while (true) { + dirs.push(current); + if (current === root) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + dirs.reverse(); // root → leaf + return dirs; +} + +/** + * Discover and merge AGENTS.md files from the project root down to workDir. + * Matches Python's `load_agents_md` behavior. + */ +export async function loadAgentsMd(workDir: string): Promise { + const projectRoot = await findProjectRoot(workDir); + const dirs = dirsRootToLeaf(workDir, projectRoot); + + // Phase 1: collect all candidate files (root → leaf order) + const discovered: { path: string; content: string }[] = []; + for (const d of dirs) { + const candidates: string[] = []; + + // .kimi/AGENTS.md — highest priority + const kimiPath = `${d}/.kimi/AGENTS.md`; + if (await Bun.file(kimiPath).exists()) { + candidates.push(kimiPath); + } + + // AGENTS.md or agents.md — mutually exclusive + const upperPath = `${d}/AGENTS.md`; + const lowerPath = `${d}/agents.md`; + if (await Bun.file(upperPath).exists()) { + candidates.push(upperPath); + } else if (await Bun.file(lowerPath).exists()) { + candidates.push(lowerPath); + } + + for (const path of candidates) { + const content = (await Bun.file(path).text()).trim(); + if (content) { + discovered.push({ path, content }); + logger.info(`Loaded agents.md: ${path}`); + } + } + } + + if (discovered.length === 0) return null; + + // Phase 2: allocate budget leaf-first + let remaining = AGENTS_MD_MAX_BYTES; + const budgeted: { path: string; content: string }[] = new Array(discovered.length); + for (let i = discovered.length - 1; i >= 0; i--) { + const { path, content } = discovered[i]!; + const annotation = `\n`; + const separatorCost = i < discovered.length - 1 ? 2 : 0; // "\n\n" + const overhead = Buffer.byteLength(annotation) + separatorCost; + remaining -= overhead; + if (remaining <= 0) { + budgeted[i] = { path, content: "" }; + remaining = 0; + continue; + } + const encoded = Buffer.from(content); + if (encoded.length > remaining) { + budgeted[i] = { + path, + content: encoded.subarray(0, remaining).toString("utf-8").trim(), + }; + remaining = 0; + } else { + budgeted[i] = { path, content }; + remaining -= encoded.length; + } + } + + // Phase 3: assemble root → leaf + const parts: string[] = []; + for (const { path, content } of budgeted) { + if (content) { + parts.push(`\n${content}`); + } + } + + return parts.length > 0 ? parts.join("\n\n") : null; +} diff --git a/src/kimi_cli_ts/soul/approval.ts b/src/kimi_cli_ts/soul/approval.ts new file mode 100644 index 000000000..4eeb8d25e --- /dev/null +++ b/src/kimi_cli_ts/soul/approval.ts @@ -0,0 +1,134 @@ +/** + * Approval system — corresponds to Python soul/approval.py + * High-level approval request/response logic used by tools. + */ + +import { randomUUID } from "node:crypto"; +import { + ApprovalRuntime, + ApprovalCancelledError, + type ApprovalResponseKind, + type ApprovalSource, +} from "../approval_runtime/index.ts"; + +// ── ApprovalResult ────────────────────────────────────── + +export class ApprovalResult { + readonly approved: boolean; + readonly feedback: string; + + constructor(approved: boolean, feedback = "") { + this.approved = approved; + this.feedback = feedback; + } + + /** Allow `if (result)` / `if (!result)` usage. */ + valueOf(): boolean { + return this.approved; + } +} + +// ── ApprovalState ─────────────────────────────────────── + +export class ApprovalState { + yolo: boolean; + autoApproveActions: Set; + private onChange?: () => void; + + constructor(opts?: { yolo?: boolean; autoApproveActions?: Set; onChange?: () => void }) { + this.yolo = opts?.yolo ?? false; + this.autoApproveActions = opts?.autoApproveActions ?? new Set(); + this.onChange = opts?.onChange; + } + + notifyChange(): void { + this.onChange?.(); + } +} + +// ── Approval ──────────────────────────────────────────── + +export class Approval { + private state: ApprovalState; + private _runtime: ApprovalRuntime; + + constructor(opts?: { yolo?: boolean; state?: ApprovalState; runtime?: ApprovalRuntime }) { + this.state = opts?.state ?? new ApprovalState({ yolo: opts?.yolo }); + this._runtime = opts?.runtime ?? new ApprovalRuntime(); + } + + /** Create a new Approval that shares state (yolo + auto-approve). */ + share(): Approval { + return new Approval({ state: this.state, runtime: this._runtime }); + } + + get runtime(): ApprovalRuntime { + return this._runtime; + } + + setRuntime(runtime: ApprovalRuntime): void { + this._runtime = runtime; + } + + setYolo(yolo: boolean): void { + this.state.yolo = yolo; + this.state.notifyChange(); + } + + isYolo(): boolean { + return this.state.yolo; + } + + async request( + sender: string, + action: string, + description: string, + opts?: { + toolCallId?: string; + display?: unknown[]; + source?: ApprovalSource; + }, + ): Promise { + const toolCallId = opts?.toolCallId ?? randomUUID(); + const source: ApprovalSource = opts?.source ?? { kind: "foreground_turn", id: toolCallId }; + + if (this.state.yolo) return new ApprovalResult(true); + if (this.state.autoApproveActions.has(action)) return new ApprovalResult(true); + + const requestId = randomUUID(); + this._runtime.createRequest({ + requestId, + toolCallId, + sender, + action, + description, + display: opts?.display, + source, + }); + + try { + const [response, feedback] = await this._runtime.waitForResponse(requestId); + switch (response) { + case "approve": + return new ApprovalResult(true); + case "approve_for_session": + this.state.autoApproveActions.add(action); + this.state.notifyChange(); + // Auto-approve other pending requests for the same action + for (const pending of this._runtime.listPending()) { + if (pending.action === action) { + this._runtime.resolve(pending.id, "approve"); + } + } + return new ApprovalResult(true); + case "reject": + return new ApprovalResult(false, feedback); + } + } catch (err) { + if (err instanceof ApprovalCancelledError) { + return new ApprovalResult(false); + } + throw err; + } + } +} diff --git a/src/kimi_cli_ts/soul/compaction.ts b/src/kimi_cli_ts/soul/compaction.ts new file mode 100644 index 000000000..3dcc1be1a --- /dev/null +++ b/src/kimi_cli_ts/soul/compaction.ts @@ -0,0 +1,224 @@ +/** + * Compaction — corresponds to Python soul/compaction.py + * Summarizes conversation history when context window is getting full. + * Supports preserved messages: the last N user/assistant turns are kept verbatim. + */ + +import type { LLM } from "../llm.ts"; +import type { Message } from "../types.ts"; +import type { Context } from "./context.ts"; +import { logger } from "../utils/logging.ts"; + +/** Default number of recent user/assistant turns to preserve during compaction. */ +const DEFAULT_MAX_PRESERVED_MESSAGES = 2; + +/** + * Estimate tokens from message text content using a character-based heuristic. + * ~4 chars per token for English; somewhat underestimates for CJK text. + */ +export function estimateTextTokens(messages: readonly Message[]): number { + let totalChars = 0; + for (const msg of messages) { + if (typeof msg.content === "string") { + totalChars += msg.content.length; + } else { + for (const part of msg.content) { + if (part.type === "text") { + totalChars += part.text.length; + } + } + } + } + return Math.floor(totalChars / 4); +} + +/** + * Prepare messages for compaction by splitting into to-compact and to-preserve. + * Preserves the last `maxPreservedMessages` user/assistant turns verbatim. + */ +export function prepareCompaction( + messages: readonly Message[], + maxPreservedMessages = DEFAULT_MAX_PRESERVED_MESSAGES, +): { toCompact: Message[]; toPreserve: Message[] } { + if (!messages.length || maxPreservedMessages <= 0) { + return { toCompact: [], toPreserve: [...messages] }; + } + + const history = [...messages]; + let preserveStartIndex = history.length; + let nPreserved = 0; + + for (let index = history.length - 1; index >= 0; index--) { + if (history[index]!.role === "user" || history[index]!.role === "assistant") { + nPreserved++; + if (nPreserved === maxPreservedMessages) { + preserveStartIndex = index; + break; + } + } + } + + if (nPreserved < maxPreservedMessages) { + return { toCompact: [], toPreserve: [...messages] }; + } + + const toCompact = history.slice(0, preserveStartIndex); + const toPreserve = history.slice(preserveStartIndex); + + if (toCompact.length === 0) { + return { toCompact: [], toPreserve }; + } + + return { toCompact, toPreserve }; +} + +/** + * Simple compaction strategy: ask the LLM to summarize the conversation. + * Preserves recent messages verbatim. + */ +export async function compactContext( + context: Context, + llm: LLM, + opts?: { + focus?: string; + maxPreservedMessages?: number; + onBegin?: () => void; + onEnd?: () => void; + }, +): Promise { + const history = context.history; + if (history.length === 0) return; + + opts?.onBegin?.(); + + try { + const maxPreserved = opts?.maxPreservedMessages ?? DEFAULT_MAX_PRESERVED_MESSAGES; + const { toCompact, toPreserve } = prepareCompaction(history, maxPreserved); + + // Nothing to compact — preserve all + if (toCompact.length === 0) { + return; + } + + // Build summary request from to-compact messages + const summaryPrompt = buildSummaryPrompt(toCompact, opts?.focus); + + // Ask LLM to summarize + let summary = ""; + try { + const stream = llm.chat( + [{ role: "user", content: summaryPrompt }], + { + system: + "You are a helpful assistant that compacts conversation context.", + maxTokens: 4096, + }, + ); + + for await (const chunk of stream) { + if (chunk.type === "text") { + summary += chunk.text; + } + } + } catch (err) { + logger.warn(`Compaction LLM call failed, using fallback: ${err}`); + summary = buildFallbackSummary(toCompact); + } + + // Clear context and inject summary + preserved messages + await context.compact(); + + if (summary) { + await context.appendMessage({ + role: "user", + content: `Previous context has been compacted. Here is the compaction output:\n${summary}`, + }); + } + + // Re-append preserved messages + for (const msg of toPreserve) { + await context.appendMessage(msg); + } + } finally { + opts?.onEnd?.(); + } +} + +function buildSummaryPrompt( + messages: readonly Message[], + focus?: string, +): string { + const parts: string[] = []; + + // Build structured input matching Python's format + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + parts.push(`## Message ${i + 1}\nRole: ${msg.role}\nContent:`); + if (typeof msg.content === "string") { + parts.push(msg.content); + } else { + for (const part of msg.content) { + if (part.type === "text") { + parts.push(part.text); + } + } + } + } + + let promptText = "\n" + COMPACT_PROMPT; + if (focus) { + promptText += + "\n\n**User's Custom Compaction Instruction:**\n" + + "The user has specifically requested the following focus during compaction. " + + "You MUST prioritize this instruction above the default compression priorities:\n" + + focus; + } + parts.push(promptText); + + return parts.join("\n"); +} + +const COMPACT_PROMPT = `Summarize the conversation above concisely. Preserve: +- Key decisions and outcomes +- Important file paths and code changes +- Tool call results that are still relevant +- Any pending tasks or goals +Be thorough but concise.`; + +function buildFallbackSummary(history: readonly Message[]): string { + // Simple fallback: keep last few messages as summary + const last = history.slice(-6); + const parts = ["[Fallback summary - LLM compaction failed]"]; + + for (const msg of last) { + const content = + typeof msg.content === "string" + ? msg.content.slice(0, 500) + : msg.content + .map((p) => ("text" in p ? p.text : `[${p.type}]`)) + .join("\n") + .slice(0, 500); + parts.push(`[${msg.role}]: ${content}`); + } + + return parts.join("\n"); +} + +/** + * Determine whether auto-compaction should be triggered. + * + * Returns true when either condition is met (whichever fires first): + * - Ratio-based: tokenCount >= maxContextSize * triggerRatio + * - Reserved-based: tokenCount + reservedContextSize >= maxContextSize + */ +export function shouldCompact( + tokenCount: number, + maxContextSize: number, + reservedContextSize: number, + triggerRatio: number, +): boolean { + return ( + tokenCount >= maxContextSize * triggerRatio || + tokenCount + reservedContextSize >= maxContextSize + ); +} diff --git a/src/kimi_cli_ts/soul/context.ts b/src/kimi_cli_ts/soul/context.ts new file mode 100644 index 000000000..688f37991 --- /dev/null +++ b/src/kimi_cli_ts/soul/context.ts @@ -0,0 +1,288 @@ +/** + * Context window management — corresponds to Python soul/context.py + * Manages conversation message history with token tracking and persistence. + */ + +import type { Message, TokenUsage } from "../types.ts"; +import { estimateMessagesTokenCount } from "../llm.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Special JSONL record markers ──────────────────────── + +interface SystemPromptRecord { + _system_prompt: string; +} + +interface UsageRecord { + _usage: { input_tokens: number; output_tokens: number }; +} + +interface CheckpointRecord { + _checkpoint: { id: number; reminder?: string }; +} + +type ContextRecord = Message | SystemPromptRecord | UsageRecord | CheckpointRecord; + +// ── Context class ─────────────────────────────────────── + +export class Context { + private _history: Message[] = []; + private _tokenCount = 0; + private _pendingTokenEstimate = 0; + private _nextCheckpointId = 0; + private _systemPrompt: string | null = null; + private _filePath: string; + + constructor(filePath: string) { + this._filePath = filePath; + } + + // ── Properties ─────────────────────────────────── + + get history(): readonly Message[] { + return this._history; + } + + get tokenCount(): number { + return this._tokenCount; + } + + get tokenCountWithPending(): number { + return this._tokenCount + this._pendingTokenEstimate; + } + + get systemPrompt(): string | null { + return this._systemPrompt; + } + + get nCheckpoints(): number { + return this._nextCheckpointId; + } + + get filePath(): string { + return this._filePath; + } + + // ── Restore from file ──────────────────────────── + + async restore(): Promise { + const file = Bun.file(this._filePath); + if (!(await file.exists())) return; + + const text = await file.text(); + const lines = text.split("\n"); + let lastUsageLineIdx = -1; + + this._history = []; + this._systemPrompt = null; + this._tokenCount = 0; + this._nextCheckpointId = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line) continue; + + try { + const record: ContextRecord = JSON.parse(line); + + if ("_system_prompt" in record) { + this._systemPrompt = record._system_prompt; + } else if ("_usage" in record) { + // Only input tokens count toward context window (matches Python behavior) + this._tokenCount = record._usage.input_tokens; + lastUsageLineIdx = i; + } else if ("_checkpoint" in record) { + this._nextCheckpointId = record._checkpoint.id + 1; + if (record._checkpoint.reminder) { + // Checkpoint with system reminder → inject as user message + this._history.push({ + role: "user", + content: `\n${record._checkpoint.reminder}\n`, + }); + } + } else if ("role" in record) { + this._history.push(record as Message); + } + } catch { + logger.warn(`Skipping corrupt context line ${i}: ${line.slice(0, 80)}`); + } + } + + // Estimate tokens for messages after last usage record + if (lastUsageLineIdx >= 0) { + const postUsageMessages: Message[] = []; + let postUsageCount = 0; + for (let i = lastUsageLineIdx + 1; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line) continue; + try { + const record = JSON.parse(line); + if ("role" in record) { + postUsageMessages.push(record); + postUsageCount++; + } + } catch { + // skip + } + } + if (postUsageCount > 0) { + this._pendingTokenEstimate = + estimateMessagesTokenCount(postUsageMessages); + } + } else { + // No usage record at all → estimate everything + this._pendingTokenEstimate = estimateMessagesTokenCount(this._history); + } + } + + // ── Append message ────────────────────────────── + + async appendMessage(message: Message): Promise { + this._history.push(message); + const estimate = estimateMessagesTokenCount([message]); + this._pendingTokenEstimate += estimate; + await this._appendToFile(message); + } + + // ── Write system prompt ───────────────────────── + + async writeSystemPrompt(systemPrompt: string): Promise { + this._systemPrompt = systemPrompt; + const record: SystemPromptRecord = { _system_prompt: systemPrompt }; + // Prepend to file (rewrite) + const file = Bun.file(this._filePath); + let existing = ""; + if (await file.exists()) { + existing = await file.text(); + } + const line = JSON.stringify(record) + "\n"; + await Bun.write(this._filePath, line + existing); + } + + // ── Update token count ────────────────────────── + + async updateTokenCount(usage: TokenUsage): Promise { + // Only input tokens count toward context window size (output doesn't consume context) + this._tokenCount = usage.inputTokens + (usage.cacheReadTokens ?? 0); + this._pendingTokenEstimate = 0; + const record: UsageRecord = { + _usage: { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + }, + }; + await this._appendToFile(record); + } + + // ── Checkpoint ────────────────────────────────── + + async checkpoint(reminder?: string): Promise { + const id = this._nextCheckpointId++; + const record: CheckpointRecord = { + _checkpoint: { id, ...(reminder ? { reminder } : {}) }, + }; + if (reminder) { + this._history.push({ + role: "user", + content: `\n${reminder}\n`, + }); + } + await this._appendToFile(record); + return id; + } + + // ── Clear context ────────────────────────────── + + async clear(): Promise { + // Clear all state, keep system prompt + this._history = []; + this._tokenCount = 0; + this._pendingTokenEstimate = 0; + this._nextCheckpointId = 0; + + // Write empty file (with system prompt if present) + if (this._systemPrompt) { + const record: SystemPromptRecord = { + _system_prompt: this._systemPrompt, + }; + await Bun.write(this._filePath, JSON.stringify(record) + "\n"); + } else { + await Bun.write(this._filePath, ""); + } + } + + // ── Compact (clear and rotate) ───────────────── + + async compact(): Promise { + // Rotate old file + const backupPath = this._filePath + ".bak"; + const file = Bun.file(this._filePath); + if (await file.exists()) { + const content = await file.text(); + await Bun.write(backupPath, content); + } + + // Clear state + this._history = []; + this._tokenCount = 0; + this._pendingTokenEstimate = 0; + this._nextCheckpointId = 0; + + // Write empty file (with system prompt if present) + if (this._systemPrompt) { + const record: SystemPromptRecord = { + _system_prompt: this._systemPrompt, + }; + await Bun.write(this._filePath, JSON.stringify(record) + "\n"); + } else { + await Bun.write(this._filePath, ""); + } + } + + // ── Revert to checkpoint ─────────────────────── + + async revertTo(checkpointId: number): Promise { + const file = Bun.file(this._filePath); + if (!(await file.exists())) return; + + const text = await file.text(); + const lines = text.split("\n"); + const kept: string[] = []; + let found = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + kept.push(trimmed); + try { + const record = JSON.parse(trimmed); + if ("_checkpoint" in record && record._checkpoint.id === checkpointId) { + found = true; + break; + } + } catch { + // keep the line + } + } + + if (!found) { + logger.warn(`Checkpoint ${checkpointId} not found, no revert`); + return; + } + + // Backup and rewrite + await Bun.write(this._filePath + ".bak", text); + await Bun.write(this._filePath, kept.join("\n") + "\n"); + + // Reload + await this.restore(); + } + + // ── Private helpers ──────────────────────────── + + private async _appendToFile(record: ContextRecord): Promise { + const line = JSON.stringify(record) + "\n"; + const { appendFile } = await import("node:fs/promises"); + await appendFile(this._filePath, line, "utf-8"); + } +} diff --git a/src/kimi_cli_ts/soul/denwarenji.ts b/src/kimi_cli_ts/soul/denwarenji.ts new file mode 100644 index 000000000..e39a3e68e --- /dev/null +++ b/src/kimi_cli_ts/soul/denwarenji.ts @@ -0,0 +1,49 @@ +/** + * DenwaRenji — corresponds to Python soul/denwarenji.py + * Manages D-Mail (time-leap mail) sending and checkpoint-based context reversal. + */ + +export interface DMail { + /** The message to send. */ + readonly message: string; + /** The checkpoint to send the message back to. Must be >= 0. */ + readonly checkpointId: number; +} + +export class DenwaRenjiError extends Error { + constructor(message: string) { + super(message); + this.name = "DenwaRenjiError"; + } +} + +export class DenwaRenji { + private _pendingDmail: DMail | undefined = undefined; + private _nCheckpoints: number = 0; + + /** Send a D-Mail. Intended to be called by the SendDMail tool. */ + sendDmail(dmail: DMail): void { + if (this._pendingDmail !== undefined) { + throw new DenwaRenjiError("Only one D-Mail can be sent at a time"); + } + if (dmail.checkpointId < 0) { + throw new DenwaRenjiError("The checkpoint ID can not be negative"); + } + if (dmail.checkpointId >= this._nCheckpoints) { + throw new DenwaRenjiError("There is no checkpoint with the given ID"); + } + this._pendingDmail = dmail; + } + + /** Set the number of checkpoints. Intended to be called by the soul. */ + setNCheckpoints(nCheckpoints: number): void { + this._nCheckpoints = nCheckpoints; + } + + /** Fetch a pending D-Mail. Intended to be called by the soul. */ + fetchPendingDmail(): DMail | undefined { + const pending = this._pendingDmail; + this._pendingDmail = undefined; + return pending; + } +} diff --git a/src/kimi_cli_ts/soul/dynamic_injection.ts b/src/kimi_cli_ts/soul/dynamic_injection.ts new file mode 100644 index 000000000..8bf9b465f --- /dev/null +++ b/src/kimi_cli_ts/soul/dynamic_injection.ts @@ -0,0 +1,102 @@ +/** + * Dynamic injection system — corresponds to Python soul/dynamic_injection.py + * Provides an extensible provider pattern for injecting dynamic prompts before LLM steps. + */ + +import type { Message, ContentPart } from "../types.ts"; +import type { KimiSoul } from "./kimisoul.ts"; + +// ── DynamicInjection ───────────────────────────────── + +export interface DynamicInjection { + /** Identifier, e.g. "plan_mode", "yolo_mode" */ + readonly type: string; + /** Text content (will be wrapped in tags) */ + readonly content: string; +} + +// ── DynamicInjectionProvider ───────────────────────── + +/** + * Base interface for dynamic injection providers. + * + * Called before each LLM step. Implementations handle their own throttling. + * Providers can access all runtime state via the `soul` parameter. + */ +export interface DynamicInjectionProvider { + getInjections( + history: readonly Message[], + soul: KimiSoul, + ): Promise; +} + +// ── normalizeHistory ───────────────────────────────── + +/** + * Merge adjacent user messages to produce a clean API input sequence. + * + * Dynamic injections are stored as standalone user messages in history; + * normalization merges them into the adjacent user message. + * + * Only `user` role messages are merged. Assistant and tool messages + * are never merged because their tool_calls / tool_call_id fields + * form linked pairs that must stay intact. + */ +export function normalizeHistory(messages: readonly Message[]): Message[] { + if (messages.length === 0) return []; + + const result: Message[] = []; + for (const msg of messages) { + const prev = result[result.length - 1]; + if ( + prev && + prev.role === "user" && + msg.role === "user" && + !isNotificationMessage(prev) && + !isNotificationMessage(msg) + ) { + // Merge content + const prevParts = toContentArray(prev.content); + const curParts = toContentArray(msg.content); + result[result.length - 1] = { + role: "user", + content: [...prevParts, ...curParts], + }; + } else { + result.push(msg); + } + } + return result; +} + +// ── Helpers ────────────────────────────────────────── + +function toContentArray( + content: string | readonly ContentPart[], +): ContentPart[] { + if (typeof content === "string") { + return [{ type: "text" as const, text: content }]; + } + return [...content]; +} + +/** + * Minimal check: notification messages are user messages whose text + * starts with a notification tag. This keeps us decoupled from the + * full notifications module. + */ +function isNotificationMessage(msg: Message): boolean { + if (msg.role !== "user") return false; + const text = extractText(msg.content); + return text.includes("") || text.includes(" p.type === "text") + .map((p) => p.text) + .join(""); +} diff --git a/src/kimi_cli_ts/soul/dynamic_injections/plan_mode.ts b/src/kimi_cli_ts/soul/dynamic_injections/plan_mode.ts new file mode 100644 index 000000000..9059e6149 --- /dev/null +++ b/src/kimi_cli_ts/soul/dynamic_injections/plan_mode.ts @@ -0,0 +1,236 @@ +/** + * Plan mode dynamic injection — corresponds to Python soul/dynamic_injections/plan_mode.py + * Periodically injects read-only reminders while plan mode is active. + */ + +import type { Message } from "../../types.ts"; +import type { KimiSoul } from "../kimisoul.ts"; +import type { DynamicInjection, DynamicInjectionProvider } from "../dynamic_injection.ts"; + +/** Inject a reminder every N assistant turns. */ +const TURN_INTERVAL = 5; +/** Every N-th reminder is the full version; others are sparse. */ +const FULL_EVERY_N = 5; + +export class PlanModeInjectionProvider implements DynamicInjectionProvider { + private _injectCount = 0; + + async getInjections( + history: readonly Message[], + soul: KimiSoul, + ): Promise { + if (!soul.planMode) { + this._injectCount = 0; + return []; + } + + const planPath = soul.getPlanFilePath(); + const planPathStr = planPath ?? null; + const planExists = planPath != null && (await fileExists(planPath)); + + // Manual toggles schedule a one-shot activation reminder for the next LLM step. + if (soul.consumePendingPlanActivationInjection()) { + this._injectCount = 1; + if (planExists) { + return [ + { type: "plan_mode_reentry", content: reentryReminder(planPathStr) }, + ]; + } + return [ + { type: "plan_mode", content: fullReminder(planPathStr, planExists) }, + ]; + } + + // Scan history backwards to find the last plan mode reminder. + let turnsSinceLast = 0; + let foundPrevious = false; + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i]!; + if (msg.role === "user" && hasPlanReminder(msg)) { + foundPrevious = true; + break; + } + if (msg.role === "assistant") { + turnsSinceLast++; + } + } + + // First time (no reminder in history yet) -> inject full version. + if (!foundPrevious) { + this._injectCount = 1; + return [ + { type: "plan_mode", content: fullReminder(planPathStr, planExists) }, + ]; + } + + // Not enough turns since last reminder -> skip. + if (turnsSinceLast < TURN_INTERVAL) { + return []; + } + + // Inject. + this._injectCount++; + const isFull = this._injectCount % FULL_EVERY_N === 1; + const content = isFull + ? fullReminder(planPathStr, planExists) + : sparseReminder(planPathStr); + return [{ type: "plan_mode", content }]; + } +} + +// ── Reminder text builders ─────────────────────────── + +function hasPlanReminder(msg: Message): boolean { + const sparseKey = sparseReminder().split(".")[0]!; + const fullKey = fullReminder().split("\n")[0]!; + + const text = extractText(msg.content); + return text.includes(sparseKey) || text.includes(fullKey); +} + +export function fullReminder( + planFilePath: string | null = null, + planExists = false, +): string { + const lines: string[] = [ + "Plan mode is active. You MUST NOT make any edits " + + "(with the exception of the plan file below), run non-readonly tools, " + + "or otherwise make changes to the system. " + + "This supersedes any other instructions you have received.", + ]; + + if (planFilePath) { + lines.push(""); + if (planExists) { + lines.push( + `Plan file: ${planFilePath} ` + + "(exists — read first, then update it with WriteFile or StrReplaceFile)", + ); + } else { + lines.push( + `Plan file: ${planFilePath} ` + + "(create it with WriteFile; once it exists, you can modify it with " + + "WriteFile or StrReplaceFile)", + ); + } + lines.push("This is the only file you are allowed to edit."); + } + + lines.push( + "", + "Workflow:", + "1. Understand — explore the codebase with Glob, Grep, ReadFile", + "2. Design — converge on the best approach; " + + "consider trade-offs but aim for a single recommendation", + "3. Review — re-read key files to verify understanding", + "4. Write Plan — modify the plan file with WriteFile or StrReplaceFile. " + + "Use WriteFile if the plan file does not exist yet", + "5. Exit — call ExitPlanMode for user approval", + ); + + lines.push( + "", + "## Handling multiple approaches", + "Keep it focused: at most 2-3 meaningfully different approaches. " + + "Do NOT pad with minor variations — if one approach is clearly " + + "superior, just propose that one.", + "When the best approach depends on user preferences, constraints, " + + "or context you don't have, use AskUserQuestion to clarify first. " + + "This helps you write a better, more targeted plan rather than " + + "dumping multiple options for the user to sort through.", + "When you do include multiple approaches in the plan, you MUST pass them " + + "as the `options` parameter when calling ExitPlanMode, so the user can select which " + + "approach to execute at approval time.", + "NEVER write multiple approaches in the plan and call ExitPlanMode without the " + + "`options` parameter — the user will only see Approve/Reject with no way to choose.", + ); + + lines.push( + "", + "AskUserQuestion is for clarifying missing requirements or user preferences " + + "that affect the plan.", + "Never ask about plan approval via text or AskUserQuestion.", + "Your turn must end with either AskUserQuestion " + + "(to clarify requirements or preferences) " + + "or ExitPlanMode (to request plan approval). " + + "Do NOT end your turn any other way.", + 'Do NOT use AskUserQuestion to ask about plan approval or reference ' + + '"the plan" — the user cannot see the plan until you call ExitPlanMode.', + ); + + return lines.join("\n"); +} + +export function sparseReminder(planFilePath: string | null = null): string { + const parts: string[] = [ + "Plan mode still active (see full instructions earlier).", + ]; + + if (planFilePath) { + parts.push(`Read-only except plan file (${planFilePath}).`); + } else { + parts.push("Read-only."); + } + + parts.push( + "Use WriteFile or StrReplaceFile to modify the plan file. " + + "If it does not exist yet, create it with WriteFile first.", + "Use AskUserQuestion to clarify user preferences " + + "when it helps you write a better plan.", + "If the plan has multiple approaches, " + + "pass options to ExitPlanMode so the user can choose.", + "End turns with AskUserQuestion (for clarifications) or ExitPlanMode (for approval).", + "Never ask about plan approval via text or AskUserQuestion.", + ); + + return parts.join(" "); +} + +function reentryReminder(planFilePath: string | null = null): string { + const lines: string[] = [ + "Plan mode is active. You MUST NOT make any edits " + + "(with the exception of the plan file below), run non-readonly tools, " + + "or otherwise make changes to the system. " + + "This supersedes any other instructions you have received.", + "", + "## Re-entering Plan Mode", + planFilePath + ? `A plan file exists at ${planFilePath} from a previous planning session.` + : "A plan file from a previous planning session already exists.", + "Before proceeding:", + "1. Read the existing plan file to understand what was previously planned", + "2. Evaluate the user's current request against that plan", + "3. If different task: replace the old plan with a fresh one. " + + "If same task: update the existing plan.", + "4. You may use WriteFile or StrReplaceFile to modify the plan file. " + + "If the file does not exist yet, create it with WriteFile first.", + "5. Use AskUserQuestion to clarify missing requirements " + + "or user preferences that affect the plan.", + "6. Always edit the plan file before calling ExitPlanMode.", + "", + "Your turn must end with either AskUserQuestion (to clarify requirements) " + + "or ExitPlanMode (to request plan approval).", + ]; + return lines.join("\n"); +} + +// ── Helpers ────────────────────────────────────────── + +function extractText( + content: string | readonly { type: string; [key: string]: unknown }[], +): string { + if (typeof content === "string") return content; + return content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join(""); +} + +async function fileExists(path: string): Promise { + try { + const file = Bun.file(path); + return await file.exists(); + } catch { + return false; + } +} diff --git a/src/kimi_cli_ts/soul/dynamic_injections/yolo_mode.ts b/src/kimi_cli_ts/soul/dynamic_injections/yolo_mode.ts new file mode 100644 index 000000000..b23156a7c --- /dev/null +++ b/src/kimi_cli_ts/soul/dynamic_injections/yolo_mode.ts @@ -0,0 +1,36 @@ +/** + * YOLO mode dynamic injection — corresponds to Python soul/dynamic_injections/yolo_mode.py + * Injects a one-time reminder when yolo mode is active. + */ + +import type { Message } from "../../types.ts"; +import type { KimiSoul } from "../kimisoul.ts"; +import type { DynamicInjection, DynamicInjectionProvider } from "../dynamic_injection.ts"; + +const YOLO_INJECTION_TYPE = "yolo_mode"; + +const YOLO_PROMPT = + "You are running in non-interactive mode. The user cannot answer questions " + + "or provide feedback during execution.\n" + + "- Do NOT call AskUserQuestion. If you need to make a decision, make your " + + "best judgment and proceed.\n" + + "- For EnterPlanMode / ExitPlanMode, they will be auto-approved. You can use " + + "them normally but expect no user feedback."; + +export class YoloModeInjectionProvider implements DynamicInjectionProvider { + private _injected = false; + + async getInjections( + _history: readonly Message[], + soul: KimiSoul, + ): Promise { + if (!soul.isYolo) { + return []; + } + if (this._injected) { + return []; + } + this._injected = true; + return [{ type: YOLO_INJECTION_TYPE, content: YOLO_PROMPT }]; + } +} diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts new file mode 100644 index 000000000..4b3272506 --- /dev/null +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -0,0 +1,1266 @@ +/** + * KimiSoul — corresponds to Python soul/kimisoul.py + * The core agent loop: receive input → call LLM → execute tools → repeat. + */ + +import type { Message, ContentPart, ToolCall, TokenUsage, StatusSnapshot, SlashCommand, ModelCapability } from "../types.ts"; +import type { ToolResult } from "../tools/types.ts"; +import type { LLM, StreamChunk, ChatOptions } from "../llm.ts"; +import type { HookEngine } from "../hooks/engine.ts"; +import type { Config } from "../config.ts"; +import type { Session } from "../session.ts"; +import { Context } from "./context.ts"; +import { Agent, type Runtime } from "./agent.ts"; +import { KimiToolset } from "./toolset.ts"; +import { SlashCommandRegistry } from "./slash.ts"; +import { compactContext, shouldCompact } from "./compaction.ts"; +import { toolResultMessage, systemReminder } from "./message.ts"; +import type { DynamicInjection, DynamicInjectionProvider } from "./dynamic_injection.ts"; +import { normalizeHistory } from "./dynamic_injection.ts"; +import { PlanModeInjectionProvider } from "./dynamic_injections/plan_mode.ts"; +import { YoloModeInjectionProvider } from "./dynamic_injections/yolo_mode.ts"; +import { handleNew, handleSessions, handleTitle } from "../ui/shell/commands/session.ts"; +import { handleModel } from "../ui/shell/commands/model.ts"; +import { handleLogin, handleLogout, createLoginPanel } from "../ui/shell/commands/login.ts"; +import { handleHooks, handleMcp, handleDebug, handleChangelog } from "../ui/shell/commands/info.ts"; +import { handleExport, handleImport } from "../ui/shell/commands/export_import.ts"; +import { handleWeb, handleVis, handleReload, handleTask } from "../ui/shell/commands/misc.ts"; +import { handleUsage } from "../ui/shell/commands/usage.ts"; +import { handleFeedback } from "../ui/shell/commands/feedback.ts"; +import { handleEditor } from "../ui/shell/commands/editor.ts"; +import { handleInit } from "../ui/shell/commands/init.ts"; +import { handleAddDir } from "../ui/shell/commands/add_dir.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Errors ───────────────────────────────────────── + +export class LLMNotSet extends Error { + constructor() { + super("LLM not set"); + this.name = "LLMNotSet"; + } +} + +export class LLMNotSupported extends Error { + readonly modelName: string; + readonly capabilities: ModelCapability[]; + constructor(modelName: string, capabilities: ModelCapability[]) { + const word = capabilities.length === 1 ? "capability" : "capabilities"; + super( + `LLM model '${modelName}' does not support required ${word}: ${capabilities.join(", ")}`, + ); + this.name = "LLMNotSupported"; + this.modelName = modelName; + this.capabilities = capabilities; + } +} + +export class MaxStepsReached extends Error { + readonly maxSteps: number; + constructor(maxSteps: number) { + super(`Reached max steps per turn: ${maxSteps}`); + this.name = "MaxStepsReached"; + this.maxSteps = maxSteps; + } +} + +export class RunCancelled extends Error { + constructor() { + super("The run was cancelled"); + this.name = "RunCancelled"; + } +} + +export class BackToTheFuture extends Error { + readonly checkpointId: number; + readonly messages: Message[]; + constructor(checkpointId: number, messages: Message[]) { + super(`Reverting context to checkpoint ${checkpointId}`); + this.name = "BackToTheFuture"; + this.checkpointId = checkpointId; + this.messages = messages; + } +} + +// ── Wire event callbacks ──────────────────────────── + +export interface SoulCallbacks { + onTurnBegin?: (userInput: string | ContentPart[]) => void; + onTurnEnd?: () => void; + onStepBegin?: (stepNum: number) => void; + onStepInterrupted?: () => void; + onTextDelta?: (text: string) => void; + onThinkDelta?: (text: string) => void; + onToolCall?: (toolCall: ToolCall) => void; + onToolResult?: (toolCallId: string, result: ToolResult) => void; + onStatusUpdate?: (status: Partial) => void; + onCompactionBegin?: () => void; + onCompactionEnd?: () => void; + onError?: (error: Error) => void; + onNotification?: (title: string, body: string) => void; +} + +// ── Retry helpers ─────────────────────────────────── + +function isRetryableError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + // HTTP status codes that are retryable + if (/\b(429|500|502|503|504)\b/.test(msg)) return true; + // Network errors + if (msg.includes("timeout") || msg.includes("econnreset") || + msg.includes("econnrefused") || msg.includes("connection") || + msg.includes("network") || msg.includes("fetch failed") || + msg.includes("socket hang up")) return true; + // Empty response + if (msg.includes("empty response") || msg.includes("no body")) return true; + return false; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Retry with exponential backoff and jitter. */ +async function withRetry( + fn: () => Promise, + maxRetries: number, + label: string, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt >= maxRetries || !isRetryableError(err)) { + throw err; + } + const baseDelay = Math.min(300 * Math.pow(2, attempt), 5000); + const jitter = Math.random() * 500; + const delay = baseDelay + jitter; + logger.warn(`${label}: retryable error (attempt ${attempt + 1}/${maxRetries}), retrying in ${Math.round(delay)}ms: ${err instanceof Error ? err.message : err}`); + await sleep(delay); + } + } + throw lastError; +} + +// ── KimiSoul ──────────────────────────────────────── + +export class KimiSoul { + private agent: Agent; + private context: Context; + private callbacks: SoulCallbacks; + private abortController: AbortController | null = null; + private _isRunning = false; + private _planMode = false; + private _planSessionId: string | null = null; + private _pendingPlanActivationInjection = false; + private _injectionProviders: DynamicInjectionProvider[]; + private _stepCount = 0; + private _totalUsage: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + }; + // Steer queue: messages injected during a running turn + private _pendingSteers: Message[] = []; + // Track whether any tool was rejected without feedback this turn + private _toolRejectedNoFeedback = false; + + constructor(opts: { + agent: Agent; + context: Context; + callbacks?: SoulCallbacks; + }) { + this.agent = opts.agent; + this.context = opts.context; + this.callbacks = opts.callbacks ?? {}; + + // Restore plan mode from session state + this._planMode = opts.agent.runtime.session.state.plan_mode ?? false; + this._planSessionId = opts.agent.runtime.session.state.plan_session_id ?? null; + if (this._planMode) { + this._ensurePlanSessionId(); + } + + // Initialize dynamic injection providers + this._injectionProviders = [ + new PlanModeInjectionProvider(), + new YoloModeInjectionProvider(), + ]; + } + + // ── Properties ─────────────────────────────────── + + get runtime(): Runtime { + return this.agent.runtime; + } + + get config(): Config { + return this.agent.runtime.config; + } + + get session(): Session { + return this.agent.runtime.session; + } + + get ctx(): Context { + return this.context; + } + + get name(): string { + return this.agent.name; + } + + get modelName(): string { + return this.agent.modelName; + } + + get modelCapabilities(): Set | null { + return this.agent.modelCapabilities; + } + + get thinking(): boolean { + return this.agent.runtime.llm?.hasCapability("thinking") ?? false; + } + + get isRunning(): boolean { + return this._isRunning; + } + + get planMode(): boolean { + return this._planMode; + } + + get isYolo(): boolean { + return this.agent.runtime.approval.isYolo(); + } + + get status(): StatusSnapshot { + const llm = this.agent.runtime.llm; + const maxCtx = llm?.maxContextSize ?? 0; + const tokenCount = this.context.tokenCountWithPending; + return { + contextUsage: maxCtx > 0 ? tokenCount / maxCtx : null, + contextTokens: tokenCount, + maxContextTokens: maxCtx, + tokenUsage: this._totalUsage, + planMode: this._planMode, + mcpStatus: null, + }; + } + + get hookEngine(): HookEngine { + return this.agent.runtime.hookEngine; + } + + /** Push a notification to the UI (appears in message list). */ + notify(title: string, body: string): void { + this.callbacks.onNotification?.(title, body); + } + + get availableSlashCommands(): SlashCommand[] { + return this.agent.slashCommands.list(); + } + + // ── Plan mode ──────────────────────────────────── + + /** Toggle plan mode from a tool call. Returns the new state. */ + async togglePlanMode(): Promise { + return this._setPlanMode(!this._planMode, "tool"); + } + + /** Toggle plan mode from a manual entry point (slash command, keybinding). */ + async togglePlanModeFromManual(): Promise { + return this._setPlanMode(!this._planMode, "manual"); + } + + /** Set plan mode to a specific state from manual entry points. */ + async setPlanModeFromManual(enabled: boolean): Promise { + return this._setPlanMode(enabled, "manual"); + } + + setPlanMode(on: boolean): void { + this._setPlanMode(on, "tool"); + } + + private _setPlanMode(enabled: boolean, source: "manual" | "tool"): boolean { + if (enabled === this._planMode) return this._planMode; + this._planMode = enabled; + if (enabled) { + this._ensurePlanSessionId(); + this._pendingPlanActivationInjection = source === "manual"; + } else { + this._pendingPlanActivationInjection = false; + this._planSessionId = null; + this.agent.runtime.session.state.plan_session_id = null; + } + // Persist to session state + this.agent.runtime.session.state.plan_mode = this._planMode; + this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + return this._planMode; + } + + private _ensurePlanSessionId(): void { + if (this._planSessionId == null) { + this._planSessionId = crypto.randomUUID().replace(/-/g, ""); + this.agent.runtime.session.state.plan_session_id = this._planSessionId; + } + } + + /** Get the plan file path for the current session. */ + getPlanFilePath(): string | null { + if (this._planSessionId == null) return null; + const workDir = this.agent.runtime.session.workDir; + return `${workDir}/.kimi/plans/${this._planSessionId}.md`; + } + + /** Read the current plan file content. */ + readCurrentPlan(): string | null { + const path = this.getPlanFilePath(); + if (!path) return null; + try { + const file = Bun.file(path); + // Synchronous existence check is not available — use a simple approach + return file.size > 0 ? null : null; // Will be refined when plan tools exist + } catch { + return null; + } + } + + /** Delete the current plan file. */ + clearCurrentPlan(): void { + const path = this.getPlanFilePath(); + if (!path) return; + try { + const { unlinkSync } = require("node:fs"); + unlinkSync(path); + } catch { + // File may not exist + } + } + + /** + * Schedule a plan-mode activation reminder for the next turn. + * + * Use this when plan mode is already active (e.g. restored session with + * `--plan` flag) and `_setPlanMode` would early-return because the + * state hasn't actually changed. + */ + schedulePlanActivationReminder(): void { + if (this._planMode) { + this._pendingPlanActivationInjection = true; + } + } + + /** Consume the next-step activation reminder scheduled by a manual toggle. */ + consumePendingPlanActivationInjection(): boolean { + if (!this._planMode || !this._pendingPlanActivationInjection) return false; + this._pendingPlanActivationInjection = false; + return true; + } + + /** Register an additional dynamic injection provider. */ + addInjectionProvider(provider: DynamicInjectionProvider): void { + this._injectionProviders.push(provider); + } + + // ── Yolo mode ──────────────────────────────────── + + setYolo(yolo: boolean): void { + this.agent.runtime.approval.setYolo(yolo); + } + + // ── Main entry point ───────────────────────────── + + async run(userInput: string | ContentPart[]): Promise { + if (this._isRunning) { + logger.warn("Soul is already running, ignoring input"); + return; + } + + // Check for slash commands + if (typeof userInput === "string" && userInput.trim().startsWith("/")) { + const handled = await this.agent.slashCommands.execute(userInput); + if (handled) return; + } + + this._isRunning = true; + this.abortController = new AbortController(); + this._toolRejectedNoFeedback = false; + + let turnStarted = false; + let turnFinished = false; + try { + this.callbacks.onTurnBegin?.(userInput); + this._wireLog({ type: "turn_begin", user_input: typeof userInput === "string" ? userInput : "[complex]" }); + turnStarted = true; + await this._turn(userInput); + this._wireLog({ type: "turn_end" }); + this.callbacks.onTurnEnd?.(); + turnFinished = true; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + logger.info("Turn aborted"); + this.callbacks.onStepInterrupted?.(); + } else if (err instanceof MaxStepsReached) { + logger.warn(err.message); + this.callbacks.onError?.(err); + this.callbacks.onTurnEnd?.(); + turnFinished = true; + } else { + logger.error(`Turn error: ${err}`); + this.callbacks.onError?.( + err instanceof Error ? err : new Error(String(err)), + ); + } + } finally { + if (turnStarted && !turnFinished) { + this._wireLog({ type: "turn_end" }); + this.callbacks.onTurnEnd?.(); + } + this._isRunning = false; + this.abortController = null; + this._pendingSteers = []; + } + } + + /** Abort the current turn. */ + abort(): void { + this.abortController?.abort(); + } + + /** Steer: inject follow-up input during a running turn. */ + async steer(content: string | ContentPart[]): Promise { + if (!this._isRunning) return; + const msg: Message = { + role: "user", + content: typeof content === "string" ? content : content, + }; + this._pendingSteers.push(msg); + } + + // ── Turn execution ────────────────────────────── + + private async _turn(userInput: string | ContentPart[]): Promise { + // Append user message + const userMsg: Message = { + role: "user", + content: typeof userInput === "string" ? userInput : userInput, + }; + await this.context.appendMessage(userMsg); + + // Agent loop + await this._agentLoop(); + } + + // ── Agent loop ────────────────────────────────── + + private async _agentLoop(): Promise { + const maxSteps = this.agent.runtime.config.loop_control.max_steps_per_turn; + this._stepCount = 0; + + while (true) { + // Check max steps — raise exception like Python + if (this._stepCount >= maxSteps) { + throw new MaxStepsReached(maxSteps); + } + + // Check abort + if (this.abortController?.signal.aborted) { + this.callbacks.onStepInterrupted?.(); + break; + } + + // Consume pending steers + const hadSteers = await this._consumePendingSteers(); + + // Check if compaction needed + await this._maybeCompact(); + + // Execute one step + this._stepCount++; + this.callbacks.onStepBegin?.(this._stepCount); + + const maxRetries = this.agent.runtime.config.loop_control.max_retries_per_step; + const toolCalls = await withRetry( + () => this._step(), + maxRetries, + `step ${this._stepCount}`, + ); + + // No tool calls = turn is done (unless steers are pending) + if (toolCalls.length === 0) { + // Check for pending steers — if any, force another iteration + if (this._pendingSteers.length > 0) { + continue; + } + break; + } + + // Execute tools and collect results — shielded from abort + await this._executeToolsShielded(toolCalls); + + // If a tool was rejected without feedback, stop the turn + if (this._toolRejectedNoFeedback && this.agent.runtime.role !== "subagent") { + logger.info("Turn stopped: tool was rejected without feedback"); + break; + } + } + } + + /** + * Execute tools and append results to context. + * This is "shielded" from abort to keep context consistent — + * once we start appending, we finish even if abort fires. + */ + private async _executeToolsShielded(toolCalls: ToolCall[]): Promise { + for (const tc of toolCalls) { + // Check abort before each tool, but don't interrupt mid-append + if (this.abortController?.signal.aborted) break; + + const result = await this.agent.toolset.handle(tc); + + // Detect tool rejection without feedback + if (result.isError && result.message?.includes("rejected by the user")) { + // If the rejection message is just the standard template, no user feedback + if (!result.extras?.userFeedback) { + this._toolRejectedNoFeedback = true; + } + } + + // Build tool result message and append to context + const resultMsg = toolResultMessage({ + toolCallId: tc.id, + output: result.output, + isError: result.isError, + message: result.message, + }); + // Append atomically — even if abort was signaled during tool execution, + // we still append the result to keep context consistent + await this.context.appendMessage(resultMsg); + } + } + + /** Drain the steer queue into context. Returns true if any steers were consumed. */ + private async _consumePendingSteers(): Promise { + if (this._pendingSteers.length === 0) return false; + const steers = this._pendingSteers.splice(0); + for (const msg of steers) { + await this.context.appendMessage(msg); + } + return true; + } + + // ── Single step ───────────────────────────────── + + private async _step(): Promise { + const llm = this.agent.runtime.llm; + if (!llm) { + throw new Error("No LLM configured"); + } + + // Build messages for LLM — normalize to merge adjacent user messages + const rawMessages = [...this.context.history] as Message[]; + + // Collect dynamic injections from providers (plan mode, yolo mode, etc.) + const injections = await this._collectInjections(); + if (injections.length > 0) { + // Add as the last user message wrapped in system-reminder tags + const injectionContent = injections + .map((inj) => `\n${inj.content}\n`) + .join("\n\n"); + rawMessages.push({ + role: "user", + content: injectionContent, + }); + } + + // Normalize: merge adjacent user messages to avoid API errors + const messages = normalizeHistory(rawMessages); + + // Call LLM + const chatOptions: ChatOptions = { + system: this.agent.systemPrompt, + tools: this.agent.toolset.definitions(), + signal: this.abortController?.signal, + }; + + let assistantText = ""; + let thinkText = ""; + const toolCalls: ToolCall[] = []; + let usage: TokenUsage | null = null; + + const stream = llm.chat(messages, chatOptions); + + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + assistantText += chunk.text; + this.callbacks.onTextDelta?.(chunk.text); + break; + + case "think": + thinkText += chunk.text; + this.callbacks.onThinkDelta?.(chunk.text); + break; + + case "tool_call": + toolCalls.push({ + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }); + this.callbacks.onToolCall?.({ + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, + }); + break; + + case "usage": + usage = chunk.usage; + this._totalUsage = { + inputTokens: + this._totalUsage.inputTokens + chunk.usage.inputTokens, + outputTokens: + this._totalUsage.outputTokens + chunk.usage.outputTokens, + }; + break; + + case "done": + break; + } + } + + // Build assistant message content + const contentParts: ContentPart[] = []; + if (assistantText) { + contentParts.push({ type: "text", text: assistantText }); + } + for (const tc of toolCalls) { + contentParts.push({ + type: "tool_use", + id: tc.id, + name: tc.name, + input: JSON.parse(tc.arguments || "{}"), + }); + } + + // Append assistant message to context + // Note: reasoning_content (thinkText) is stored separately in the message + // and will be serialized as reasoning_content field for the API + if (contentParts.length > 0) { + const assistantMsg: Message & { reasoning_content?: string } = { + role: "assistant", + content: contentParts, + }; + // Preserve thinking content so it can be sent back to the model + if (thinkText) { + assistantMsg.reasoning_content = thinkText; + } + await this.context.appendMessage(assistantMsg); + } + + // Update token count + if (usage) { + await this.context.updateTokenCount(usage); + } + + // Wire log step results + if (assistantText) { + this._wireLog({ type: "text_part", text: assistantText }); + } + for (const tc of toolCalls) { + this._wireLog({ type: "tool_call", name: tc.name, id: tc.id }); + } + + // Send status update + this.callbacks.onStatusUpdate?.(this.status); + + return toolCalls; + } + + // ── Dynamic injections ────────────────────────── + + /** Collect dynamic injections from all registered providers. */ + private async _collectInjections(): Promise { + const injections: DynamicInjection[] = []; + for (const provider of this._injectionProviders) { + try { + const result = await provider.getInjections(this.context.history, this); + injections.push(...result); + } catch (err) { + logger.warn(`Injection provider failed: ${err}`); + } + } + return injections; + } + + // ── Compaction ────────────────────────────────── + + private async _maybeCompact(): Promise { + const llm = this.agent.runtime.llm; + if (!llm) return; + + const lc = this.agent.runtime.config.loop_control; + + if ( + shouldCompact( + this.context.tokenCountWithPending, + llm.maxContextSize, + lc.reserved_context_size, + lc.compaction_trigger_ratio, + ) + ) { + this.callbacks.onCompactionBegin?.(); + try { + await compactContext(this.context, llm); + } catch (err) { + logger.error(`Compaction failed: ${err}`); + } + this.callbacks.onCompactionEnd?.(); + } + } + + // ── Slash command wiring ──────────────────────── + + wireSlashCommands(): void { + const registry = this.agent.slashCommands; + + // Wire /clear + const clearCmd = registry.get("clear"); + if (clearCmd) { + clearCmd.handler = async () => { + await this.context.clear(); + await this.context.writeSystemPrompt(this.agent.systemPrompt); + logger.info("Context cleared"); + this.callbacks.onStatusUpdate?.(this.status); + }; + } + + // Wire /compact + const compactCmd = registry.get("compact"); + if (compactCmd) { + compactCmd.handler = async (args: string) => { + if (this.context.nCheckpoints === 0) { + logger.info("The context is empty."); + return; + } + const llm = this.agent.runtime.llm; + if (!llm) return; + logger.info("Running `/compact`"); + await compactContext(this.context, llm, { focus: args || undefined }); + this.callbacks.onStatusUpdate?.(this.status); + }; + } + + // Wire /yolo + const yoloCmd = registry.get("yolo"); + if (yoloCmd) { + yoloCmd.handler = async () => { + if (this.agent.runtime.approval.isYolo()) { + this.agent.runtime.approval.setYolo(false); + logger.info("YOLO mode: OFF"); + } else { + this.agent.runtime.approval.setYolo(true); + logger.info("YOLO mode: ON"); + } + }; + } + + // Wire /plan with subcmd support (on/off/view/clear/toggle) + const planCmd = registry.get("plan"); + if (planCmd) { + planCmd.handler = async (args: string) => { + const subcmd = args.trim().toLowerCase(); + if (subcmd === "on") { + if (!this._planMode) await this.togglePlanModeFromManual(); + const planPath = this.getPlanFilePath(); + logger.info(`Plan mode ON. Plan file: ${planPath}`); + this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + } else if (subcmd === "off") { + if (this._planMode) await this.togglePlanModeFromManual(); + logger.info("Plan mode OFF. All tools are now available."); + this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + } else if (subcmd === "view") { + const content = this.readCurrentPlan(); + if (content) { + logger.info(content); + } else { + logger.info("No plan file found for this session."); + } + } else if (subcmd === "clear") { + this.clearCurrentPlan(); + logger.info("Plan cleared."); + } else { + // Default: toggle + const newState = await this.togglePlanModeFromManual(); + if (newState) { + const planPath = this.getPlanFilePath(); + logger.info(`Plan mode ON. Write your plan to: ${planPath}`); + } else { + logger.info("Plan mode OFF. All tools are now available."); + } + this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + } + }; + } + + // Wire /model + const modelCmd = registry.get("model"); + if (modelCmd) { + modelCmd.handler = async () => { + await handleModel(this.agent.runtime.config, { isFromDefaultLocation: true, sourceFile: null }); + }; + } + + // Wire /export + const exportCmd = registry.get("export"); + if (exportCmd) { + exportCmd.handler = async (args: string) => { + await handleExport(this.context, this.agent.runtime.session, args); + }; + } + + // Wire /import + const importCmd = registry.get("import"); + if (importCmd) { + importCmd.handler = async (args: string) => { + await handleImport(this.context, this.agent.runtime.session, args); + }; + } + + // Wire /web + const webCmd = registry.get("web"); + if (webCmd) { + webCmd.handler = async () => { + handleWeb(this.agent.runtime.session.id); + }; + } + + // Wire /vis + const visCmd = registry.get("vis"); + if (visCmd) { + visCmd.handler = async () => { + handleVis(this.agent.runtime.session.id); + }; + } + + // Wire /reload + const reloadCmd = registry.get("reload"); + if (reloadCmd) { + reloadCmd.handler = async () => { + handleReload(); + }; + } + + // Wire /task + const taskCmd = registry.get("task"); + if (taskCmd) { + taskCmd.handler = async () => { + handleTask(); + }; + } + + // Wire /login + const loginCmd = registry.get("login"); + if (loginCmd) { + const notify = (t: string, b: string) => this.notify(t, b); + loginCmd.handler = async () => { + await handleLogin(this.agent.runtime.config, notify); + }; + loginCmd.panel = () => createLoginPanel(this.agent.runtime.config, notify); + } + + // Wire /logout + const logoutCmd = registry.get("logout"); + if (logoutCmd) { + logoutCmd.handler = async () => { + await handleLogout(this.agent.runtime.config, (t, b) => this.notify(t, b)); + }; + } + + // Wire /usage + const usageCmd = registry.get("usage"); + if (usageCmd) { + usageCmd.handler = async () => { + await handleUsage(this.agent.runtime.config, this.agent.runtime.config.default_model || undefined); + }; + } + + // Wire /feedback + const feedbackCmd = registry.get("feedback"); + if (feedbackCmd) { + feedbackCmd.handler = async (args: string) => { + await handleFeedback( + this.agent.runtime.config, + args, + this.agent.runtime.session.id, + this.agent.runtime.config.default_model || undefined, + ); + }; + } + + // Wire /editor + const editorCmd = registry.get("editor"); + if (editorCmd) { + editorCmd.handler = async (args: string) => { + await handleEditor(this.agent.runtime.config, { isFromDefaultLocation: true, sourceFile: null }, args); + }; + } + + // Wire /hooks + const hooksCmd = registry.get("hooks"); + if (hooksCmd) { + hooksCmd.handler = async () => { + handleHooks(this.agent.runtime.hookEngine); + }; + } + + // Wire /mcp + const mcpCmd = registry.get("mcp"); + if (mcpCmd) { + mcpCmd.handler = async () => { + handleMcp(this.agent.runtime.config); + }; + } + + // Wire /debug + const debugCmd = registry.get("debug"); + if (debugCmd) { + debugCmd.handler = async () => { + handleDebug(this.context); + }; + } + + // Wire /changelog + const changelogCmd = registry.get("changelog"); + if (changelogCmd) { + changelogCmd.handler = async () => { + handleChangelog(); + }; + } + + // Wire /new + const newCmd = registry.get("new"); + if (newCmd) { + newCmd.handler = async () => { + await handleNew(this.agent.runtime.session); + }; + } + + // Wire /sessions + const sessionsCmd = registry.get("sessions"); + if (sessionsCmd) { + sessionsCmd.handler = async () => { + await handleSessions(this.agent.runtime.session); + }; + } + + // Wire /title + const titleCmd = registry.get("title"); + if (titleCmd) { + titleCmd.handler = async (args: string) => { + await handleTitle(this.agent.runtime.session, args); + }; + } + + // Wire /init + const initCmd = registry.get("init"); + if (initCmd) { + initCmd.handler = async () => { + const result = await handleInit(this.agent.runtime.session.workDir); + if (result) { + // Inject the generated AGENTS.md into context so the LLM knows about it + await this.context.appendMessage({ + role: "user", + content: `The user ran /init. Generated AGENTS.md:\n${result}`, + }); + } + }; + } + + // Wire /add-dir + const addDirCmd = registry.get("add-dir"); + if (addDirCmd) { + addDirCmd.handler = async (args: string) => { + const result = await handleAddDir( + this.agent.runtime.session, + this.agent.runtime.session.workDir, + args, + ); + if (result) { + // Inject directory info into context so the LLM knows about it + await this.context.appendMessage({ + role: "user", + content: result, + }); + } + }; + } + } + + /** Wire tool context callbacks (plan mode, ask user, etc.) to the soul. */ + wireToolContext(): void { + const ctx = this.agent.toolset.context; + ctx.setPlanMode = (on: boolean) => this.setPlanMode(on); + ctx.getPlanMode = () => this._planMode; + ctx.getPlanFilePath = () => this.getPlanFilePath() ?? undefined; + ctx.togglePlanMode = () => this.togglePlanMode(); + } + + // ── Wire file logging ──────────────────────────── + + /** + * Append a wire event to the session's wire.jsonl file. + * Used for session title generation and debugging. + */ + private async _wireLog(event: Record): Promise { + const wireFile = this.agent.runtime.session.wireFile; + if (!wireFile) return; + try { + const { appendFile } = await import("node:fs/promises"); + const line = JSON.stringify({ ...event, ts: Date.now() }) + "\n"; + await appendFile(wireFile, line, "utf-8"); + } catch { + // Wire logging is best-effort — don't crash on failure + } + } +} + +// ── FlowRunner ───────────────────────────────────── + +import type { Flow, FlowNode, FlowEdge } from "../skill/flow/index.ts"; +import { parseChoice } from "../skill/flow/index.ts"; + +const DEFAULT_MAX_FLOW_MOVES = 1000; +const FLOW_COMMAND_PREFIX = "flow:"; + +interface FlowTurnResult { + /** Number of agent steps used in this turn. */ + stepCount: number; + /** Why the turn stopped. */ + stopReason: "no_tool_calls" | "tool_rejected"; + /** The final assistant message text, if any. */ + finalText: string | undefined; +} + +/** + * Drives the agent through a Flow graph, executing task and decision nodes. + * Corresponds to Python `FlowRunner` in `soul/kimisoul.py`. + */ +export class FlowRunner { + private readonly _flow: Flow; + private readonly _name: string | undefined; + private readonly _maxMoves: number; + + constructor(flow: Flow, opts?: { name?: string; maxMoves?: number }) { + this._flow = flow; + this._name = opts?.name; + this._maxMoves = opts?.maxMoves ?? DEFAULT_MAX_FLOW_MOVES; + } + + /** + * Build a FlowRunner for the ralph (auto-repeat) loop pattern. + * The agent runs a task repeatedly until it chooses STOP. + */ + static ralphLoop( + promptText: string, + maxRalphIterations: number, + ): FlowRunner { + const totalRuns = + maxRalphIterations < 0 ? 1_000_000_000_000_000 : maxRalphIterations + 1; + + const nodes: Record = { + BEGIN: { id: "BEGIN", label: "BEGIN", kind: "begin" }, + END: { id: "END", label: "END", kind: "end" }, + R1: { id: "R1", label: promptText, kind: "task" }, + R2: { + id: "R2", + label: + `${promptText}. (You are running in an automated loop where the same ` + + "prompt is fed repeatedly. Only choose STOP when the task is fully complete. " + + "Including it will stop further iterations. If you are not 100% sure, " + + "choose CONTINUE.)", + kind: "decision", + }, + }; + + const outgoing: Record = { + BEGIN: [{ src: "BEGIN", dst: "R1", label: undefined }], + R1: [{ src: "R1", dst: "R2", label: undefined }], + R2: [ + { src: "R2", dst: "R2", label: "CONTINUE" }, + { src: "R2", dst: "END", label: "STOP" }, + ], + END: [], + }; + + const flow: Flow = { nodes, outgoing, beginId: "BEGIN", endId: "END" }; + return new FlowRunner(flow, { maxMoves: totalRuns }); + } + + /** Execute the flow graph using the given KimiSoul. */ + async run(soul: KimiSoul, args: string): Promise { + if (args.trim()) { + const command = this._name + ? `/${FLOW_COMMAND_PREFIX}${this._name}` + : "/flow"; + logger.warn(`Agent flow ${command} ignores args: ${args}`); + return; + } + + let currentId = this._flow.beginId; + let moves = 0; + let totalSteps = 0; + + while (true) { + const node = this._flow.nodes[currentId]; + if (!node) { + logger.error(`Agent flow: unknown node "${currentId}"; stopping.`); + return; + } + const edges = this._flow.outgoing[currentId] ?? []; + + if (node.kind === "end") { + logger.info(`Agent flow reached END node ${currentId}`); + return; + } + + if (node.kind === "begin") { + if (edges.length === 0) { + logger.error( + `Agent flow BEGIN node "${node.id}" has no outgoing edges; stopping.`, + ); + return; + } + currentId = edges[0]!.dst; + continue; + } + + if (moves >= this._maxMoves) { + throw new MaxStepsReached(totalSteps); + } + + const result = await this._executeFlowNode(soul, node, edges); + totalSteps += result.stepsUsed; + if (result.nextId === undefined) return; + moves++; + currentId = result.nextId; + } + } + + private async _executeFlowNode( + soul: KimiSoul, + node: FlowNode, + edges: FlowEdge[], + ): Promise<{ nextId: string | undefined; stepsUsed: number }> { + if (edges.length === 0) { + logger.error( + `Agent flow node "${node.id}" has no outgoing edges; stopping.`, + ); + return { nextId: undefined, stepsUsed: 0 }; + } + + const basePrompt = FlowRunner._buildFlowPrompt(node, edges); + let prompt = basePrompt; + let stepsUsed = 0; + + while (true) { + const result = await FlowRunner._flowTurn(soul, prompt); + stepsUsed += result.stepCount; + + if (result.stopReason === "tool_rejected") { + logger.error("Agent flow stopped after tool rejection."); + return { nextId: undefined, stepsUsed }; + } + + if (node.kind !== "decision") { + return { nextId: edges[0]!.dst, stepsUsed }; + } + + const choice = result.finalText + ? parseChoice(result.finalText) + : undefined; + const nextId = FlowRunner._matchFlowEdge(edges, choice); + if (nextId !== undefined) { + return { nextId, stepsUsed }; + } + + const options = edges.map((e) => e.label ?? "").join(", "); + logger.warn( + `Agent flow invalid choice. Got: ${choice ?? ""}. Available: ${options}.`, + ); + prompt = + `${basePrompt}\n\n` + + "Your last response did not include a valid choice. " + + "Reply with one of the choices using ...."; + } + } + + private static _buildFlowPrompt( + node: FlowNode, + edges: FlowEdge[], + ): string { + if (node.kind !== "decision") { + return node.label; + } + + const choices = edges.filter((e) => e.label).map((e) => e.label!); + const lines = [ + node.label, + "", + "Available branches:", + ...choices.map((c) => `- ${c}`), + "", + "Reply with a choice using ....", + ]; + return lines.join("\n"); + } + + private static _matchFlowEdge( + edges: FlowEdge[], + choice: string | undefined, + ): string | undefined { + if (!choice) return undefined; + for (const edge of edges) { + if (edge.label === choice) return edge.dst; + } + return undefined; + } + + private static async _flowTurn( + soul: KimiSoul, + prompt: string, + ): Promise { + // TODO: Wire TurnBegin/TurnEnd events once wire_send is available + // For now, drive the soul's internal _turn method + const stepsBefore = soul["_stepCount"]; + await soul["_turn"](prompt); + const stepsAfter = soul["_stepCount"]; + + // Extract final assistant text from context + const history = soul["context"].messages; + const lastMsg = history.length > 0 ? history[history.length - 1] : undefined; + let finalText: string | undefined; + if (lastMsg?.role === "assistant") { + finalText = + typeof lastMsg.content === "string" + ? lastMsg.content + : Array.isArray(lastMsg.content) + ? lastMsg.content + .filter((p): p is { type: "text"; text: string } => "type" in p && p.type === "text") + .map((p) => p.text) + .join(" ") + : undefined; + } + + return { + stepCount: stepsAfter - stepsBefore, + stopReason: "no_tool_calls", + finalText, + }; + } +} diff --git a/src/kimi_cli_ts/soul/message.ts b/src/kimi_cli_ts/soul/message.ts new file mode 100644 index 000000000..216a052d8 --- /dev/null +++ b/src/kimi_cli_ts/soul/message.ts @@ -0,0 +1,106 @@ +/** + * Message utility functions — corresponds to Python soul/message.py + * Helpers for constructing system/tool messages. + */ + +import type { ContentPart, Message, ModelCapability } from "../types.ts"; + +/** Wrap text in tags. */ +export function system(message: string): ContentPart { + return { type: "text", text: `${message}` }; +} + +/** Wrap text in tags. */ +export function systemReminder(message: string): ContentPart { + return { type: "text", text: `\n${message}\n` }; +} + +/** Check whether a message is an internal system-reminder user message. */ +export function isSystemReminderMessage(message: Message): boolean { + if (message.role !== "user") return false; + if (typeof message.content === "string") { + return message.content.trim().startsWith(""); + } + if (Array.isArray(message.content) && message.content.length === 1) { + const part = message.content[0]!; + if (part.type === "text") { + return part.text.trim().startsWith(""); + } + } + return false; +} + +/** Build a tool result message from output. */ +export function toolResultMessage(opts: { + toolCallId: string; + output: string | ContentPart | ContentPart[]; + isError?: boolean; + message?: string; +}): Message { + const parts: ContentPart[] = []; + + if (opts.isError) { + const errMsg = opts.message ?? "Unknown error"; + parts.push(system(`ERROR: ${errMsg}`)); + const outputParts = outputToContentParts(opts.output); + parts.push(...outputParts); + } else { + if (opts.message) { + parts.push(system(opts.message)); + } + const outputParts = outputToContentParts(opts.output); + parts.push(...outputParts); + if (parts.length === 0) { + parts.push(system("Tool output is empty.")); + } else if (!parts.some((p) => p.type === "text")) { + // Ensure at least one TextPart exists so the LLM API won't reject + parts.unshift(system("Tool returned non-text content.")); + } + } + + return { + role: "tool", + content: [ + { + type: "tool_result", + toolUseId: opts.toolCallId, + content: parts.map((p) => (p.type === "text" ? p.text : JSON.stringify(p))).join("\n"), + isError: opts.isError, + }, + ], + }; +} + +/** Convert various output formats to ContentPart array. */ +function outputToContentParts( + output: string | ContentPart | ContentPart[], +): ContentPart[] { + if (typeof output === "string") { + return output ? [{ type: "text", text: output }] : []; + } + if (Array.isArray(output)) { + return output; + } + // Single ContentPart + return [output]; +} + +/** Check message content for required model capabilities, return missing ones. */ +export function checkMessage( + message: Message, + modelCapabilities: Set, +): Set { + const needed = new Set(); + const content = typeof message.content === "string" ? [] : message.content; + for (const part of content) { + if (part.type === "image") needed.add("image_in"); + if ((part as any).type === "video") needed.add("video_in"); + if ((part as any).type === "thinking") needed.add("thinking"); + } + // Return only the capabilities that are missing + const missing = new Set(); + for (const cap of needed) { + if (!modelCapabilities.has(cap)) missing.add(cap); + } + return missing; +} diff --git a/src/kimi_cli_ts/soul/slash.ts b/src/kimi_cli_ts/soul/slash.ts new file mode 100644 index 000000000..3b17ec602 --- /dev/null +++ b/src/kimi_cli_ts/soul/slash.ts @@ -0,0 +1,206 @@ +/** + * Slash command registry — corresponds to Python soul/slash concepts + * Provides registration + dispatch for /commands in the CLI. + */ + +import type { SlashCommand } from "../types.ts"; + +export class SlashCommandRegistry { + private commands = new Map(); + private aliases = new Map(); + + register(command: SlashCommand): void { + this.commands.set(command.name, command); + if (command.aliases) { + for (const alias of command.aliases) { + this.aliases.set(alias, command.name); + } + } + } + + get(name: string): SlashCommand | undefined { + const resolved = this.aliases.get(name) ?? name; + return this.commands.get(resolved); + } + + has(name: string): boolean { + return this.commands.has(name) || this.aliases.has(name); + } + + list(): SlashCommand[] { + return [...this.commands.values()]; + } + + async execute(input: string): Promise { + const trimmed = input.trim(); + if (!trimmed.startsWith("/")) return false; + + const spaceIdx = trimmed.indexOf(" "); + const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + const cmd = this.get(name); + if (!cmd) return false; + + await cmd.handler(args); + return true; + } +} + +/** + * Create a default registry with built-in commands. + * Handlers are stubs — the real app wires them up. + */ +export function createDefaultRegistry(): SlashCommandRegistry { + const registry = new SlashCommandRegistry(); + + const builtins: SlashCommand[] = [ + { + name: "clear", + description: "Clear conversation history", + aliases: ["reset"], + handler: async () => { + /* wired by app */ + }, + }, + { + name: "compact", + description: "Compact conversation context", + handler: async () => {}, + }, + { + name: "yolo", + description: "Toggle auto-approve mode", + aliases: ["auto-approve"], + handler: async () => {}, + }, + { + name: "plan", + description: "Toggle plan mode", + handler: async () => {}, + }, + { + name: "model", + description: "Switch model", + handler: async () => {}, + }, + { + name: "help", + description: "Show help", + aliases: ["?"], + handler: async () => {}, + }, + { + name: "init", + description: "Initialize project configuration", + handler: async () => {}, + }, + { + name: "add-dir", + description: "Add directory to workspace scope", + handler: async () => {}, + }, + // ── Commands below are newly registered to match Python version ── + { + name: "login", + description: "Login or setup a platform", + aliases: ["setup"], + handler: async () => {}, + }, + { + name: "logout", + description: "Logout from the current platform", + handler: async () => {}, + }, + { + name: "new", + description: "Start a new session", + handler: async () => {}, + }, + { + name: "sessions", + description: "List sessions and resume", + aliases: ["resume"], + handler: async () => {}, + }, + { + name: "title", + description: "Set or show the session title", + aliases: ["rename"], + handler: async () => {}, + }, + { + name: "task", + description: "Browse and manage background tasks", + handler: async () => {}, + }, + { + name: "editor", + description: "Set default external editor", + handler: async () => {}, + }, + { + name: "reload", + description: "Reload configuration", + handler: async () => {}, + }, + { + name: "usage", + description: "Display API usage and quota information", + aliases: ["status"], + handler: async () => {}, + }, + { + name: "changelog", + description: "Show release notes", + aliases: ["release-notes"], + handler: async () => {}, + }, + { + name: "feedback", + description: "Submit feedback", + handler: async () => {}, + }, + { + name: "hooks", + description: "List configured hooks", + handler: async () => {}, + }, + { + name: "mcp", + description: "Show MCP servers and tools", + handler: async () => {}, + }, + { + name: "web", + description: "Open Kimi Code Web UI in browser", + handler: async () => {}, + }, + { + name: "vis", + description: "Open Kimi Agent Tracing Visualizer", + handler: async () => {}, + }, + { + name: "export", + description: "Export session context to markdown", + handler: async () => {}, + }, + { + name: "import", + description: "Import context from file or session", + handler: async () => {}, + }, + { + name: "debug", + description: "Debug the context", + handler: async () => {}, + }, + ]; + + for (const cmd of builtins) { + registry.register(cmd); + } + + return registry; +} diff --git a/src/kimi_cli_ts/soul/toolset.ts b/src/kimi_cli_ts/soul/toolset.ts new file mode 100644 index 000000000..1dc3be49b --- /dev/null +++ b/src/kimi_cli_ts/soul/toolset.ts @@ -0,0 +1,193 @@ +/** + * Toolset — corresponds to Python soul/toolset.py + * Extended tool registry with hook integration, wire event emission, + * currentToolCall tracking, and sessionId context. + */ + +import type { CallableTool } from "../tools/base.ts"; +import { ToolRegistry } from "../tools/registry.ts"; +import type { ToolContext, ToolResult } from "../tools/types.ts"; +import type { HookEngine } from "../hooks/engine.ts"; +import type { ToolCall } from "../types.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Context variables (module-level singletons) ────── + +let _currentToolCall: ToolCall | null = null; +let _currentSessionId = ""; + +/** Set the current session ID for tool call context. */ +export function setSessionId(sid: string): void { + _currentSessionId = sid; +} + +/** Get the current session ID. */ +export function getSessionId(): string { + return _currentSessionId; +} + +/** Get the current tool call, or null if not in a tool execution. */ +export function getCurrentToolCallOrNull(): ToolCall | null { + return _currentToolCall; +} + +export interface ToolsetOptions { + context: ToolContext; + hookEngine?: HookEngine; + onToolCall?: (toolCall: ToolCall) => void; + onToolResult?: (toolCallId: string, result: ToolResult) => void; +} + +export class KimiToolset { + private registry: ToolRegistry; + private hookEngine?: HookEngine; + private hiddenTools = new Set(); + private onToolCall?: (toolCall: ToolCall) => void; + private onToolResult?: (toolCallId: string, result: ToolResult) => void; + + constructor(opts: ToolsetOptions) { + this.registry = new ToolRegistry(opts.context); + this.hookEngine = opts.hookEngine; + this.onToolCall = opts.onToolCall; + this.onToolResult = opts.onToolResult; + } + + get context(): ToolContext { + return this.registry.context; + } + + // ── Tool management ───────────────────────────── + + add(tool: CallableTool): void { + this.registry.register(tool); + } + + find(name: string): CallableTool | undefined { + return this.registry.find(name); + } + + list(): CallableTool[] { + return this.registry.list(); + } + + hide(toolName: string): void { + this.hiddenTools.add(toolName); + } + + unhide(toolName: string): void { + this.hiddenTools.delete(toolName); + } + + /** Get tool definitions for LLM, excluding hidden tools. */ + definitions(): Array<{ + name: string; + description: string; + parameters: Record; + }> { + return this.registry + .list() + .filter((t) => !this.hiddenTools.has(t.name)) + .map((t) => t.toDefinition()); + } + + // ── Tool execution with hooks ──────────────────── + + async handle(toolCall: ToolCall): Promise { + const { id, name, arguments: argsStr } = toolCall; + + // Set current tool call context + const prevToolCall = _currentToolCall; + _currentToolCall = toolCall; + + try { + // Notify about tool call + this.onToolCall?.(toolCall); + + // Parse arguments + let args: Record; + try { + args = argsStr ? JSON.parse(argsStr) : {}; + } catch { + const result: ToolResult = { + isError: true, + output: "", + message: `Failed to parse arguments for tool "${name}": ${argsStr}`, + }; + this.onToolResult?.(id, result); + return result; + } + + // Run PreToolUse hook + if (this.hookEngine?.hasHooksFor("PreToolUse")) { + const hookResults = await this.hookEngine.trigger("PreToolUse", { + matcherValue: name, + inputData: { + session_id: _currentSessionId, + tool_name: name, + tool_input: args, + tool_call_id: id, + }, + }); + + for (const hr of hookResults) { + if (hr.action === "block") { + const result: ToolResult = { + isError: true, + output: "", + message: `Tool "${name}" blocked by hook: ${hr.reason}`, + }; + this.onToolResult?.(id, result); + return result; + } + } + } + + // Execute tool + let result: ToolResult; + try { + result = await this.registry.execute(name, args); + } catch (err) { + logger.error(`Tool "${name}" threw an error: ${err}`); + result = { + isError: true, + output: "", + message: `Tool "${name}" failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // Run PostToolUse / PostToolUseFailure hook (fire-and-forget) + if (this.hookEngine) { + const hookEvent = result.isError ? "PostToolUseFailure" : "PostToolUse"; + if (this.hookEngine.hasHooksFor(hookEvent as any)) { + this.hookEngine + .trigger(hookEvent as any, { + matcherValue: name, + inputData: { + session_id: _currentSessionId, + tool_name: name, + tool_input: args, + tool_output: (result.output ?? "").slice(0, 2000), + tool_error: result.isError ? result.message : undefined, + tool_call_id: id, + }, + }) + .catch(() => {}); // fire-and-forget + } + } + + // Notify about result + this.onToolResult?.(id, result); + + return result; + } finally { + // Restore previous tool call context + _currentToolCall = prevToolCall; + } + } + + // ── Cleanup ─────────────────────────────────────── + + async cleanup(): Promise { + // Cleanup MCP connections, etc. (future) + } +} diff --git a/src/kimi_cli_ts/subagents/builder.ts b/src/kimi_cli_ts/subagents/builder.ts new file mode 100644 index 000000000..ee5d465dc --- /dev/null +++ b/src/kimi_cli_ts/subagents/builder.ts @@ -0,0 +1,56 @@ +/** + * Subagent builder — corresponds to Python subagents/builder.py + * Constructs subagent instances from type definitions. + */ + +import { type Runtime, type Agent, loadAgent } from "../soul/agent.ts"; +import type { AgentLaunchSpec, AgentTypeDefinition } from "./models.ts"; + +export class SubagentBuilder { + private _rootRuntime: Runtime; + + constructor(rootRuntime: Runtime) { + this._rootRuntime = rootRuntime; + } + + /** + * Build a subagent Agent instance from a type definition and launch spec. + * Corresponds to Python SubagentBuilder.build_builtin_instance(). + */ + async buildBuiltinInstance(opts: { + agentId: string; + typeDef: AgentTypeDefinition; + launchSpec: AgentLaunchSpec; + }): Promise { + const _effectiveModel = SubagentBuilder.resolveEffectiveModel({ + typeDef: opts.typeDef, + launchSpec: opts.launchSpec, + }); + + // Create a subagent runtime copy + const runtime = this._rootRuntime.copyForSubagent(); + + // TODO: If effectiveModel differs from root, clone LLM with model alias. + // For now, subagents inherit the root LLM. + + return await loadAgent({ + runtime, + agentName: opts.typeDef.agentFile, + }); + } + + /** + * Determine the effective model for a subagent launch. + * Priority: launch spec override > launch spec effective > type definition default. + */ + static resolveEffectiveModel(opts: { + typeDef: AgentTypeDefinition; + launchSpec: AgentLaunchSpec; + }): string | undefined { + return ( + opts.launchSpec.modelOverride ?? + opts.launchSpec.effectiveModel ?? + opts.typeDef.defaultModel + ); + } +} diff --git a/src/kimi_cli_ts/subagents/core.ts b/src/kimi_cli_ts/subagents/core.ts new file mode 100644 index 000000000..891813f8f --- /dev/null +++ b/src/kimi_cli_ts/subagents/core.ts @@ -0,0 +1,77 @@ +/** + * Subagent run spec and prepare_soul pipeline — corresponds to Python subagents/core.py + */ + +import { writeFileSync } from "node:fs"; + +import type { Runtime, Agent } from "../soul/agent.ts"; +import { KimiSoul } from "../soul/kimisoul.ts"; +import { Context } from "../soul/context.ts"; +import type { AgentLaunchSpec, AgentTypeDefinition } from "./models.ts"; +import type { SubagentBuilder } from "./builder.ts"; +import type { SubagentStore } from "./store.ts"; +import { collectGitContext } from "./git_context.ts"; + +export interface SubagentRunSpec { + readonly agentId: string; + readonly typeDef: AgentTypeDefinition; + readonly launchSpec: AgentLaunchSpec; + readonly prompt: string; + readonly resumed: boolean; +} + +/** + * Build agent, restore context, handle system prompt, write prompt file. + * Returns [soul, finalPrompt] ready for execution. + * Corresponds to Python subagents/core.py:prepare_soul. + */ +export async function prepareSoul( + spec: SubagentRunSpec, + runtime: Runtime, + builder: SubagentBuilder, + store: SubagentStore, + onStage?: (name: string) => void, +): Promise<[KimiSoul, string]> { + // 1. Build agent from type definition + const agent = await builder.buildBuiltinInstance({ + agentId: spec.agentId, + typeDef: spec.typeDef, + launchSpec: spec.launchSpec, + }); + onStage?.("agent_built"); + + // 2. Restore conversation context + const context = new Context(store.contextPath(spec.agentId)); + await context.restore(); + onStage?.("context_restored"); + + // 3. System prompt: reuse persisted prompt on resume, persist on first run + if (context.systemPrompt !== null) { + // On resume, the agent's system prompt is overridden by the persisted one. + // The KimiSoul constructor uses agent.systemPrompt for the initial system, + // but context already has the correct system prompt restored from file. + } else { + await context.writeSystemPrompt(agent.systemPrompt); + } + onStage?.("context_ready"); + + // 4. For new (non-resumed) explore agents, prepend git context + let prompt = spec.prompt; + if (spec.typeDef.name === "explore" && !spec.resumed) { + const gitCtx = await collectGitContext(runtime.builtinArgs.KIMI_WORK_DIR); + if (gitCtx) { + prompt = `${gitCtx}\n\n${prompt}`; + } + } + + // 5. Write prompt snapshot (debugging aid) + try { + writeFileSync(store.promptPath(spec.agentId), prompt, "utf-8"); + } catch { + // Best effort + } + + // 6. Create soul + const soul = new KimiSoul({ agent, context }); + return [soul, prompt]; +} diff --git a/src/kimi_cli_ts/subagents/git_context.ts b/src/kimi_cli_ts/subagents/git_context.ts new file mode 100644 index 000000000..c0eb09aa4 --- /dev/null +++ b/src/kimi_cli_ts/subagents/git_context.ts @@ -0,0 +1,127 @@ +/** + * Git context collection for explore subagents — corresponds to Python subagents/git_context.py + * Collects git repository metadata (remote URL, branch, dirty files, recent commits). + */ + +import { logger } from "../utils/logging.ts"; + +const TIMEOUT = 5000; // ms +const MAX_DIRTY_FILES = 20; + +const ALLOWED_HOSTS = [ + "github.com", + "gitlab.com", + "gitee.com", + "bitbucket.org", + "codeberg.org", + "sr.ht", +]; + +export async function collectGitContext(workDir: string): Promise { + // Quick check: is this a git repo? + if ((await runGit(["rev-parse", "--is-inside-work-tree"], workDir)) == null) { + return ""; + } + + // Run all git commands in parallel + const [remoteUrl, branch, dirtyRaw, logRaw] = await Promise.all([ + runGit(["remote", "get-url", "origin"], workDir), + runGit(["branch", "--show-current"], workDir), + runGit(["status", "--porcelain"], workDir), + runGit(["log", "-3", "--format=%h %s"], workDir), + ]); + + const sections: string[] = []; + sections.push(`Working directory: ${workDir}`); + + // Remote origin & project name + if (remoteUrl) { + const safeUrl = sanitizeRemoteUrl(remoteUrl); + if (safeUrl) sections.push(`Remote: ${safeUrl}`); + const project = parseProjectName(remoteUrl); + if (project) sections.push(`Project: ${project}`); + } + + // Current branch + if (branch) sections.push(`Branch: ${branch}`); + + // Dirty files + if (dirtyRaw != null) { + const dirtyLines = dirtyRaw.split("\n").filter((l) => l.trim()); + if (dirtyLines.length > 0) { + const total = dirtyLines.length; + const shown = dirtyLines.slice(0, MAX_DIRTY_FILES); + const header = `Dirty files (${total}):`; + let body = shown.map((l) => ` ${l}`).join("\n"); + if (total > MAX_DIRTY_FILES) { + body += `\n ... and ${total - MAX_DIRTY_FILES} more`; + } + sections.push(`${header}\n${body}`); + } + } + + // Recent commits + if (logRaw) { + const logLines = logRaw.split("\n").filter((l) => l.trim()); + if (logLines.length > 0) { + const body = logLines.map((l) => ` ${l.slice(0, 200)}`).join("\n"); + sections.push(`Recent commits:\n${body}`); + } + } + + if (sections.length <= 1) return ""; + const content = sections.join("\n"); + return `\n${content}\n`; +} + +async function runGit(args: string[], cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const timer = setTimeout(() => proc.kill(), TIMEOUT); + const exitCode = await proc.exited; + clearTimeout(timer); + + if (exitCode !== 0) return undefined; + const stdout = await new Response(proc.stdout).text(); + return stdout.trim(); + } catch { + logger.debug(`git ${args.join(" ")} failed`); + return undefined; + } +} + +function sanitizeRemoteUrl(remoteUrl: string): string | undefined { + // SSH format: git@host:owner/repo.git + for (const host of ALLOWED_HOSTS) { + const pattern = new RegExp(`^git@${host.replace(".", "\\.")}:`); + if (pattern.test(remoteUrl)) return remoteUrl; + } + + // HTTPS format + try { + const url = new URL(remoteUrl); + if (ALLOWED_HOSTS.includes(url.hostname)) { + const port = url.port ? `:${url.port}` : ""; + return `https://${url.hostname}${port}${url.pathname}`; + } + } catch { + // Not a valid URL + } + + return undefined; +} + +function parseProjectName(remoteUrl: string): string | undefined { + // SSH format: git@host:owner/repo.git + const sshMatch = remoteUrl.match(/:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + // HTTPS format: https://host/owner/repo.git + const httpsMatch = remoteUrl.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + return undefined; +} diff --git a/src/kimi_cli_ts/subagents/models.ts b/src/kimi_cli_ts/subagents/models.ts new file mode 100644 index 000000000..cdc156525 --- /dev/null +++ b/src/kimi_cli_ts/subagents/models.ts @@ -0,0 +1,52 @@ +/** + * Subagent models — corresponds to Python subagents/models.py + */ + +export type ToolPolicyMode = "inherit" | "allowlist"; +export type SubagentStatus = + | "idle" + | "running_foreground" + | "running_background" + | "completed" + | "failed" + | "killed"; + +export interface ToolPolicy { + readonly mode: ToolPolicyMode; + readonly tools: readonly string[]; +} + +export interface AgentTypeDefinition { + readonly name: string; + readonly description: string; + readonly agentFile: string; + readonly whenToUse: string; + readonly defaultModel?: string; + readonly toolPolicy: ToolPolicy; + readonly supportsBackground: boolean; +} + +export interface AgentLaunchSpec { + readonly agentId: string; + readonly subagentType: string; + readonly modelOverride?: string; + readonly effectiveModel?: string; + readonly createdAt: number; +} + +export interface AgentInstanceRecord { + readonly agentId: string; + readonly subagentType: string; + readonly status: SubagentStatus; + readonly description: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly lastTaskId?: string; + readonly launchSpec: AgentLaunchSpec; +} + +// ── Defaults ── + +export function defaultToolPolicy(): ToolPolicy { + return { mode: "inherit", tools: [] }; +} diff --git a/src/kimi_cli_ts/subagents/output.ts b/src/kimi_cli_ts/subagents/output.ts new file mode 100644 index 000000000..59d7d2c7e --- /dev/null +++ b/src/kimi_cli_ts/subagents/output.ts @@ -0,0 +1,59 @@ +/** + * Subagent output writer — corresponds to Python subagents/output.py + * Appends human-readable transcript lines to output files. + */ + +import { appendFileSync } from "node:fs"; + +export class SubagentOutputWriter { + private _path: string; + private _extraPaths: string[]; + + constructor(path: string, extraPaths: string[] = []) { + this._path = path; + this._extraPaths = extraPaths; + } + + stage(name: string): void { + this.append(`[stage] ${name}\n`); + } + + toolCall(name: string): void { + this.append(`[tool] ${name}\n`); + } + + toolResult(status: "success" | "error", brief?: string): void { + if (brief) { + this.append(`[tool_result] ${status}: ${brief}\n`); + } else { + this.append(`[tool_result] ${status}\n`); + } + } + + text(text: string): void { + if (text) this.append(text); + } + + summary(text: string): void { + if (text) this.append(`\n[summary]\n${text}\n`); + } + + error(message: string): void { + this.append(`[error] ${message}\n`); + } + + private append(text: string): void { + try { + appendFileSync(this._path, text, "utf-8"); + } catch { + // Ignore write errors + } + for (const p of this._extraPaths) { + try { + appendFileSync(p, text, "utf-8"); + } catch { + // Best-effort tee + } + } + } +} diff --git a/src/kimi_cli_ts/subagents/registry.ts b/src/kimi_cli_ts/subagents/registry.ts new file mode 100644 index 000000000..3463db11c --- /dev/null +++ b/src/kimi_cli_ts/subagents/registry.ts @@ -0,0 +1,30 @@ +/** + * Subagent type registry — corresponds to Python subagents/registry.py + * LaborMarket holds the available agent type definitions. + */ + +import type { AgentTypeDefinition } from "./models.ts"; + +export class LaborMarket { + private _builtinTypes = new Map(); + + get builtinTypes(): ReadonlyMap { + return this._builtinTypes; + } + + addBuiltinType(typeDef: AgentTypeDefinition): void { + this._builtinTypes.set(typeDef.name, typeDef); + } + + getBuiltinType(name: string): AgentTypeDefinition | undefined { + return this._builtinTypes.get(name); + } + + requireBuiltinType(name: string): AgentTypeDefinition { + const typeDef = this._builtinTypes.get(name); + if (!typeDef) { + throw new Error(`Builtin subagent type not found: ${name}`); + } + return typeDef; + } +} diff --git a/src/kimi_cli_ts/subagents/runner.ts b/src/kimi_cli_ts/subagents/runner.ts new file mode 100644 index 000000000..22a8f6d5b --- /dev/null +++ b/src/kimi_cli_ts/subagents/runner.ts @@ -0,0 +1,355 @@ +/** + * Foreground subagent runner — corresponds to Python subagents/runner.py + * Manages the lifecycle of foreground subagent executions. + */ + +import { randomUUID } from "node:crypto"; + +import type { Runtime } from "../soul/agent.ts"; +import type { Message, ContentPart } from "../types.ts"; +import { KimiSoul, MaxStepsReached, RunCancelled } from "../soul/kimisoul.ts"; +import { getCurrentToolCallOrNull } from "../soul/toolset.ts"; +import { ToolOk, ToolError, type ToolResult } from "../tools/types.ts"; +import { SubagentOutputWriter } from "./output.ts"; +import { SubagentStore } from "./store.ts"; +import { SubagentBuilder } from "./builder.ts"; +import type { AgentInstanceRecord } from "./models.ts"; +import { type SubagentRunSpec, prepareSoul } from "./core.ts"; +import type { ApprovalSource } from "../approval_runtime/index.ts"; +import * as hookEvents from "../hooks/events.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Constants ───────────────────────────────────────────── + +export const SUMMARY_MIN_LENGTH = 200; +export const SUMMARY_CONTINUATION_ATTEMPTS = 1; +export const SUMMARY_CONTINUATION_PROMPT = `Your previous response was too brief. Please provide a more comprehensive summary that includes: + +1. Specific technical details and implementations +2. Detailed findings and analysis +3. All important information that the parent agent should know`; + +// ── Shared result types ────────────────────────────────── + +export interface SoulRunFailure { + readonly message: string; + readonly brief: string; +} + +export interface ForegroundRunRequest { + readonly description: string; + readonly prompt: string; + readonly requestedType: string; + readonly model?: string; + readonly resume?: string; +} + +export interface PreparedInstance { + readonly record: AgentInstanceRecord; + readonly actualType: string; + readonly resumed: boolean; +} + +// ── Execution helpers ──────────────────────────────────── + +/** + * Extract text content from the last assistant message in history. + */ +function extractAssistantText(history: readonly Message[]): string { + if (history.length === 0) return ""; + const last = history[history.length - 1]!; + if (last.role !== "assistant") return ""; + if (typeof last.content === "string") return last.content; + if (Array.isArray(last.content)) { + return (last.content as ContentPart[]) + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("\n"); + } + return ""; +} + +/** + * Run a single soul turn and validate the result. + * Returns a SoulRunFailure if the run failed, or null on success. + * Corresponds to Python run_soul_checked(). + */ +export async function runSoulChecked( + soul: KimiSoul, + prompt: string, + phase: string, +): Promise { + try { + await soul.run(prompt); + } catch (err) { + // RunCancelled must propagate — the caller marks the instance as killed. + if (err instanceof RunCancelled) { + throw err; + } + if (err instanceof MaxStepsReached) { + return { + message: + `Max steps ${err.maxSteps} reached when ${phase}. ` + + "Please try splitting the task into smaller subtasks.", + brief: "Max steps reached", + }; + } + // Convert any other error into a structured failure so the caller can + // report it without crashing the parent agent. + const errMsg = err instanceof Error ? err.message : String(err); + logger.error(`Soul run error during ${phase}: ${errMsg}`); + return { + message: `Error when ${phase}: ${errMsg}`, + brief: "Soul run error", + }; + } + + const history = soul.ctx.history; + if (history.length === 0 || history[history.length - 1]!.role !== "assistant") { + return { + message: "The agent did not produce a valid assistant response.", + brief: "Invalid agent result", + }; + } + return null; +} + +/** + * Run soul, then optionally extend the summary if it is too short. + * Returns [finalResponse, failure]. On success failure is null. + * Corresponds to Python run_with_summary_continuation(). + */ +export async function runWithSummaryContinuation( + soul: KimiSoul, + prompt: string, +): Promise<[string | null, SoulRunFailure | null]> { + const failure = await runSoulChecked(soul, prompt, "running agent"); + if (failure !== null) return [null, failure]; + + let finalResponse = extractAssistantText(soul.ctx.history); + let remaining = SUMMARY_CONTINUATION_ATTEMPTS; + + while (remaining > 0 && finalResponse.length < SUMMARY_MIN_LENGTH) { + remaining--; + const contFailure = await runSoulChecked( + soul, + SUMMARY_CONTINUATION_PROMPT, + "continuing the agent summary", + ); + if (contFailure !== null) return [null, contFailure]; + finalResponse = extractAssistantText(soul.ctx.history); + } + + return [finalResponse, null]; +} + +// ── ForegroundSubagentRunner ───────────────────────────── + +export class ForegroundSubagentRunner { + private _runtime: Runtime; + private _store: SubagentStore; + private _builder: SubagentBuilder; + + constructor(runtime: Runtime) { + if (!runtime.subagentStore) { + throw new Error("Runtime must have a subagentStore to run subagents."); + } + this._runtime = runtime; + this._store = runtime.subagentStore; + this._builder = new SubagentBuilder(runtime); + } + + async run(req: ForegroundRunRequest): Promise { + const prepared = await this._prepareInstance(req); + const agentId = prepared.record.agentId; + const actualType = prepared.actualType; + const resumed = prepared.resumed; + + const laborMarket = this._runtime.laborMarket; + if (!laborMarket) { + return ToolError("LaborMarket not available on runtime."); + } + const typeDef = laborMarket.requireBuiltinType(actualType); + + let launchSpec = prepared.record.launchSpec; + if (req.model) { + launchSpec = { + ...launchSpec, + modelOverride: req.model, + effectiveModel: req.model, + }; + } + + const outputWriter = new SubagentOutputWriter(this._store.outputPath(agentId)); + outputWriter.stage("runner_started"); + + const spec: SubagentRunSpec = { + agentId, + typeDef, + launchSpec, + prompt: req.prompt, + resumed, + }; + + const [soul, prompt] = await prepareSoul( + spec, + this._runtime, + this._builder, + this._store, + (name) => outputWriter.stage(name), + ); + + this._store.updateInstance(agentId, { + status: "running_foreground", + description: req.description.trim(), + }); + + const toolCall = getCurrentToolCallOrNull(); + const parentToolCallId = toolCall?.id ?? null; + + // approvalSource is created inside the try block so the finally + // clause can safely skip cancellation if we never got that far. + let approvalSource: ApprovalSource | null = null; + + try { + // Create a stable ApprovalSource for the entire run (including + // summary continuation). This ensures cancelBySource can reliably + // cancel all pending approval requests belonging to this execution. + approvalSource = { + kind: "foreground_turn", + id: randomUUID().replace(/-/g, ""), + agentId, + subagentType: actualType, + }; + + // --- SubagentStart hook --- + const hookEngine = this._runtime.hookEngine; + await hookEngine.trigger("SubagentStart", { + matcherValue: actualType, + inputData: hookEvents.subagentStart({ + sessionId: this._runtime.session.id, + cwd: process.cwd(), + agentName: actualType, + prompt: req.prompt.slice(0, 500), + }), + }); + + outputWriter.stage("run_soul_start"); + const [finalResponse, failure] = await runWithSummaryContinuation(soul, prompt); + + if (failure !== null) { + this._store.updateInstance(agentId, { status: "failed" }); + outputWriter.stage(`failed: ${failure.brief}`); + return ToolError(failure.message); + } + + // Defensive check — finalResponse should never be null when failure is null, + // but guard against unexpected edge cases instead of crashing. + if (finalResponse == null) { + this._store.updateInstance(agentId, { status: "failed" }); + outputWriter.stage("failed: empty response"); + return ToolError("The agent did not produce a response."); + } + outputWriter.stage("run_soul_finished"); + + // --- SubagentStop hook (fire-and-forget) --- + hookEngine + .trigger("SubagentStop", { + matcherValue: actualType, + inputData: hookEvents.subagentStop({ + sessionId: this._runtime.session.id, + cwd: process.cwd(), + agentName: actualType, + response: finalResponse.slice(0, 500), + }), + }) + .catch(() => {}); + + // Success + this._store.updateInstance(agentId, { status: "idle" }); + outputWriter.summary(finalResponse); + + const lines = [ + `agent_id: ${agentId}`, + resumed ? "resumed: true" : "resumed: false", + ]; + if (resumed && req.requestedType && req.requestedType !== actualType) { + lines.push(`requested_subagent_type: ${req.requestedType}`); + } + lines.push( + `actual_subagent_type: ${actualType}`, + "status: completed", + "", + "[summary]", + finalResponse, + ); + return ToolOk(lines.join("\n")); + } catch (err) { + if (err instanceof RunCancelled) { + this._store.updateInstance(agentId, { status: "killed" }); + outputWriter.stage("cancelled"); + throw err; + } + if (err instanceof Error && err.name === "AbortError") { + this._store.updateInstance(agentId, { status: "killed" }); + outputWriter.stage("cancelled"); + throw err; + } + this._store.updateInstance(agentId, { status: "failed" }); + outputWriter.stage("failed_exception"); + throw err; + } finally { + // Cancel any pending approval requests from this subagent execution + if (approvalSource && this._runtime.approvalRuntime) { + this._runtime.approvalRuntime.cancelBySource( + approvalSource.kind, + approvalSource.id, + ); + } + } + } + + private async _prepareInstance(req: ForegroundRunRequest): Promise { + if (req.resume) { + const record = this._store.requireInstance(req.resume); + if ( + record.status === "running_foreground" || + record.status === "running_background" + ) { + throw new Error( + `Agent instance ${record.agentId} is still ${record.status} and cannot be ` + + "resumed concurrently.", + ); + } + return { + record, + actualType: record.subagentType, + resumed: true, + }; + } + + const actualType = req.requestedType || "coder"; + const laborMarket = this._runtime.laborMarket; + if (!laborMarket) { + throw new Error("LaborMarket not available on runtime."); + } + const typeDef = laborMarket.requireBuiltinType(actualType); + const agentId = `a${randomUUID().replace(/-/g, "").slice(0, 8)}`; + const record = this._store.createInstance({ + agentId, + description: req.description.trim(), + launchSpec: { + agentId, + subagentType: actualType, + modelOverride: req.model, + effectiveModel: req.model ?? typeDef.defaultModel, + createdAt: Date.now() / 1000, + }, + }); + return { + record, + actualType, + resumed: false, + }; + } +} diff --git a/src/kimi_cli_ts/subagents/store.ts b/src/kimi_cli_ts/subagents/store.ts new file mode 100644 index 000000000..43370b87e --- /dev/null +++ b/src/kimi_cli_ts/subagents/store.ts @@ -0,0 +1,210 @@ +/** + * Subagent store — corresponds to Python subagents/store.py + * File-based persistence for subagent instance metadata. + */ + +import { join } from "node:path"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, + rmSync, +} from "node:fs"; +import { logger } from "../utils/logging.ts"; +import type { + AgentInstanceRecord, + AgentLaunchSpec, + SubagentStatus, +} from "./models.ts"; + +function recordFromJson(data: Record): AgentInstanceRecord { + const launchSpec = data.launch_spec as Record ?? data.launchSpec as Record ?? {}; + return { + agentId: String(data.agent_id ?? data.agentId ?? ""), + subagentType: String(data.subagent_type ?? data.subagentType ?? ""), + status: String(data.status ?? "idle") as SubagentStatus, + description: String(data.description ?? ""), + createdAt: Number(data.created_at ?? data.createdAt ?? 0), + updatedAt: Number(data.updated_at ?? data.updatedAt ?? 0), + lastTaskId: (data.last_task_id ?? data.lastTaskId) as string | undefined, + launchSpec: { + agentId: String(launchSpec.agent_id ?? launchSpec.agentId ?? ""), + subagentType: String(launchSpec.subagent_type ?? launchSpec.subagentType ?? ""), + modelOverride: (launchSpec.model_override ?? launchSpec.modelOverride) as string | undefined, + effectiveModel: (launchSpec.effective_model ?? launchSpec.effectiveModel) as string | undefined, + createdAt: Number(launchSpec.created_at ?? launchSpec.createdAt ?? 0), + }, + }; +} + +function recordToJson(record: AgentInstanceRecord): Record { + return { + agent_id: record.agentId, + subagent_type: record.subagentType, + status: record.status, + description: record.description, + created_at: record.createdAt, + updated_at: record.updatedAt, + last_task_id: record.lastTaskId, + launch_spec: { + agent_id: record.launchSpec.agentId, + subagent_type: record.launchSpec.subagentType, + model_override: record.launchSpec.modelOverride, + effective_model: record.launchSpec.effectiveModel, + created_at: record.launchSpec.createdAt, + }, + }; +} + +export class SubagentStore { + private _root: string; + + constructor(root: string) { + this._root = root; + } + + get root(): string { + return this._root; + } + + instanceDir(agentId: string, create = false): string { + const path = join(this._root, agentId); + if (create && !existsSync(path)) { + mkdirSync(path, { recursive: true }); + } + return path; + } + + contextPath(agentId: string): string { + return join(this.instanceDir(agentId), "context.jsonl"); + } + + wirePath(agentId: string): string { + return join(this.instanceDir(agentId), "wire.jsonl"); + } + + metaPath(agentId: string): string { + return join(this.instanceDir(agentId), "meta.json"); + } + + promptPath(agentId: string): string { + return join(this.instanceDir(agentId), "prompt.txt"); + } + + outputPath(agentId: string): string { + return join(this.instanceDir(agentId), "output"); + } + + createInstance(opts: { + agentId: string; + description: string; + launchSpec: AgentLaunchSpec; + }): AgentInstanceRecord { + this.initializeInstanceFiles(opts.agentId); + const record: AgentInstanceRecord = { + agentId: opts.agentId, + subagentType: opts.launchSpec.subagentType, + status: "idle", + description: opts.description, + createdAt: opts.launchSpec.createdAt, + updatedAt: opts.launchSpec.createdAt, + launchSpec: opts.launchSpec, + }; + this.writeInstance(record); + return record; + } + + writeInstance(record: AgentInstanceRecord): void { + const dir = this.instanceDir(record.agentId, true); + const tmpPath = join(dir, "meta.json.tmp"); + const targetPath = join(dir, "meta.json"); + writeFileSync(tmpPath, JSON.stringify(recordToJson(record), null, 2), "utf-8"); + const { renameSync } = require("node:fs"); + renameSync(tmpPath, targetPath); + } + + private initializeInstanceFiles(agentId: string): void { + const dir = this.instanceDir(agentId, true); + for (const name of ["context.jsonl", "wire.jsonl", "prompt.txt", "output"]) { + const path = join(dir, name); + if (!existsSync(path)) { + writeFileSync(path, "", "utf-8"); + } + } + } + + getInstance(agentId: string): AgentInstanceRecord | undefined { + const meta = this.metaPath(agentId); + if (!existsSync(meta)) return undefined; + try { + const data = JSON.parse(readFileSync(meta, "utf-8")); + return recordFromJson(data); + } catch (err) { + logger.warn(`Corrupted instance record for agent ${agentId}, skipping: ${err}`); + return undefined; + } + } + + requireInstance(agentId: string): AgentInstanceRecord { + const record = this.getInstance(agentId); + if (!record) { + throw new Error(`Subagent instance not found: ${agentId}`); + } + return record; + } + + updateInstance( + agentId: string, + opts?: { + status?: SubagentStatus; + description?: string; + lastTaskId?: string | null; + }, + ): AgentInstanceRecord { + const current = this.requireInstance(agentId); + const record: AgentInstanceRecord = { + agentId: current.agentId, + subagentType: current.subagentType, + status: opts?.status ?? current.status, + description: opts?.description ?? current.description, + createdAt: current.createdAt, + updatedAt: Date.now() / 1000, + lastTaskId: opts?.lastTaskId !== undefined ? (opts.lastTaskId ?? undefined) : current.lastTaskId, + launchSpec: current.launchSpec, + }; + this.writeInstance(record); + return record; + } + + listInstances(): AgentInstanceRecord[] { + if (!existsSync(this._root)) return []; + const records: AgentInstanceRecord[] = []; + for (const entry of readdirSync(this._root)) { + const dirPath = join(this._root, entry); + try { + if (!statSync(dirPath).isDirectory()) continue; + } catch { + continue; + } + const meta = join(dirPath, "meta.json"); + if (!existsSync(meta)) continue; + try { + const record = recordFromJson(JSON.parse(readFileSync(meta, "utf-8"))); + records.push(record); + } catch (err) { + logger.warn(`Skipping corrupted subagent instance ${entry}: ${err}`); + } + } + records.sort((a, b) => b.updatedAt - a.updatedAt); + return records; + } + + deleteInstance(agentId: string): void { + const dir = this.instanceDir(agentId); + if (!existsSync(dir)) return; + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/src/kimi_cli_ts/tools/agent/agent.ts b/src/kimi_cli_ts/tools/agent/agent.ts new file mode 100644 index 000000000..705b7655b --- /dev/null +++ b/src/kimi_cli_ts/tools/agent/agent.ts @@ -0,0 +1,216 @@ +/** + * Agent tool — spawn subagent instances. + * Corresponds to Python tools/agent/__init__.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import type { Runtime } from "../../soul/agent.ts"; +import type { AgentTypeDefinition } from "../../subagents/models.ts"; +import { + ForegroundSubagentRunner, + type ForegroundRunRequest, +} from "../../subagents/runner.ts"; +import { logger } from "../../utils/logging.ts"; + +const MAX_FOREGROUND_TIMEOUT = 60 * 60; // 1 hour +const MAX_BACKGROUND_TIMEOUT = 60 * 60; // 1 hour + +const DESCRIPTION = `Start a subagent instance to work on a focused task. + +**Usage:** +- Always provide a short \`description\` (3-5 words). +- Use \`subagent_type\` to select a built-in agent type. If omitted, \`coder\` is used. +- Use \`model\` when you need to override the default model. +- Default to foreground execution. Use \`run_in_background=true\` only when needed. +- Be explicit about whether the subagent should write code or only do research. +- The subagent result is only visible to you. If the user should see it, summarize it yourself.`; + +const ParamsSchema = z.object({ + description: z + .string() + .describe("A short (3-5 word) description of the task"), + prompt: z.string().describe("The task for the agent to perform"), + subagent_type: z + .string() + .default("coder") + .describe("The built-in agent type to use. Defaults to `coder`."), + model: z + .string() + .nullish() + .describe( + "Optional model override. Selection priority is: this parameter, then the built-in " + + "type default model, then the parent agent's current model.", + ), + resume: z + .string() + .nullish() + .describe( + "Optional agent ID to resume instead of creating a new instance.", + ), + run_in_background: z + .boolean() + .default(false) + .describe( + "Whether to run the agent in the background. Prefer false unless the task can " + + "continue independently and there is a clear benefit to returning control before " + + "the result is needed.", + ), + timeout: z + .number() + .int() + .min(30) + .max(MAX_BACKGROUND_TIMEOUT) + .nullish() + .describe( + "Timeout in seconds for the agent task. " + + "Foreground: no default timeout (runs until completion), max 3600s (1hr). " + + "Background: default from config (15min), max 3600s (1hr). " + + "The agent is stopped if it exceeds this limit.", + ), +}); + +type Params = z.infer; + +export class AgentTool extends CallableTool { + readonly name = "Agent"; + readonly schema = ParamsSchema; + private _description: string; + + get description(): string { + return this._description; + } + + constructor() { + super(); + this._description = DESCRIPTION; + } + + /** + * Build the full description including available builtin types. + * Should be called after the tool is registered and runtime is available. + */ + buildDescription(runtime: Runtime): void { + const typeLines = AgentTool._builtinTypeLines(runtime); + if (typeLines) { + this._description = + DESCRIPTION + "\n\n**Available subagent types:**\n" + typeLines; + } + } + + private static _builtinTypeLines(runtime: Runtime): string { + if (!runtime.laborMarket) return ""; + const lines: string[] = []; + for (const [name, typeDef] of runtime.laborMarket.builtinTypes) { + const toolNames = AgentTool._toolSummary(typeDef); + const model = typeDef.defaultModel ?? "inherit"; + const suffix = typeDef.whenToUse + ? ` When to use: ${AgentTool._normalizeSummary(typeDef.whenToUse)}` + : ""; + const background = typeDef.supportsBackground ? "yes" : "no"; + lines.push( + `- \`${name}\`: ${typeDef.description} ` + + `(Tools: ${toolNames}, Model: ${model}, Background: ${background}).${suffix}`, + ); + } + return lines.join("\n"); + } + + private static _normalizeSummary(text: string): string { + return text.split(/\s+/).join(" "); + } + + private static _toolSummary(typeDef: AgentTypeDefinition): string { + if (typeDef.toolPolicy.mode !== "allowlist") return "*"; + if (typeDef.toolPolicy.tools.length === 0) return "(none)"; + return AgentTool._uniqueToolNames(typeDef.toolPolicy.tools).join(", "); + } + + private static _uniqueToolNames(toolPaths: readonly string[]): string[] { + const names: string[] = []; + for (const path of toolPaths) { + const name = path.split(":").pop() ?? path; + if (!names.includes(name)) { + names.push(name); + } + } + return names; + } + + async execute(params: Params, ctx: ToolContext): Promise { + const runtime = ctx.runtime; + if (!runtime) { + return ToolError("Agent tool requires runtime context."); + } + + if (runtime.role !== "root") { + return ToolError("Subagents cannot launch other subagents."); + } + + if (params.model != null && params.model !== "") { + // Validate model alias exists in config + if ( + runtime.config.models && + !(params.model in runtime.config.models) + ) { + return ToolError(`Unknown model alias: ${params.model}`); + } + } + + if (params.run_in_background) { + return this._runInBackground(params, runtime); + } + + return this._runForeground(params, runtime); + } + + private async _runForeground( + params: Params, + runtime: Runtime, + ): Promise { + const timeout = params.timeout ?? undefined; + try { + const runner = new ForegroundSubagentRunner(runtime); + const req: ForegroundRunRequest = { + description: params.description, + prompt: params.prompt, + requestedType: params.subagent_type || "coder", + model: params.model ?? undefined, + resume: params.resume ?? undefined, + }; + + if (timeout != null) { + // Run with timeout + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout * 1000); + try { + return await runner.run(req); + } finally { + clearTimeout(timer); + } + } + + return await runner.run(req); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + logger.warn(`Foreground agent timed out after ${timeout}s`); + return ToolError(`Agent timed out after ${timeout}s.`); + } + logger.error(`Foreground agent run failed: ${err}`); + return ToolError(`Failed to run agent: ${err}`); + } + } + + private async _runInBackground( + params: Params, + _runtime: Runtime, + ): Promise { + // Background agent execution requires the background task system + // which will be implemented as part of the background runner migration. + return ToolError( + "Background subagent execution is not yet implemented in this version.", + ); + } +} diff --git a/src/kimi_cli_ts/tools/ask_user/ask_user.ts b/src/kimi_cli_ts/tools/ask_user/ask_user.ts new file mode 100644 index 000000000..eff2ec079 --- /dev/null +++ b/src/kimi_cli_ts/tools/ask_user/ask_user.ts @@ -0,0 +1,99 @@ +/** + * AskUserQuestion tool — ask the user structured questions. + * Corresponds to Python tools/ask_user/__init__.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolOk } from "../types.ts"; + +const DESCRIPTION = `Use this tool when you need to ask the user questions with structured options during execution. This allows you to: +1. Collect user preferences or requirements before proceeding +2. Resolve ambiguous or underspecified instructions +3. Let the user decide between implementation approaches as you work +4. Present concrete options when multiple valid directions exist + +**When NOT to use:** +- When you can infer the answer from context — be decisive and proceed +- Trivial decisions that don't materially affect the outcome + +**Usage notes:** +- Users always have an "Other" option for custom input +- Use multi_select to allow multiple answers +- Keep option labels concise (1-5 words) +- Each question should have 2-4 meaningful, distinct options`; + +const QuestionOptionSchema = z.object({ + label: z + .string() + .describe( + "Concise display text (1-5 words). If recommended, append '(Recommended)'.", + ), + description: z + .string() + .default("") + .describe("Brief explanation of trade-offs or implications."), +}); + +const QuestionSchema = z.object({ + question: z + .string() + .describe("A specific, actionable question. End with '?'."), + header: z + .string() + .default("") + .describe("Short category tag (max 12 chars, e.g. 'Auth', 'Style')."), + options: z + .array(QuestionOptionSchema) + .min(2) + .max(4) + .describe("2-4 meaningful, distinct options."), + multi_select: z + .boolean() + .default(false) + .describe("Whether the user can select multiple options."), +}); + +const ParamsSchema = z.object({ + questions: z + .array(QuestionSchema) + .min(1) + .max(4) + .describe("The questions to ask the user (1-4 questions)."), +}); + +type Params = z.infer; + +export class AskUserQuestion extends CallableTool { + readonly name = "AskUserQuestion"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + const answers: Record = {}; + + for (const q of params.questions) { + const optionLabels = q.options.map((o) => o.label); + + if (ctx.askUser) { + // Wire-connected: actually ask the user + try { + const answer = await ctx.askUser(q.question, optionLabels); + answers[q.question] = answer; + } catch { + // User didn't respond or error — use first option as default + answers[q.question] = optionLabels[0] ?? "No answer"; + } + } else { + // Not connected (print mode, yolo mode, etc.) — auto-select first option + answers[q.question] = optionLabels[0] ?? "No answer"; + } + } + + return ToolOk( + JSON.stringify({ answers }, null, 2), + "User responses collected.", + ); + } +} diff --git a/src/kimi_cli_ts/tools/background/background.ts b/src/kimi_cli_ts/tools/background/background.ts new file mode 100644 index 000000000..04a1832fb --- /dev/null +++ b/src/kimi_cli_ts/tools/background/background.ts @@ -0,0 +1,170 @@ +/** + * Background task tools — TaskList, TaskOutput, TaskStop. + * Corresponds to Python tools/background/__init__.py + * Uses BackgroundTaskManager for real task management. + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import { listTaskViews, formatTaskList } from "../../background/summary.ts"; +import type { BackgroundTaskManager } from "../../background/manager.ts"; +import { isTerminalStatus } from "../../background/models.ts"; + +// Shared manager reference — bound at session startup +let _manager: BackgroundTaskManager | undefined; + +export function bindBackgroundManager(manager: BackgroundTaskManager): void { + _manager = manager; +} + +export function getBackgroundManager(): BackgroundTaskManager | undefined { + return _manager; +} + +// ── TaskList ──────────────────────────────────────────── + +const TaskListParamsSchema = z.object({ + active_only: z + .boolean() + .default(true) + .describe("Whether to list only non-terminal background tasks."), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum number of tasks to return."), +}); + +export class TaskList extends CallableTool { + readonly name = "TaskList"; + readonly description = + "List background tasks. Returns task IDs, statuses, and descriptions."; + readonly schema = TaskListParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + if (!_manager) { + return ToolOk("No background tasks.", "Task list retrieved."); + } + _manager.reconcile(); + const views = listTaskViews(_manager, { + activeOnly: params.active_only, + limit: params.limit, + }); + const text = formatTaskList(views, { + activeOnly: params.active_only, + includeCommand: true, + }); + return ToolOk(text, "Task list retrieved."); + } +} + +// ── TaskOutput ────────────────────────────────────────── + +const TaskOutputParamsSchema = z.object({ + task_id: z.string().describe("The background task ID to inspect."), + block: z + .boolean() + .default(false) + .describe("Whether to wait for the task to finish before returning."), + timeout: z + .number() + .int() + .min(0) + .max(3600) + .default(30) + .describe("Maximum number of seconds to wait when block=true."), +}); + +export class TaskOutput extends CallableTool { + readonly name = "TaskOutput"; + readonly description = + "Retrieve output from a background task by its ID."; + readonly schema = TaskOutputParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + if (!_manager) { + return ToolError(`Task not found: ${params.task_id}`); + } + + _manager.reconcile(); + let view = _manager.getTask(params.task_id); + if (!view) { + return ToolError(`Task not found: ${params.task_id}`); + } + + // Block if requested and task is still running + if (params.block && !isTerminalStatus(view.runtime.status)) { + view = await _manager.wait(params.task_id, params.timeout); + } + + const chunk = _manager.readOutput(params.task_id); + const lines = [ + `task_id: ${view.spec.id}`, + `status: ${view.runtime.status}`, + `kind: ${view.spec.kind}`, + ]; + if (view.runtime.exitCode != null) { + lines.push(`exit_code: ${view.runtime.exitCode}`); + } + if (view.runtime.failureReason) { + lines.push(`reason: ${view.runtime.failureReason}`); + } + if (chunk.text) { + lines.push("", "[output]", chunk.text); + } + if (!chunk.eof) { + lines.push(`[truncated at offset ${chunk.nextOffset}]`); + } + return ToolOk(lines.join("\n"), `Output for task ${params.task_id}.`); + } +} + +// ── TaskStop ──────────────────────────────────────────── + +const TaskStopParamsSchema = z.object({ + task_id: z.string().describe("The background task ID to stop."), + reason: z + .string() + .default("Stopped by TaskStop") + .describe("Short reason recorded when the task is stopped."), +}); + +export class TaskStop extends CallableTool { + readonly name = "TaskStop"; + readonly description = "Stop a running background task by its ID."; + readonly schema = TaskStopParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + if (!_manager) { + return ToolError(`Task not found: ${params.task_id}`); + } + + const existing = _manager.getTask(params.task_id); + if (!existing) { + return ToolError(`Task not found: ${params.task_id}`); + } + + if (isTerminalStatus(existing.runtime.status)) { + return ToolOk(`Task ${params.task_id} already in terminal state: ${existing.runtime.status}`); + } + + const view = _manager.kill(params.task_id, params.reason); + return ToolOk( + `Task ${params.task_id} stop requested. Current status: ${view.runtime.status}`, + `Task ${params.task_id} stopped.`, + ); + } +} diff --git a/src/kimi_cli_ts/tools/base.ts b/src/kimi_cli_ts/tools/base.ts new file mode 100644 index 000000000..3ad72c4f1 --- /dev/null +++ b/src/kimi_cli_ts/tools/base.ts @@ -0,0 +1,29 @@ +/** + * Abstract base class for all tools. + * Corresponds to Python's CallableTool2. + */ + +import { z } from "zod/v4"; +import type { ToolContext, ToolDefinition, ToolResult } from "./types.ts"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export abstract class CallableTool = z.ZodType> { + abstract readonly name: string; + abstract readonly description: string; + abstract readonly schema: TParams; + + /** Execute the tool with validated parameters. */ + abstract execute( + params: z.infer, + ctx: ToolContext, + ): Promise; + + /** Convert this tool into a ToolDefinition for LLM function calling. */ + toDefinition(): ToolDefinition { + return { + name: this.name, + description: this.description, + parameters: z.toJSONSchema(this.schema) as Record, + }; + } +} diff --git a/src/kimi_cli_ts/tools/display.ts b/src/kimi_cli_ts/tools/display.ts new file mode 100644 index 000000000..335c794b8 --- /dev/null +++ b/src/kimi_cli_ts/tools/display.ts @@ -0,0 +1,44 @@ +/** + * Display block types for UI rendering. + * Corresponds to Python tools/display.py + */ + +export interface DiffDisplayBlock { + type: "diff"; + path: string; + oldText: string; + newText: string; + oldStart?: number; + newStart?: number; + isSummary?: boolean; +} + +export interface TodoDisplayItem { + title: string; + status: "pending" | "in_progress" | "done"; +} + +export interface TodoDisplayBlock { + type: "todo"; + items: TodoDisplayItem[]; +} + +export interface ShellDisplayBlock { + type: "shell"; + language: string; + command: string; +} + +export interface BackgroundTaskDisplayBlock { + type: "background_task"; + taskId: string; + kind: string; + status: string; + description: string; +} + +export type DisplayBlock = + | DiffDisplayBlock + | TodoDisplayBlock + | ShellDisplayBlock + | BackgroundTaskDisplayBlock; diff --git a/src/kimi_cli_ts/tools/dmail/dmail.ts b/src/kimi_cli_ts/tools/dmail/dmail.ts new file mode 100644 index 000000000..1c42ce1a7 --- /dev/null +++ b/src/kimi_cli_ts/tools/dmail/dmail.ts @@ -0,0 +1,43 @@ +/** + * SendDMail tool — send a D-Mail to revert context to a checkpoint. + * Corresponds to Python tools/dmail/__init__.py + * Stub: full implementation requires denwa_renji integration. + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolOk } from "../types.ts"; + +const DESCRIPTION = `Send a message to the past, just like sending a D-Mail in Steins;Gate. + +This tool is provided to enable you to proactively manage the context. You can see some \`user\` messages with text \`CHECKPOINT {checkpoint_id}\` wrapped in \`\` tags in the context. When you feel there is too much irrelevant information in the current context, you can send a D-Mail to revert the context to a previous checkpoint with a message containing only the useful information. + +After a D-Mail is sent, the system will revert the current context to the specified checkpoint. You must make it very clear in the message what you have done/changed, what you have learned, so that your past self can continue the task without confusion. + +When sending a D-Mail, DO NOT explain to the user. Just explain to your past self.`; + +const ParamsSchema = z.object({ + checkpoint_id: z.string().describe("The checkpoint ID to revert to."), + message: z + .string() + .describe( + "The message to send to your past self with useful information.", + ), +}); + +type Params = z.infer; + +export class SendDMail extends CallableTool { + readonly name = "SendDMail"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, _ctx: ToolContext): Promise { + // Stub: full implementation requires denwa_renji + return ToolOk( + "", + "If you see this message, the D-Mail was NOT sent successfully.", + ); + } +} diff --git a/src/kimi_cli_ts/tools/file/glob.ts b/src/kimi_cli_ts/tools/file/glob.ts new file mode 100644 index 000000000..c01905747 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/glob.ts @@ -0,0 +1,97 @@ +/** + * Glob tool — find files and directories using glob patterns. + * Corresponds to Python tools/file/glob.py + */ + +import { z } from "zod/v4"; +import { globby } from "globby"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; + +const MAX_MATCHES = 1000; + +const DESCRIPTION = `Find files and directories using glob patterns. This tool supports standard glob syntax like \`*\`, \`?\`, and \`**\` for recursive searches. + +**When to use:** +- Find files matching specific patterns (e.g., all Python files: \`*.py\`) +- Search for files recursively in subdirectories (e.g., \`src/**/*.js\`) +- Locate configuration files (e.g., \`*.config.*\`, \`*.json\`) + +**Bad example patterns:** +- \`**\`, \`**/*.py\` - Any pattern starting with '**' will be rejected. +- \`node_modules/**/*.js\` - Avoid recursively searching in large directories.`; + +const ParamsSchema = z.object({ + pattern: z.string().describe("Glob pattern to match files/directories."), + directory: z + .string() + .nullish() + .describe( + "Absolute path to the directory to search in (defaults to working directory).", + ), + include_dirs: z + .boolean() + .default(true) + .describe("Whether to include directories in results."), +}); + +type Params = z.infer; + +export class Glob extends CallableTool { + readonly name = "Glob"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + try { + // Validate pattern safety + if (params.pattern.startsWith("**")) { + return ToolError( + `Pattern \`${params.pattern}\` starts with '**' which is not allowed. ` + + "This would recursively search all directories. Use more specific patterns instead.", + ); + } + + const dirPath = params.directory || ctx.workingDir; + + if (!dirPath.startsWith("/")) { + return ToolError( + `\`${params.directory}\` is not an absolute path. You must provide an absolute path to search.`, + ); + } + + // Perform the glob search + let matches = await globby(params.pattern, { + cwd: dirPath, + dot: true, + onlyFiles: !params.include_dirs, + ignore: [ + ".git", + ".svn", + ".hg", + "node_modules/**", + ], + }); + + // Sort for consistent output + matches.sort(); + + let message = + matches.length > 0 + ? `Found ${matches.length} matches for pattern \`${params.pattern}\`.` + : `No matches found for pattern \`${params.pattern}\`.`; + + if (matches.length > MAX_MATCHES) { + matches = matches.slice(0, MAX_MATCHES); + message += ` Only the first ${MAX_MATCHES} matches are returned. You may want to use a more specific pattern.`; + } + + return ToolOk(matches.join("\n"), message); + } catch (e) { + return ToolError( + `Failed to search for pattern ${params.pattern}. Error: ${e}`, + ); + } + } +} diff --git a/src/kimi_cli_ts/tools/file/grep.ts b/src/kimi_cli_ts/tools/file/grep.ts new file mode 100644 index 000000000..b62a95400 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/grep.ts @@ -0,0 +1,400 @@ +/** + * Grep tool — regex search using ripgrep. + * Corresponds to Python tools/file/grep_local.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolResultBuilder } from "../types.ts"; +import { isSensitiveFile, sensitiveFileWarning } from "../../utils/sensitive.ts"; + +const RG_TIMEOUT = 20_000; // 20 seconds in ms +const RG_MAX_BUFFER = 20_000_000; // 20MB +const RG_KILL_GRACE = 5_000; // 5 seconds: SIGTERM → SIGKILL + +const DESCRIPTION = `A powerful search tool based on ripgrep. + +**Tips:** +- ALWAYS use Grep tool instead of running \`grep\` or \`rg\` command with Shell tool. +- Use the ripgrep pattern syntax, not grep syntax. E.g. you need to escape braces like \`\\{\` to search for \`{\`. +- Hidden files (dotfiles like \`.gitlab-ci.yml\`, \`.eslintrc.json\`) are always searched. To also search files excluded by \`.gitignore\` (e.g. \`node_modules\`, build outputs), set \`include_ignored\` to \`true\`. Sensitive files (such as \`.env\`) are still skipped for safety, even when \`include_ignored\` is \`true\`.`; + +const ParamsSchema = z.object({ + pattern: z + .string() + .describe( + "The regular expression pattern to search for in file contents", + ), + path: z + .string() + .default(".") + .describe( + "File or directory to search in. Defaults to current working directory.", + ), + glob: z + .string() + .nullish() + .describe("Glob pattern to filter files (e.g. `*.js`, `*.{ts,tsx}`)."), + output_mode: z + .string() + .default("files_with_matches") + .describe( + "`content`: Show matching lines; `files_with_matches`: Show file paths; `count_matches`: Show total number of matches.", + ), + "-B": z + .number() + .int() + .nullish() + .describe("Number of lines to show before each match."), + "-A": z + .number() + .int() + .nullish() + .describe("Number of lines to show after each match."), + "-C": z + .number() + .int() + .nullish() + .describe("Number of lines to show before and after each match."), + "-n": z.boolean().default(true).describe("Show line numbers in output."), + "-i": z.boolean().default(false).describe("Case insensitive search."), + type: z + .string() + .nullish() + .describe("File type to search (e.g. py, js, ts, go, java)."), + head_limit: z + .number() + .int() + .min(0) + .default(250) + .describe("Limit output to first N lines/entries. 0 for unlimited."), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe("Skip first N lines/entries before applying head_limit."), + multiline: z + .boolean() + .default(false) + .describe("Enable multiline mode where `.` matches newlines."), + include_ignored: z + .boolean() + .default(false) + .describe( + "Include files that are ignored by `.gitignore`, `.ignore`, and other ignore " + + "rules. Useful for searching gitignored artifacts such as build outputs " + + "(e.g. `dist/`, `build/`) or `node_modules`. Sensitive files (like `.env`) " + + "remain filtered by the sensitive-file protection layer. Defaults to false.", + ), +}); + +type Params = z.infer; + +function buildRgArgs( + params: Params, + searchPath: string, + opts?: { singleThreaded?: boolean }, +): string[] { + const args: string[] = ["rg"]; + + // Fixed args + if (params.output_mode !== "content") { + args.push("--max-columns", "500"); + } + args.push("--hidden"); + if (params.include_ignored) { + args.push("--no-ignore"); + } + for (const vcsDir of [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"]) { + args.push("--glob", `!${vcsDir}`); + } + + if (opts?.singleThreaded) { + args.push("-j", "1"); + } + + // Search options + if (params["-i"]) args.push("--ignore-case"); + if (params.multiline) args.push("--multiline", "--multiline-dotall"); + + // Content display options + if (params.output_mode === "content") { + if (params["-B"] != null) args.push("--before-context", String(params["-B"])); + if (params["-A"] != null) args.push("--after-context", String(params["-A"])); + if (params["-C"] != null) args.push("--context", String(params["-C"])); + if (params["-n"]) args.push("--line-number"); + } + + // File filtering + if (params.glob) args.push("--glob", params.glob); + if (params.type) args.push("--type", params.type); + + // Output mode + if (params.output_mode === "files_with_matches") { + args.push("--files-with-matches"); + } else if (params.output_mode === "count_matches") { + args.push("--count-matches"); + } + + // Pattern and path + args.push("--", params.pattern, searchPath); + + return args; +} + +function stripPathPrefix(output: string, searchBase: string): string { + const prefix = searchBase.replace(/[/\\]$/, "") + "/"; + return output + .split("\n") + .map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line)) + .join("\n"); +} + +function isEagain(stderr: string): boolean { + return stderr.includes("os error 11") || stderr.includes("Resource temporarily unavailable"); +} + +/** Two-phase kill: SIGTERM → grace period → SIGKILL. */ +async function killProcess(proc: ReturnType): Promise { + proc.kill(); // SIGTERM + try { + await Promise.race([ + proc.exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("kill_grace_timeout")), RG_KILL_GRACE), + ), + ]); + } catch { + // Grace period expired, send SIGKILL + proc.kill(9); + await proc.exited; + } +} + +export class Grep extends CallableTool { + readonly name = "Grep"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute( + params: Params, + ctx: ToolContext, + opts?: { _retry?: boolean }, + ): Promise { + try { + const builder = new ToolResultBuilder(); + let message = ""; + + // Resolve the search path + let searchPath = params.path; + if (!searchPath.startsWith("/")) { + searchPath = `${ctx.workingDir}/${searchPath}`; + } + searchPath = searchPath.replace(/^~/, process.env.HOME || ""); + + const args = buildRgArgs(params, searchPath, { + singleThreaded: opts?._retry, + }); + + // Execute ripgrep using Bun.spawn + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + }); + + let timedOut = false; + let output: string; + let stderrStr: string; + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), RG_TIMEOUT); + }); + + const resultPromise = (async () => { + const stdoutBytes = await new Response(proc.stdout).arrayBuffer(); + const stderrBytes = await new Response(proc.stderr).arrayBuffer(); + return { + stdout: new TextDecoder().decode(stdoutBytes), + stderr: new TextDecoder().decode(stderrBytes), + }; + })(); + + const result = await Promise.race([resultPromise, timeoutPromise]); + output = result.stdout; + stderrStr = result.stderr; + await proc.exited; + } catch (e) { + if (e instanceof Error && e.message === "timeout") { + await killProcess(proc); + timedOut = true; + output = ""; + stderrStr = ""; + } else { + throw e; + } + } + + // Buffer truncation + let bufferTruncated = false; + if (output.length > RG_MAX_BUFFER) { + output = output.slice(0, RG_MAX_BUFFER); + const lastNl = output.lastIndexOf("\n"); + output = lastNl >= 0 ? output.slice(0, lastNl) : ""; + bufferTruncated = true; + message = "Output exceeded buffer limit. Some results omitted."; + } + + // Timeout handling + if (timedOut) { + if (!output.trim()) { + return ToolError( + `Grep timed out after ${RG_TIMEOUT / 1000}s. Try a more specific path or pattern.`, + ); + } + const timeoutMsg = `Grep timed out after ${RG_TIMEOUT / 1000}s. Partial results returned.`; + message = message ? `${message} ${timeoutMsg}` : timeoutMsg; + } + + // rg exit codes: 0=matches found, 1=no matches, 2+=error + if (!timedOut && proc.exitCode !== 0 && proc.exitCode !== 1) { + // EAGAIN: retry once with single-threaded mode + if (!opts?._retry && isEagain(stderrStr)) { + return this.execute(params, ctx, { _retry: true }); + } + return ToolError(`Failed to grep. Error: ${stderrStr}`); + } + + // Post-processing: strip path prefix + let searchBase = searchPath; + try { + const { stat } = await import("node:fs/promises"); + const info = await stat(searchBase); + if (info.isFile()) { + searchBase = searchBase.replace(/\/[^/]+$/, ""); + } + } catch { + // path doesn't exist or inaccessible, use as-is + } + output = stripPathPrefix(output, searchBase); + + // Filter sensitive files from output + const _RG_LINE_RE = /^(.*?)([:\-])(\d+)\2/; + const outLines = output.split("\n"); + const filteredPaths: string[] = []; + const keptLines: string[] = []; + const sensitivePathSet = new Set(); + + for (const line of outLines) { + let filePath: string; + if (params.output_mode === "content") { + if (line === "--") { + keptLines.push(line); + continue; + } + const m = _RG_LINE_RE.exec(line); + filePath = m ? m[1] : line; + } else if (params.output_mode === "count_matches") { + const idx = line.lastIndexOf(":"); + filePath = idx > 0 ? line.slice(0, idx) : line; + } else { + filePath = line; + } + + if (filePath && isSensitiveFile(filePath)) { + if (!sensitivePathSet.has(filePath)) { + sensitivePathSet.add(filePath); + filteredPaths.push(filePath); + } + } else { + keptLines.push(line); + } + } + + if (filteredPaths.length > 0) { + // Remove trailing "--" separators left after filtering + while (keptLines.length > 0 && keptLines[keptLines.length - 1] === "--") { + keptLines.pop(); + } + output = keptLines.join("\n"); + const warning = sensitiveFileWarning(filteredPaths); + message = message ? `${message} ${warning}` : warning; + } + + // Split into lines + let lines = output.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines = lines.slice(0, -1); + } + + // Sort files_with_matches by mtime (most recently modified first) + if (!timedOut && params.output_mode === "files_with_matches" && lines.length > 0) { + const { stat: fsStat } = await import("node:fs/promises"); + const withMtime = await Promise.all( + lines.map(async (filePath) => { + try { + const fullPath = filePath.startsWith("/") ? filePath : `${searchBase}/${filePath}`; + const info = await fsStat(fullPath); + return { filePath, mtime: info.mtimeMs }; + } catch { + return { filePath, mtime: 0 }; + } + }), + ); + withMtime.sort((a, b) => b.mtime - a.mtime); + lines = withMtime.map((x) => x.filePath); + } + + // count_matches summary + if (params.output_mode === "count_matches") { + let totalMatches = 0; + let totalFiles = 0; + for (const line of lines) { + const idx = line.lastIndexOf(":"); + if (idx > 0) { + const count = parseInt(line.slice(idx + 1), 10); + if (!isNaN(count)) { + totalMatches += count; + totalFiles += 1; + } + } + } + const countSummary = `Found ${totalMatches} total occurrences across ${totalFiles} files.`; + message = message ? `${message} ${countSummary}` : countSummary; + } + + // Offset + head_limit pagination + if (params.offset > 0) { + lines = lines.slice(params.offset); + } + + const effectiveLimit = params.head_limit; + if (effectiveLimit && lines.length > effectiveLimit) { + const total = lines.length + params.offset; + lines = lines.slice(0, effectiveLimit); + output = lines.join("\n"); + const truncationMsg = + `Results truncated to ${effectiveLimit} lines (total: ${total}). ` + + `Use offset=${params.offset + effectiveLimit} to see more.`; + message = message ? `${message} ${truncationMsg}` : truncationMsg; + } else { + output = lines.join("\n"); + } + + if (!output && !bufferTruncated) { + let noMatchMsg = "No matches found"; + if (message) { + noMatchMsg = `${noMatchMsg}. ${message}`; + } + return builder.ok(noMatchMsg); + } + + builder.write(output); + return builder.ok(message); + } catch (e) { + return ToolError(`Failed to grep. Error: ${String(e)}`); + } + } +} diff --git a/src/kimi_cli_ts/tools/file/plan_mode.ts b/src/kimi_cli_ts/tools/file/plan_mode.ts new file mode 100644 index 000000000..9d1e4d595 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/plan_mode.ts @@ -0,0 +1,50 @@ +/** + * Plan mode edit target validation. + * Corresponds to Python tools/file/plan_mode.py + */ + +import { resolve } from "node:path"; +import type { ToolResult } from "../types.ts"; +import { ToolError } from "../types.ts"; + +export interface PlanEditTarget { + active: boolean; + planPath: string | null; + isPlanTarget: boolean; +} + +/** + * Resolve whether a file edit is targeting the current plan artifact. + * Returns a PlanEditTarget on success, or a ToolResult error on failure. + */ +export function inspectPlanEditTarget( + filePath: string, + opts: { + planModeChecker?: () => boolean; + planFilePathGetter?: () => string | null; + }, +): PlanEditTarget | ToolResult { + const { planModeChecker, planFilePathGetter } = opts; + + if (!planModeChecker || !planModeChecker()) { + return { active: false, planPath: null, isPlanTarget: false }; + } + + const planPath = planFilePathGetter?.() ?? null; + if (planPath === null) { + return ToolError( + "Plan mode is active, but the current plan file is unavailable.", + ); + } + + const canonicalPlanPath = resolve(planPath); + const canonicalFilePath = resolve(filePath); + + if (canonicalFilePath !== canonicalPlanPath) { + return ToolError( + `Plan mode is active. You may only edit the current plan file: \`${canonicalPlanPath}\`.`, + ); + } + + return { active: true, planPath, isPlanTarget: true }; +} diff --git a/src/kimi_cli_ts/tools/file/read.ts b/src/kimi_cli_ts/tools/file/read.ts new file mode 100644 index 000000000..5888c1901 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/read.ts @@ -0,0 +1,367 @@ +/** + * ReadFile tool — read text content from a file. + * Corresponds to Python tools/file/read.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import { isSensitiveFile } from "../../utils/sensitive.ts"; + +const MAX_LINES = 1000; +const MAX_LINE_LENGTH = 2000; +const MAX_BYTES = 100 * 1024; // 100KB + +const DESCRIPTION = `Read text content from a file. + +**Tips:** +- A \`\` tag will be given before the read file content. +- This tool can only read text files. +- Content will be returned with a line number before each line like \`cat -n\` format. +- Use \`line_offset\` and \`n_lines\` parameters when you only need to read a part of the file. +- Use negative \`line_offset\` to read from the end of the file (e.g. \`line_offset=-100\` reads the last 100 lines). This is useful for viewing the tail of log files. The absolute value cannot exceed ${MAX_LINES}. +- The tool always returns the total number of lines in the file in its message, which you can use to plan subsequent reads. +- The maximum number of lines that can be read at once is ${MAX_LINES}. +- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated, ending with "...".`; + +const ParamsSchema = z.object({ + path: z.string().describe( + "The path to the file to read. Absolute paths are required when reading files outside the working directory.", + ), + line_offset: z + .number() + .int() + .default(1) + .describe( + "The line number to start reading from. By default read from the beginning of the file. " + + "Set this when the file is too large to read at once. " + + "Negative values read from the end of the file (e.g. -100 reads the last 100 lines). " + + `The absolute value of negative offset cannot exceed ${MAX_LINES}.`, + ), + n_lines: z + .number() + .int() + .min(1) + .default(MAX_LINES) + .describe( + `The number of lines to read. Defaults to ${MAX_LINES} (max allowed).`, + ), +}); + +type Params = z.infer; + +function truncateLine(line: string, maxLength: number): string { + if (line.length <= maxLength) return line; + return line.slice(0, maxLength - 3) + "..."; +} + +function resolvePath(filePath: string, workingDir: string): string { + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return `${workingDir}/${filePath}`; +} + +// ── Binary file detection ───────────────────────────── +// Magic byte signatures for common binary formats + +const BINARY_SIGNATURES: Array<{ bytes: number[]; type: string }> = [ + { bytes: [0x89, 0x50, 0x4e, 0x47], type: "PNG image" }, + { bytes: [0xff, 0xd8, 0xff], type: "JPEG image" }, + { bytes: [0x47, 0x49, 0x46, 0x38], type: "GIF image" }, + { bytes: [0x52, 0x49, 0x46, 0x46], type: "RIFF (WebP/AVI)" }, + { bytes: [0x50, 0x4b, 0x03, 0x04], type: "ZIP archive" }, + { bytes: [0x1f, 0x8b], type: "gzip archive" }, + { bytes: [0x25, 0x50, 0x44, 0x46], type: "PDF document" }, + { bytes: [0x7f, 0x45, 0x4c, 0x46], type: "ELF binary" }, + { bytes: [0xfe, 0xed, 0xfa, 0xce], type: "Mach-O binary" }, + { bytes: [0xfe, 0xed, 0xfa, 0xcf], type: "Mach-O binary (64-bit)" }, + { bytes: [0xce, 0xfa, 0xed, 0xfe], type: "Mach-O binary (reverse)" }, + { bytes: [0xcf, 0xfa, 0xed, 0xfe], type: "Mach-O binary (64-bit reverse)" }, + { bytes: [0xca, 0xfe, 0xba, 0xbe], type: "Mach-O universal binary" }, + { bytes: [0x4d, 0x5a], type: "Windows executable" }, +]; + +const NON_TEXT_EXTENSIONS = new Set([ + // Images + "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", "tiff", "tif", "avif", "heic", "heif", + // Video + "mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "3gp", + // Audio + "mp3", "wav", "ogg", "flac", "aac", "wma", "m4a", "opus", + // Archives + "zip", "tar", "gz", "bz2", "xz", "7z", "rar", "zst", + // Binaries + "exe", "dll", "so", "dylib", "o", "a", "lib", "bin", "dat", + // Documents (binary) + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + // Database + "db", "sqlite", "sqlite3", + // Fonts + "ttf", "otf", "woff", "woff2", "eot", + // Other + "pyc", "pyo", "class", "jar", "war", "deb", "rpm", "dmg", "iso", "img", +]); + +function detectBinaryType(resolvedPath: string, headerBytes: Uint8Array): string | null { + // Check magic bytes + for (const sig of BINARY_SIGNATURES) { + if (sig.bytes.every((b, i) => headerBytes[i] === b)) { + return sig.type; + } + } + + // Check for NUL bytes in first 8KB (strong binary indicator) + for (let i = 0; i < Math.min(headerBytes.length, 8192); i++) { + if (headerBytes[i] === 0x00) { + return "binary file (contains NUL bytes)"; + } + } + + // Check extension + const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? ""; + if (NON_TEXT_EXTENSIONS.has(ext)) { + return `binary file (.${ext})`; + } + + return null; +} + +export class ReadFile extends CallableTool { + readonly name = "ReadFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + // Validate line_offset + if (params.line_offset === 0) { + return ToolError("line_offset cannot be 0; use 1 for the first line or -1 for the last line."); + } + if (params.line_offset < -MAX_LINES) { + return ToolError( + `line_offset cannot be less than -${MAX_LINES}. ` + + "Use a positive line_offset with the total line count to read from a specific position.", + ); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + + if (isSensitiveFile(resolvedPath)) { + return ToolError( + `\`${params.path}\` appears to contain secrets ` + + "(matched sensitive file pattern). " + + "Reading this file is blocked to protect credentials.", + ); + } + + const file = Bun.file(resolvedPath); + + if (!(await file.exists())) { + return ToolError(`\`${params.path}\` does not exist.`); + } + + // Check if it's a directory + const { stat } = await import("node:fs/promises"); + try { + const info = await stat(resolvedPath); + if (info.isDirectory()) { + return ToolError(`\`${params.path}\` is a directory, not a file. Use the Glob tool to list directory contents.`); + } + } catch { + // stat failed — continue, file.text() will catch it + } + + // Binary detection: read header bytes first + const headerSize = Math.min(file.size, 8192); + if (headerSize > 0) { + const headerBuf = await file.slice(0, headerSize).arrayBuffer(); + const headerBytes = new Uint8Array(headerBuf); + const binaryType = detectBinaryType(resolvedPath, headerBytes); + if (binaryType) { + return ToolError( + `\`${params.path}\` is a ${binaryType}. This tool can only read text files. ` + + `Use the Shell tool if you need to inspect binary files (e.g. \`file\`, \`hexdump\`).` + ); + } + } + + // Read file content + const text = await file.text(); + const allLines = text.split("\n"); + // Handle trailing newline: if file ends with \n, last element is empty string — not a real line + const totalLines = text.endsWith("\n") ? allLines.length - 1 : allLines.length; + // Special case: empty file + if (text === "") { + return ToolOk( + "", + `No lines read from file. Total lines in file: 0. End of file reached.`, + ); + } + + if (params.line_offset < 0) { + return this._readTail(allLines, totalLines, text, params); + } else { + return this._readForward(allLines, totalLines, text, params); + } + } catch (e) { + return ToolError(`Failed to read ${params.path}. Error: ${e}`); + } + } + + private _readForward(allLines: string[], totalLines: number, text: string, params: Params): ToolResult { + const lineOffset = params.line_offset; + const nLines = params.n_lines; + + const lines: string[] = []; + const truncatedLineNumbers: number[] = []; + let nBytes = 0; + let maxLinesReached = false; + let maxBytesReached = false; + + for ( + let i = lineOffset - 1; + i < totalLines && lines.length < nLines; + i++ + ) { + const lineNo = i + 1; + let line = allLines[i] ?? ""; + // Add newline back except for last line if original doesn't end with \n + if (i < allLines.length - 1 || text.endsWith("\n")) { + line += "\n"; + } + + const truncated = truncateLine(line, MAX_LINE_LENGTH); + if (truncated !== line) { + truncatedLineNumbers.push(lineNo); + } + lines.push(truncated); + nBytes += new TextEncoder().encode(truncated).length; + + if (lines.length >= MAX_LINES) { + maxLinesReached = true; + break; + } + if (nBytes >= MAX_BYTES) { + maxBytesReached = true; + break; + } + } + + // Format output with line numbers (cat -n format) + const linesWithNo = lines.map((line: string, idx: number) => { + const lineNum = lineOffset + idx; + return `${String(lineNum).padStart(6)}\t${line}`; + }); + + let message = + lines.length > 0 + ? `${lines.length} lines read from file starting from line ${lineOffset}.` + : "No lines read from file."; + + message += ` Total lines in file: ${totalLines}.`; + + if (maxLinesReached) { + message += ` Max ${MAX_LINES} lines reached.`; + } else if (maxBytesReached) { + message += ` Max ${MAX_BYTES} bytes reached.`; + } else if (lines.length < nLines) { + message += " End of file reached."; + } + if (truncatedLineNumbers.length > 0) { + message += ` Lines [${truncatedLineNumbers.join(", ")}] were truncated.`; + } + + return ToolOk(linesWithNo.join(""), message); + } + + private _readTail(allLines: string[], totalLines: number, text: string, params: Params): ToolResult { + const tailCount = Math.abs(params.line_offset); + + // Get the last tailCount lines (using a slice of allLines, 0-indexed) + const startIdx = Math.max(0, totalLines - tailCount); + const tailEntries: Array<{ lineNo: number; line: string; wasTruncated: boolean }> = []; + + for (let i = startIdx; i < totalLines; i++) { + let line = allLines[i] ?? ""; + if (i < allLines.length - 1 || text.endsWith("\n")) { + line += "\n"; + } + const truncated = truncateLine(line, MAX_LINE_LENGTH); + tailEntries.push({ + lineNo: i + 1, + line: truncated, + wasTruncated: truncated !== line, + }); + } + + // Apply n_lines / MAX_LINES limit from head of tail entries + const lineLimit = Math.min(params.n_lines, MAX_LINES); + let candidates = tailEntries.slice(0, lineLimit); + const maxLinesReached = tailEntries.length > MAX_LINES && candidates.length === MAX_LINES; + + // Apply MAX_BYTES — if candidates exceed byte budget, reverse-scan to keep newest lines + const totalCandidateBytes = candidates.reduce( + (sum, e) => sum + new TextEncoder().encode(e.line).length, + 0, + ); + let maxBytesReached = false; + if (totalCandidateBytes > MAX_BYTES) { + maxBytesReached = true; + let kept = 0; + let nBytes = 0; + for (let i = candidates.length - 1; i >= 0; i--) { + nBytes += new TextEncoder().encode(candidates[i].line).length; + if (nBytes > MAX_BYTES) break; + kept++; + } + candidates = candidates.slice(candidates.length - kept); + } + + // Collect results + const truncatedLineNumbers: number[] = []; + const lineNumbers: number[] = []; + const lines: string[] = []; + + for (const entry of candidates) { + if (entry.wasTruncated) truncatedLineNumbers.push(entry.lineNo); + lines.push(entry.line); + lineNumbers.push(entry.lineNo); + } + + // Format output with absolute line numbers + const linesWithNo = lines.map((line: string, idx: number) => { + return `${String(lineNumbers[idx]).padStart(6)}\t${line}`; + }); + + const startLine = lineNumbers.length > 0 ? lineNumbers[0] : totalLines + 1; + let message = + lines.length > 0 + ? `${lines.length} lines read from file starting from line ${startLine}.` + : "No lines read from file."; + + message += ` Total lines in file: ${totalLines}.`; + + if (maxLinesReached) { + message += ` Max ${MAX_LINES} lines reached.`; + } else if (maxBytesReached) { + message += ` Max ${MAX_BYTES} bytes reached.`; + } else if (lines.length < params.n_lines) { + message += " End of file reached."; + } + if (truncatedLineNumbers.length > 0) { + message += ` Lines [${truncatedLineNumbers.join(", ")}] were truncated.`; + } + + return ToolOk(linesWithNo.join(""), message); + } +} diff --git a/src/kimi_cli_ts/tools/file/read_media.ts b/src/kimi_cli_ts/tools/file/read_media.ts new file mode 100644 index 000000000..5f512185f --- /dev/null +++ b/src/kimi_cli_ts/tools/file/read_media.ts @@ -0,0 +1,115 @@ +/** + * ReadMediaFile tool — read images and videos. + * Corresponds to Python tools/file/read_media.py + */ + +import { resolve } from "node:path"; +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import { MEDIA_SNIFF_BYTES, detectFileType, type FileType } from "./utils.ts"; + +const MAX_MEDIA_MEGABYTES = 100; + +const DESCRIPTION = `Read an image or video file from disk. + +**Tips:** +- Use this tool to view images and videos directly. +- Maximum file size: ${MAX_MEDIA_MEGABYTES}MB. +- For text files, use ReadFile instead.`; + +const ParamsSchema = z.object({ + path: z.string().describe( + "The path to the file to read. Absolute paths are required when reading files outside the working directory.", + ), +}); + +type Params = z.infer; + +function toDataUrl(mimeType: string, data: Uint8Array): string { + const base64 = Buffer.from(data).toString("base64"); + return `data:${mimeType};base64,${base64}`; +} + +function resolvePath(filePath: string, workingDir: string): string { + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return resolve(workingDir, filePath); +} + +export class ReadMediaFile extends CallableTool { + readonly name = "ReadMediaFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + const file = Bun.file(resolvedPath); + + if (!(await file.exists())) { + return ToolError(`\`${params.path}\` does not exist.`); + } + + const { stat: fsStat } = await import("node:fs/promises"); + const info = await fsStat(resolvedPath); + if (!info.isFile()) { + return ToolError(`\`${params.path}\` is not a file.`); + } + + const size = info.size; + if (size === 0) { + return ToolError(`\`${params.path}\` is empty.`); + } + if (size > MAX_MEDIA_MEGABYTES * 1024 * 1024) { + return ToolError( + `\`${params.path}\` is ${size} bytes, which exceeds the max ${MAX_MEDIA_MEGABYTES}MB for media files.`, + ); + } + + // Read header for file type detection + const headerBuf = await file.slice(0, MEDIA_SNIFF_BYTES).arrayBuffer(); + const header = new Uint8Array(headerBuf); + const fileType = detectFileType(resolvedPath, header); + + if (fileType.kind === "text") { + return ToolError( + `\`${params.path}\` is a text file. Use ReadFile to read text files.`, + ); + } + if (fileType.kind === "unknown") { + return ToolError( + `\`${params.path}\` seems not readable as an image or video file. ` + + "You may need to read it with proper shell commands or other tools.", + ); + } + + // Read the full file + const data = new Uint8Array(await file.arrayBuffer()); + const dataUrl = toDataUrl(fileType.mimeType, data); + + const note = + " If you need to output coordinates, output relative coordinates first and " + + "compute absolute coordinates using the original image size; if you generate or " + + "edit images/videos via commands or scripts, read the result back immediately " + + "before continuing."; + + return ToolOk( + dataUrl, + `Loaded ${fileType.kind} file \`${params.path}\` (${fileType.mimeType}, ${size} bytes).${note}`, + ); + } catch (e) { + return ToolError(`Failed to read ${params.path}. Error: ${e}`); + } + } +} diff --git a/src/kimi_cli_ts/tools/file/replace.ts b/src/kimi_cli_ts/tools/file/replace.ts new file mode 100644 index 000000000..584c3b999 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/replace.ts @@ -0,0 +1,184 @@ +/** + * StrReplaceFile tool — edit/replace strings in a file. + * Corresponds to Python tools/file/replace.py + */ + +import { resolve } from "node:path"; +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError } from "../types.ts"; +import { inspectPlanEditTarget } from "./plan_mode.ts"; + +const DESCRIPTION = `Replace specific strings within a specified file. + +**Tips:** +- Only use this tool on text files. +- Multi-line strings are supported. +- Can specify a single edit or a list of edits in one call. +- You should prefer this tool over WriteFile tool and Shell \`sed\` command.`; + +const EditSchema = z.object({ + old: z.string().describe("The old string to replace. Can be multi-line."), + new: z.string().describe("The new string to replace with. Can be multi-line."), + replace_all: z + .boolean() + .default(false) + .describe("Whether to replace all occurrences."), +}); + +const ParamsSchema = z.object({ + path: z.string().describe( + "The path to the file to edit. Absolute paths are required when editing files outside the working directory.", + ), + edit: z + .union([EditSchema, z.array(EditSchema)]) + .describe("The edit(s) to apply to the file."), +}); + +type Params = z.infer; +type Edit = z.infer; + +function resolvePath(filePath: string, workingDir: string): string { + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return resolve(workingDir, filePath); +} + +function applyEdit(content: string, edit: Edit): string { + if (edit.replace_all) { + return content.split(edit.old).join(edit.new); + } + const idx = content.indexOf(edit.old); + if (idx === -1) return content; + return content.slice(0, idx) + edit.new + content.slice(idx + edit.old.length); +} + +export class StrReplaceFile extends CallableTool { + readonly name = "StrReplaceFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + /** Optional plan mode bindings. */ + private _planModeChecker?: () => boolean; + private _planFilePathGetter?: () => string | null; + + /** Bind plan mode state checker and plan file path getter. */ + bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { + this._planModeChecker = checker; + this._planFilePathGetter = pathGetter; + } + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + + // Check plan mode restrictions + const planTarget = inspectPlanEditTarget(resolvedPath, { + planModeChecker: this._planModeChecker ?? ctx.getPlanMode, + planFilePathGetter: this._planFilePathGetter, + }); + if ("isError" in planTarget && planTarget.isError) { + return planTarget; + } + const isPlanFileEdit = !("isError" in planTarget) && planTarget.isPlanTarget; + + const file = Bun.file(resolvedPath); + + if (!(await file.exists())) { + if (isPlanFileEdit) { + return ToolError( + "The current plan file does not exist yet. " + + "Use WriteFile to create it before calling StrReplaceFile.", + ); + } + return ToolError(`\`${params.path}\` does not exist.`); + } + + // Check if it's actually a file + const { stat: fsStat } = await import("node:fs/promises"); + try { + const info = await fsStat(resolvedPath); + if (!info.isFile()) { + return ToolError(`\`${params.path}\` is not a file.`); + } + } catch { + // stat failed — continue + } + + // Read the file content + const originalContent = await file.text(); + let content = originalContent; + + const edits: Edit[] = Array.isArray(params.edit) + ? params.edit + : [params.edit]; + + // Apply all edits + for (const edit of edits) { + content = applyEdit(content, edit); + } + + // Check if any changes were made + if (content === originalContent) { + return ToolError( + "No replacements were made. The old string was not found in the file.", + ); + } + + // Plan file edits are auto-approved; all other edits need approval + if (!isPlanFileEdit) { + // Build diff preview + const diffLines: string[] = []; + for (const edit of edits) { + if (edit.old.length < 200 && edit.new.length < 200) { + diffLines.push(`-${edit.old.split("\n").join("\n-")}`); + diffLines.push(`+${edit.new.split("\n").join("\n+")}`); + } + } + const diffPreview = diffLines.length > 0 ? `\n${diffLines.join("\n")}` : ""; + + const decision = await ctx.approval( + "StrReplaceFile", + "edit", + `Edit file \`${resolvedPath}\` (${edits.length} edit(s))${diffPreview}`, + ); + if (decision === "reject") { + return ToolError( + "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", + ); + } + } + + // Write the modified content back + await Bun.write(resolvedPath, content); + + // Count changes for success message + let totalReplacements = 0; + for (const edit of edits) { + if (edit.replace_all) { + totalReplacements += originalContent.split(edit.old).length - 1; + } else { + totalReplacements += originalContent.includes(edit.old) ? 1 : 0; + } + } + + return { + isError: false, + output: "", + message: `File successfully edited. Applied ${edits.length} edit(s) with ${totalReplacements} total replacement(s).`, + }; + } catch (e) { + return ToolError(`Failed to edit. Error: ${e}`); + } + } +} diff --git a/src/kimi_cli_ts/tools/file/utils.ts b/src/kimi_cli_ts/tools/file/utils.ts new file mode 100644 index 000000000..41cd83e1f --- /dev/null +++ b/src/kimi_cli_ts/tools/file/utils.ts @@ -0,0 +1,223 @@ +/** + * File type detection utilities. + * Corresponds to Python tools/file/utils.py + */ + +import { extname } from "node:path"; + +export const MEDIA_SNIFF_BYTES = 512; + +export interface FileType { + kind: "text" | "image" | "video" | "unknown"; + mimeType: string; +} + +const IMAGE_MIME_BY_SUFFIX: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif", + ".svgz": "image/svg+xml", +}; + +const VIDEO_MIME_BY_SUFFIX: Record = { + ".mp4": "video/mp4", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".mov": "video/quicktime", + ".wmv": "video/x-ms-wmv", + ".webm": "video/webm", + ".m4v": "video/x-m4v", + ".flv": "video/x-flv", + ".3gp": "video/3gpp", + ".3g2": "video/3gpp2", +}; + +const TEXT_MIME_BY_SUFFIX: Record = { + ".svg": "image/svg+xml", +}; + +const FTYP_IMAGE_BRANDS: Record = { + avif: "image/avif", + avis: "image/avif", + heic: "image/heic", + heif: "image/heif", + heix: "image/heif", + hevc: "image/heic", + mif1: "image/heif", + msf1: "image/heif", +}; + +const FTYP_VIDEO_BRANDS: Record = { + isom: "video/mp4", + iso2: "video/mp4", + iso5: "video/mp4", + mp41: "video/mp4", + mp42: "video/mp4", + avc1: "video/mp4", + mp4v: "video/mp4", + m4v: "video/x-m4v", + qt: "video/quicktime", + "3gp4": "video/3gpp", + "3gp5": "video/3gpp", + "3gp6": "video/3gpp", + "3gp7": "video/3gpp", + "3g2": "video/3gpp2", +}; + +const NON_TEXT_SUFFIXES = new Set([ + ".icns", ".psd", ".ai", ".eps", + // Documents / office formats + ".pdf", ".doc", ".docx", ".dot", ".dotx", ".rtf", ".odt", + ".xls", ".xlsx", ".xlsm", ".xlt", ".xltx", ".xltm", ".ods", + ".ppt", ".pptx", ".pptm", ".pps", ".ppsx", ".odp", + ".pages", ".numbers", ".key", + // Archives / compressed + ".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".bz2", ".xz", + ".zst", ".lz", ".lz4", ".br", ".cab", ".ar", ".deb", ".rpm", + // Audio + ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac", ".m4a", ".wma", + // Fonts + ".ttf", ".otf", ".woff", ".woff2", + // Binaries / bundles + ".exe", ".dll", ".so", ".dylib", ".bin", ".apk", ".ipa", + ".jar", ".class", ".pyc", ".pyo", ".wasm", + // Disk images / databases + ".dmg", ".iso", ".img", ".sqlite", ".sqlite3", ".db", ".db3", +]); + +const ASF_HEADER = new Uint8Array([ + 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, + 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, +]); + +function bufStartsWith(buf: Uint8Array, prefix: Uint8Array | number[]): boolean { + if (buf.length < prefix.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (buf[i] !== prefix[i]) return false; + } + return true; +} + +function sniffFtypBrand(header: Uint8Array): string | null { + if (header.length < 12) return null; + // Check for "ftyp" at bytes 4-8 + if ( + header[4] !== 0x66 || // f + header[5] !== 0x74 || // t + header[6] !== 0x79 || // y + header[7] !== 0x70 // p + ) return null; + const brand = String.fromCharCode(...header.slice(8, 12)).toLowerCase().trim(); + return brand; +} + +/** Detect media type from raw magic bytes. */ +export function sniffMediaFromMagic(data: Uint8Array): FileType | null { + const header = data.slice(0, MEDIA_SNIFF_BYTES); + + // PNG + if (bufStartsWith(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { kind: "image", mimeType: "image/png" }; + } + // JPEG + if (bufStartsWith(header, [0xff, 0xd8, 0xff])) { + return { kind: "image", mimeType: "image/jpeg" }; + } + // GIF + if (bufStartsWith(header, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || // GIF87a + bufStartsWith(header, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) { // GIF89a + return { kind: "image", mimeType: "image/gif" }; + } + // BMP + if (bufStartsWith(header, [0x42, 0x4d])) { + return { kind: "image", mimeType: "image/bmp" }; + } + // TIFF (II or MM) + if (bufStartsWith(header, [0x49, 0x49, 0x2a, 0x00]) || + bufStartsWith(header, [0x4d, 0x4d, 0x00, 0x2a])) { + return { kind: "image", mimeType: "image/tiff" }; + } + // ICO + if (bufStartsWith(header, [0x00, 0x00, 0x01, 0x00])) { + return { kind: "image", mimeType: "image/x-icon" }; + } + // RIFF (WEBP or AVI) + if (bufStartsWith(header, [0x52, 0x49, 0x46, 0x46]) && header.length >= 12) { + const chunk = String.fromCharCode(header[8]!, header[9]!, header[10]!, header[11]!); + if (chunk === "WEBP") return { kind: "image", mimeType: "image/webp" }; + if (chunk === "AVI ") return { kind: "video", mimeType: "video/x-msvideo" }; + } + // FLV + if (bufStartsWith(header, [0x46, 0x4c, 0x56])) { + return { kind: "video", mimeType: "video/x-flv" }; + } + // ASF (WMV) + if (bufStartsWith(header, ASF_HEADER)) { + return { kind: "video", mimeType: "video/x-ms-wmv" }; + } + // WebM / Matroska + if (bufStartsWith(header, [0x1a, 0x45, 0xdf, 0xa3])) { + const lowered = new TextDecoder().decode(header).toLowerCase(); + if (lowered.includes("webm")) return { kind: "video", mimeType: "video/webm" }; + if (lowered.includes("matroska")) return { kind: "video", mimeType: "video/x-matroska" }; + } + // ftyp container (MP4, HEIC, AVIF, etc.) + const brand = sniffFtypBrand(header); + if (brand) { + if (brand in FTYP_IMAGE_BRANDS) { + return { kind: "image", mimeType: FTYP_IMAGE_BRANDS[brand]! }; + } + if (brand in FTYP_VIDEO_BRANDS) { + return { kind: "video", mimeType: FTYP_VIDEO_BRANDS[brand]! }; + } + } + + return null; +} + +/** Detect file type from path extension and optional header bytes. */ +export function detectFileType(path: string, header?: Uint8Array): FileType { + const suffix = extname(path).toLowerCase(); + + let mediaHint: FileType | null = null; + if (suffix in TEXT_MIME_BY_SUFFIX) { + mediaHint = { kind: "text", mimeType: TEXT_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in IMAGE_MIME_BY_SUFFIX) { + mediaHint = { kind: "image", mimeType: IMAGE_MIME_BY_SUFFIX[suffix]! }; + } else if (suffix in VIDEO_MIME_BY_SUFFIX) { + mediaHint = { kind: "video", mimeType: VIDEO_MIME_BY_SUFFIX[suffix]! }; + } + + if (mediaHint && (mediaHint.kind === "image" || mediaHint.kind === "video")) { + return mediaHint; + } + + if (header !== undefined) { + const sniffed = sniffMediaFromMagic(header); + if (sniffed) { + if (mediaHint && sniffed.kind !== mediaHint.kind) { + return { kind: "unknown", mimeType: "" }; + } + return sniffed; + } + // NUL bytes are a strong signal of binary content + if (header.includes(0x00)) { + return { kind: "unknown", mimeType: "" }; + } + } + + if (mediaHint) return mediaHint; + if (NON_TEXT_SUFFIXES.has(suffix)) { + return { kind: "unknown", mimeType: "" }; + } + return { kind: "text", mimeType: "text/plain" }; +} diff --git a/src/kimi_cli_ts/tools/file/write.ts b/src/kimi_cli_ts/tools/file/write.ts new file mode 100644 index 000000000..cbd79fff5 --- /dev/null +++ b/src/kimi_cli_ts/tools/file/write.ts @@ -0,0 +1,185 @@ +/** + * WriteFile tool — write content to a file. + * Corresponds to Python tools/file/write.py + */ + +import { resolve, dirname } from "node:path"; +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError } from "../types.ts"; +import { inspectPlanEditTarget } from "./plan_mode.ts"; +import type { DiffDisplayBlock } from "../display.ts"; + +const DESCRIPTION = `Write content to a file. + +**Tips:** +- When \`mode\` is not specified, it defaults to \`overwrite\`. Always write with caution. +- When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use \`overwrite\` mode at the first time, then use \`append\` mode after the first write.`; + +const ParamsSchema = z.object({ + path: z.string().describe( + "The path to the file to write. Absolute paths are required when writing files outside the working directory.", + ), + content: z.string().describe("The content to write to the file"), + mode: z + .enum(["overwrite", "append"]) + .default("overwrite") + .describe("The mode to use: `overwrite` or `append`."), +}); + +type Params = z.infer; + +function resolvePath(filePath: string, workingDir: string): string { + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return resolve(workingDir, filePath); +} + +/** Build a simple unified diff for display. */ +function buildSimpleDiff(oldContent: string, newContent: string, path: string): string { + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxPreview = 50; + const diffLines: string[] = [`--- a/${path}`, `+++ b/${path}`]; + + let shown = 0; + const maxLen = Math.max(oldLines.length, newLines.length); + for (let i = 0; i < maxLen && shown < maxPreview; i++) { + const oldLine = oldLines[i]; + const newLine = newLines[i]; + if (oldLine !== newLine) { + if (oldLine !== undefined) { + diffLines.push(`-${oldLine}`); + shown++; + } + if (newLine !== undefined) { + diffLines.push(`+${newLine}`); + shown++; + } + } + } + + if (shown >= maxPreview) { + diffLines.push(`... (diff truncated, ${maxLen} total lines)`); + } + + return diffLines.join("\n"); +} + +export class WriteFile extends CallableTool { + readonly name = "WriteFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + /** Optional plan mode bindings. */ + private _planModeChecker?: () => boolean; + private _planFilePathGetter?: () => string | null; + + /** Bind plan mode state checker and plan file path getter. */ + bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { + this._planModeChecker = checker; + this._planFilePathGetter = pathGetter; + } + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + + // Check plan mode restrictions + const planTarget = inspectPlanEditTarget(resolvedPath, { + planModeChecker: this._planModeChecker ?? ctx.getPlanMode, + planFilePathGetter: this._planFilePathGetter, + }); + if ("isError" in planTarget && planTarget.isError) { + return planTarget; + } + const isPlanFileWrite = !("isError" in planTarget) && planTarget.isPlanTarget; + + // Ensure parent directory for plan file writes + if (isPlanFileWrite && !("isError" in planTarget) && planTarget.planPath) { + const { mkdirSync } = await import("node:fs"); + mkdirSync(dirname(planTarget.planPath), { recursive: true }); + } + + // Check if parent directory exists + const parentDir = dirname(resolvedPath); + const { stat: fsStat, mkdir } = await import("node:fs/promises"); + try { + const parentInfo = await fsStat(parentDir); + if (!parentInfo.isDirectory()) { + return ToolError(`Parent path \`${parentDir}\` exists but is not a directory.`); + } + } catch (err: any) { + if (err?.code === "ENOENT") { + await mkdir(parentDir, { recursive: true }); + } else { + return ToolError(`Cannot access parent directory \`${parentDir}\`: ${err?.message}`); + } + } + + const file = Bun.file(resolvedPath); + const fileExisted = await file.exists(); + + // Build diff for approval display + let diffPreview = ""; + if (fileExisted) { + try { + const oldContent = await file.text(); + const newContent = params.mode === "append" ? oldContent + params.content : params.content; + diffPreview = buildSimpleDiff(oldContent, newContent, params.path); + } catch { + // Can't read old file — skip diff + } + } + + // Plan file writes are auto-approved; other writes need approval + if (!isPlanFileWrite) { + const approvalSummary = fileExisted + ? `${params.mode === "append" ? "Append to" : "Overwrite"} file \`${params.path}\`${diffPreview ? `\n${diffPreview}` : ""}` + : `Create file \`${params.path}\` (${params.content.length} chars)`; + + const decision = await ctx.approval( + "WriteFile", + fileExisted ? "edit" : "create", + approvalSummary, + ); + if (decision === "reject") { + return ToolError( + "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", + ); + } + } + + if (params.mode === "append" && fileExisted) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(resolvedPath, params.content, "utf-8"); + } else { + await Bun.write(resolvedPath, params.content); + } + + const newFile = Bun.file(resolvedPath); + const fileSize = newFile.size; + const action = + params.mode === "overwrite" + ? (fileExisted ? "overwritten" : "created") + : "appended to"; + return { + isError: false, + output: "", + message: `File successfully ${action}. Current size: ${fileSize} bytes.`, + }; + } catch (e) { + return ToolError(`Failed to write to ${params.path}. Error: ${e}`); + } + } +} diff --git a/src/kimi_cli_ts/tools/plan/heroes.ts b/src/kimi_cli_ts/tools/plan/heroes.ts new file mode 100644 index 000000000..513eea744 --- /dev/null +++ b/src/kimi_cli_ts/tools/plan/heroes.ts @@ -0,0 +1,292 @@ +/** + * Plan file slug generation using Marvel and DC hero names. + * Corresponds to Python tools/plan/heroes.py + */ + +import { join } from "node:path"; +import { homedir } from "node:os"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; + +export const PLANS_DIR = join(homedir(), ".kimi", "plans"); + +export const HERO_NAMES: string[] = [ + // --- Marvel --- + "iron-man", + "spider-man", + "captain-america", + "thor", + "hulk", + "black-widow", + "hawkeye", + "black-panther", + "doctor-strange", + "scarlet-witch", + "vision", + "falcon", + "war-machine", + "ant-man", + "wasp", + "captain-marvel", + "gamora", + "star-lord", + "groot", + "rocket", + "drax", + "mantis", + "nebula", + "shang-chi", + "moon-knight", + "ms-marvel", + "she-hulk", + "echo", + "wolverine", + "cyclops", + "storm", + "jean-grey", + "rogue", + "beast", + "nightcrawler", + "colossus", + "shadowcat", + "jubilee", + "cable", + "deadpool", + "bishop", + "magik", + "iceman", + "archangel", + "psylocke", + "dazzler", + "forge", + "havok", + "polaris", + "emma-frost", + "namor", + "silver-surfer", + "adam-warlock", + "nova", + "quasar", + "sentry", + "blue-marvel", + "spectrum", + "squirrel-girl", + "cloak", + "dagger", + "punisher", + "elektra", + "luke-cage", + "iron-fist", + "jessica-jones", + "daredevil", + "blade", + "ghost-rider", + "morbius", + "venom", + "carnage", + "silk", + "spider-gwen", + "miles-morales", + "america-chavez", + "kate-bishop", + "yelena-belova", + "white-tiger", + "moon-girl", + "devil-dinosaur", + "amadeus-cho", + "riri-williams", + "kamala-khan", + "sam-alexander", + "nova-prime", + "medusa", + "black-bolt", + "crystal", + "karnak", + "gorgon", + "lockjaw", + "quake", + "mockingbird", + "bobbi-morse", + "maria-hill", + "nick-fury", + "phil-coulson", + "winter-soldier", + "us-agent", + "patriot", + "speed", + "wiccan", + "hulkling", + "stature", + "yellowjacket", + "tigra", + "hellcat", + "valkyrie", + "sif", + "beta-ray-bill", + "hercules", + "wonder-man", + "taskmaster", + "domino", + "cannonball", + "sunspot", + "wolfsbane", + "warpath", + "multiple-man", + "banshee", + "siryn", + "monet", + "rictor", + "shatterstar", + "longshot", + "daken", + "x-23", + "fantomex", + // --- DC --- + "batman", + "superman", + "wonder-woman", + "flash", + "aquaman", + "green-lantern", + "martian-manhunter", + "cyborg", + "hawkgirl", + "green-arrow", + "black-canary", + "zatanna", + "constantine", + "shazam", + "blue-beetle", + "booster-gold", + "firestorm", + "atom", + "hawkman", + "plastic-man", + "red-tornado", + "starfire", + "raven", + "beast-boy", + "robin", + "nightwing", + "batgirl", + "batwoman", + "red-hood", + "signal", + "orphan", + "spoiler", + "catwoman", + "huntress", + "supergirl", + "superboy", + "power-girl", + "steel", + "stargirl", + "wildcat", + "doctor-fate", + "mister-terrific", + "hourman", + "sandman", + "spectre", + "phantom-stranger", + "swamp-thing", + "animal-man", + "deadman", + "vixen", + "black-lightning", + "static", + "icon", + "rocket-dc", + "captain-atom", + "fire", + "ice", + "elongated-man", + "metamorpho", + "black-hawk", + "crimson-avenger", + "doctor-mid-nite", + "jakeem-thunder", + "mister-miracle", + "big-barda", + "orion", + "lightray", + "forager", + "killer-frost", + "jessica-cruz", + "simon-baz", + "john-stewart", + "guy-gardner", + "kyle-rayner", + "hal-jordan", + "wally-west", + "barry-allen", + "jay-garrick", + "impulse", + "kid-flash", + "donna-troy", + "tempest", + "aqualad", + "miss-martian", + "terra", + "jericho", + "ravager", + "red-star", + "pantha", + "argent", + "damage", + "jade", + "obsidian", + "cyclone", + "atom-smasher", + "maxima", + "starman", + "liberty-belle", +]; + +const _slugCache = new Map(); + +/** Pre-warm the in-process slug cache with a previously persisted slug. */ +export function seedSlugCache(sessionId: string, slug: string): void { + _slugCache.set(sessionId, slug); +} + +/** Get or create a plan file slug for the given session. */ +export function getOrCreateSlug(sessionId: string): string { + const cached = _slugCache.get(sessionId); + if (cached) return cached; + + mkdirSync(PLANS_DIR, { recursive: true }); + + let slug = ""; + for (let i = 0; i < 20; i++) { + const words: string[] = []; + for (let j = 0; j < 3; j++) { + words.push(HERO_NAMES[Math.floor(Math.random() * HERO_NAMES.length)]!); + } + slug = words.join("-"); + if (!existsSync(join(PLANS_DIR, `${slug}.md`))) { + break; + } + // If last attempt and still colliding, append session prefix + if (i === 19) { + slug = `${slug}-${sessionId.slice(0, 8)}`; + } + } + + _slugCache.set(sessionId, slug); + return slug; +} + +/** Get the plan file path for the given session. */ +export function getPlanFilePath(sessionId: string): string { + return join(PLANS_DIR, `${getOrCreateSlug(sessionId)}.md`); +} + +/** Read the plan file content for the given session, or null if not found. */ +export function readPlanFile(sessionId: string): string | null { + const path = getPlanFilePath(sessionId); + try { + if (!existsSync(path)) return null; + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} diff --git a/src/kimi_cli_ts/tools/plan/plan.ts b/src/kimi_cli_ts/tools/plan/plan.ts new file mode 100644 index 000000000..769f60ff6 --- /dev/null +++ b/src/kimi_cli_ts/tools/plan/plan.ts @@ -0,0 +1,337 @@ +/** + * Plan mode tools — lets the LLM enter/exit plan mode. + * Corresponds to Python tools/plan/enter.py and tools/plan/__init__.py + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import { getPlanFilePath, readPlanFile } from "./heroes.ts"; + +// ── EnterPlanMode ────────────────────────────────── + +const ENTER_DESCRIPTION = `Use this tool proactively when you're about to start a non-trivial implementation task. +Getting user sign-off on your approach before writing code prevents wasted effort. + +Use it when ANY of these conditions apply: +1. New Feature Implementation +2. Multiple Valid Approaches +3. Code Modifications +4. Architectural Decisions +5. Multi-File Changes +6. Unclear Requirements +7. User Preferences Matter + +When NOT to use: +- Single-line or few-line fixes +- User gave very specific, detailed instructions +- Pure research/exploration tasks`; + +const EnterParamsSchema = z.object({}); + +export class EnterPlanMode extends CallableTool { + readonly name = "EnterPlanMode"; + readonly description = ENTER_DESCRIPTION; + readonly schema = EnterParamsSchema; + + /** Session ID for plan file management. */ + private _sessionId: string; + private _isYolo: (() => boolean) | null = null; + + constructor(sessionId = "default") { + super(); + this._sessionId = sessionId; + } + + /** Bind optional YOLO mode checker. */ + bindYolo(isYolo: () => boolean): void { + this._isYolo = isYolo; + } + + async execute(_params: unknown, ctx: ToolContext): Promise { + // Guard: already in plan mode + if (ctx.getPlanMode?.()) { + return ToolError( + "Already in plan mode. Use ExitPlanMode when your plan is ready.", + ); + } + + const planPath = getPlanFilePath(this._sessionId); + + // In YOLO mode, auto-approve entering plan mode + if (this._isYolo?.()) { + ctx.setPlanMode?.(true); + return ToolOk( + `Plan mode activated (auto-approved in non-interactive mode).\n` + + `Plan file: ${planPath}\n` + + `Workflow: identify key questions about the codebase → ` + + `use Agent(subagent_type='explore') to investigate if needed → ` + + `design approach → ` + + `modify the plan file with WriteFile or StrReplaceFile ` + + `(create it with WriteFile first if it does not exist) → ` + + `call ExitPlanMode.\n`, + "Plan mode on (auto)", + ); + } + + // In interactive mode, ask user for confirmation + if (ctx.askUser) { + try { + const answer = await ctx.askUser("Enter plan mode?", ["Yes", "No"]); + + if (answer === "Yes") { + ctx.setPlanMode?.(true); + return ToolOk( + `Plan mode activated. You MUST NOT edit code files — only read and plan.\n` + + `Plan file: ${planPath}\n` + + `Workflow: identify key questions about the codebase → ` + + `use Agent(subagent_type='explore') to investigate if needed → ` + + `design approach → ` + + `modify the plan file with WriteFile or StrReplaceFile ` + + `(create it with WriteFile first if it does not exist) → ` + + `call ExitPlanMode.\n` + + `Use AskUserQuestion only to clarify missing requirements or choose ` + + `between approaches.\n` + + `Do NOT use AskUserQuestion to ask about plan approval.`, + "Plan mode on", + ); + } else { + return ToolOk( + "User declined to enter plan mode. Please check with user whether " + + "to proceed with implementation directly.", + "Declined", + ); + } + } catch { + // askUser not supported, fall through to auto-enter + } + } + + // Fallback: auto-enter plan mode + ctx.setPlanMode?.(true); + return ToolOk( + "Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.\n" + + "In plan mode, you should:\n" + + "1. Thoroughly explore the codebase to understand existing patterns\n" + + "2. Consider multiple approaches and their trade-offs\n" + + "3. Design a concrete implementation strategy\n" + + `4. Write your plan to: ${planPath}\n` + + "5. When ready, use ExitPlanMode to present your plan for approval\n" + + "\n" + + "Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.", + "Plan mode activated.", + ); + } +} + +// ── ExitPlanMode ────────────────────────────────── + +const EXIT_DESCRIPTION = `Use this tool when you are in plan mode and have finished writing your plan. +This signals that you're done planning and ready for the user to review and approve. + +IMPORTANT: Only use this tool when the task requires planning the implementation of a task that requires writing code.`; + +const RESERVED_LABELS = new Set(["reject", "revise", "approve", "reject and exit"]); + +const PlanOptionSchema = z.object({ + label: z.string().describe( + "Short name for this option (1-8 words). Append '(Recommended)' if you recommend this option.", + ), + description: z.string().default("").describe( + "Brief summary of this approach and its trade-offs.", + ), +}); + +const ExitParamsSchema = z.object({ + options: z + .array(PlanOptionSchema) + .max(3) + .nullish() + .describe( + "When the plan contains multiple alternative approaches, list them here " + + "so the user can choose which one to execute. 2-3 options. " + + "Do not use 'Reject', 'Revise', 'Approve', or 'Reject and Exit' as labels.", + ), +}); + +type ExitParams = z.infer; + +export class ExitPlanMode extends CallableTool { + readonly name = "ExitPlanMode"; + readonly description = EXIT_DESCRIPTION; + readonly schema = ExitParamsSchema; + + /** Session ID for plan file management. */ + private _sessionId: string; + private _isYolo: (() => boolean) | null = null; + + constructor(sessionId = "default") { + super(); + this._sessionId = sessionId; + } + + /** Bind optional YOLO mode checker. */ + bindYolo(isYolo: () => boolean): void { + this._isYolo = isYolo; + } + + async execute(params: ExitParams, ctx: ToolContext): Promise { + // Guard: only works in plan mode + if (!ctx.getPlanMode?.()) { + return ToolError( + "Not in plan mode. ExitPlanMode is only available during plan mode.", + ); + } + + // Read the plan file + const planPath = getPlanFilePath(this._sessionId); + const planContent = readPlanFile(this._sessionId); + + if (!planContent) { + return ToolError( + `No plan file found. Write your plan to ${planPath} first, then call ExitPlanMode.`, + ); + } + + // Validate option labels + if (params.options) { + for (const opt of params.options) { + if (RESERVED_LABELS.has(opt.label.trim().toLowerCase())) { + return ToolError( + `Option label '${opt.label}' is reserved. Do not use Reject, Revise, Approve, or Reject and Exit as option labels.`, + ); + } + } + // Check uniqueness + const labels = params.options.map((o) => o.label); + if (new Set(labels).size !== labels.length) { + return ToolError("Option labels must be unique."); + } + } + + // In YOLO mode, auto-approve + if (this._isYolo?.()) { + ctx.setPlanMode?.(false); + return ToolOk( + `Plan approved (auto-approved in non-interactive mode). ` + + `Plan mode deactivated. All tools are now available.\n` + + `Plan saved to: ${planPath}\n\n` + + `## Approved Plan:\n${planContent}`, + "Plan approved (auto)", + ); + } + + const hasOptions = params.options != null && params.options.length >= 2; + + // Interactive mode: ask user for approval + if (ctx.askUser) { + try { + // Build option list + let choices: string[]; + if (hasOptions) { + choices = [ + ...params.options!.map((o) => o.label), + "Reject", + "Reject and Exit", + ]; + } else { + choices = ["Approve", "Reject", "Reject and Exit"]; + } + + // Display plan content via wireEmit if available + ctx.wireEmit?.({ + type: "plan_display", + content: planContent, + filePath: planPath, + }); + + const answer = await ctx.askUser("Approve this plan", choices); + + // Handle the answer + if (answer === "Reject and Exit") { + ctx.setPlanMode?.(false); + return ToolError( + "Plan rejected by user. Plan mode deactivated. " + + "All tools are now available. " + + "Wait for the user's next message.", + ); + } + + if (answer === "Reject") { + return ToolError( + "Plan rejected by user. Stay in plan mode. " + + "The user will provide feedback via conversation. " + + "Wait for the user's next message before revising.", + ); + } + + // Approve — multi-approach (user selected a specific option) + if (hasOptions) { + const optionLabels = new Set(params.options!.map((o) => o.label)); + if (optionLabels.has(answer)) { + ctx.setPlanMode?.(false); + return ToolOk( + `Plan approved by user. Selected approach: "${answer}"\n` + + `Plan mode deactivated. All tools are now available.\n` + + `Plan saved to: ${planPath}\n\n` + + `IMPORTANT: Execute ONLY the selected approach "${answer}". ` + + `Ignore other approaches in the plan.\n\n` + + `## Approved Plan:\n${planContent}`, + `Plan approved: ${answer}`, + ); + } + } + + // Approve — single-approach + if (answer === "Approve") { + ctx.setPlanMode?.(false); + return ToolOk( + `Plan approved by user. Plan mode deactivated. ` + + `All tools are now available.\n` + + `Plan saved to: ${planPath}\n\n` + + `## Approved Plan:\n${planContent}`, + "Plan approved", + ); + } + + // Revise — user provided free-text feedback + if (answer) { + return ToolOk( + `User wants to revise the plan. Stay in plan mode. ` + + `Revise based on the feedback below.\n\n` + + `User feedback: ${answer}`, + "Plan revision requested", + ); + } + + return ToolOk( + "User dismissed without choosing. Plan mode remains active. " + + "Continue working on your plan or call ExitPlanMode again when ready.", + "Dismissed", + ); + } catch { + // askUser not supported, auto-approve + ctx.setPlanMode?.(false); + return ToolOk( + `Plan approved (client does not support interactive review). ` + + `Plan mode deactivated.\n` + + `Plan saved to: ${planPath}\n\n` + + `## Approved Plan:\n${planContent}`, + "Plan approved", + ); + } + } + + // Fallback: auto-exit with plan content + ctx.setPlanMode?.(false); + return ToolOk( + `Exited plan mode. All tools are now available.\n` + + `Plan saved to: ${planPath}\n\n` + + `## Plan:\n${planContent}`, + "Plan mode deactivated.", + ); + } +} diff --git a/src/kimi_cli_ts/tools/registry.ts b/src/kimi_cli_ts/tools/registry.ts new file mode 100644 index 000000000..c69d695a2 --- /dev/null +++ b/src/kimi_cli_ts/tools/registry.ts @@ -0,0 +1,97 @@ +/** + * Tool registry — register, find, and list all tools. + * Also acts as a DI container for ToolContext. + * Corresponds to Python tools/__init__.py and tools/registry. + */ + +import type { CallableTool } from "./base.ts"; +import type { ToolContext, ToolDefinition, ToolResult } from "./types.ts"; +import { SkipThisTool, extractKeyArgument } from "./types.ts"; + +// Re-export for convenience +export { SkipThisTool, extractKeyArgument }; + +export class ToolRegistry { + private tools = new Map(); + private _ctx: ToolContext; + + constructor(ctx: ToolContext) { + this._ctx = ctx; + } + + get context(): ToolContext { + return this._ctx; + } + + /** Register a tool instance. Silently skips if SkipThisTool is thrown during construction. */ + register(tool: CallableTool): void { + if (this.tools.has(tool.name)) { + throw new Error(`Tool "${tool.name}" is already registered.`); + } + this.tools.set(tool.name, tool); + } + + /** + * Safely register a tool, catching SkipThisTool during construction. + * Returns true if registered, false if skipped. + */ + tryRegister(factory: () => CallableTool): boolean { + try { + const tool = factory(); + this.register(tool); + return true; + } catch (e) { + if (e instanceof SkipThisTool) { + return false; + } + throw e; + } + } + + /** Find a tool by name. */ + find(name: string): CallableTool | undefined { + return this.tools.get(name); + } + + /** List all registered tools. */ + list(): CallableTool[] { + return [...this.tools.values()]; + } + + /** Get all tool definitions for LLM function calling. */ + definitions(): ToolDefinition[] { + return this.list().map((t) => t.toDefinition()); + } + + /** Execute a tool by name with raw JSON arguments. */ + async execute( + name: string, + rawArgs: Record, + ): Promise { + const tool = this.tools.get(name); + if (!tool) { + return { + isError: true, + output: "", + message: `Tool "${name}" not found.`, + }; + } + + // Validate params through tool schema + const parsed = tool.schema.safeParse(rawArgs); + if (!parsed.success) { + return { + isError: true, + output: "", + message: `Invalid parameters for tool "${name}": ${parsed.error.message}`, + }; + } + + return tool.execute(parsed.data, this._ctx); + } + + /** Extract a key argument for display/logging from raw JSON arguments. */ + extractKeyArgument(jsonContent: string, toolName: string): string | null { + return extractKeyArgument(jsonContent, toolName); + } +} diff --git a/src/kimi_cli_ts/tools/shell/shell.ts b/src/kimi_cli_ts/tools/shell/shell.ts new file mode 100644 index 000000000..b01a31a7c --- /dev/null +++ b/src/kimi_cli_ts/tools/shell/shell.ts @@ -0,0 +1,213 @@ +/** + * Shell tool — execute shell commands. + * Corresponds to Python tools/shell/__init__.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolResultBuilder } from "../types.ts"; + +const MAX_FOREGROUND_TIMEOUT = 5 * 60; // 5 minutes +const MAX_BACKGROUND_TIMEOUT = 24 * 60 * 60; // 24 hours + +const DESCRIPTION = `Execute a shell command. Use this tool to explore the filesystem, edit files, run scripts, get system information, etc. + +**Output:** +The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. + +**Guidelines for safety and security:** +- Each shell tool call will be executed in a fresh shell environment. +- Avoid using \`..\ to access files outside of the working directory. +- Never run commands that require superuser privileges unless explicitly instructed. + +**Guidelines for efficiency:** +- For multiple related commands, use \`&&\` to chain them in a single call. +- Prefer \`run_in_background=true\` for long-running builds, tests, or servers.`; + +const ParamsSchema = z + .object({ + command: z.string().describe("The command to execute."), + timeout: z + .number() + .int() + .min(1) + .max(MAX_BACKGROUND_TIMEOUT) + .default(60) + .describe("The timeout in seconds for the command to execute."), + run_in_background: z + .boolean() + .default(false) + .describe("Whether to run the command as a background task."), + description: z + .string() + .default("") + .describe( + "A short description for the background task. Required when run_in_background=true.", + ), + }) + .refine( + (data) => !data.run_in_background || data.description.trim().length > 0, + { + message: "description is required when run_in_background is true", + path: ["description"], + }, + ) + .refine( + (data) => + data.run_in_background || data.timeout <= MAX_FOREGROUND_TIMEOUT, + { + message: `timeout must be <= ${MAX_FOREGROUND_TIMEOUT}s for foreground commands; use run_in_background=true for longer timeouts`, + path: ["timeout"], + }, + ); + +type Params = z.infer; + +/** Build a non-interactive environment to prevent prompts from hanging. */ +function getNoninteractiveEnv(): Record { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + TERM: "dumb", + // Prevent SSH from trying to open a tty for passphrase/password + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", + // Prevent GPG pinentry + GPG_TTY: "", + // Disable pager for git, man, etc. + GIT_PAGER: "cat", + PAGER: "cat", + // Disable color in common tools (helps with output parsing) + NO_COLOR: "1", + }; +} + +/** Read a stream and write chunks to builder, interleaving stdout and stderr. */ +async function readStreamToBuilder( + stream: ReadableStream | null, + builder: ToolResultBuilder, +): Promise { + if (!stream) return ""; + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + const chunks: string[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }); + chunks.push(text); + builder.write(text); + } + } finally { + reader.releaseLock(); + } + return chunks.join(""); +} + +export class Shell extends CallableTool { + readonly name = "Shell"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + const builder = new ToolResultBuilder(); + + if (!params.command) { + return builder.error("Command cannot be empty."); + } + + if (params.run_in_background) { + // Background mode - stub for now + return builder.error( + "Background tasks are not yet implemented in this version.", + ); + } + + // Request approval + const decision = await ctx.approval( + "Shell", + "run command", + `Run command \`${params.command}\``, + ); + if (decision === "reject") { + return ToolError( + "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", + ); + } + + try { + const shellPath = process.env.SHELL || "/bin/bash"; + + // Redirect stderr to stdout so they're interleaved in order + // Use shell syntax: command 2>&1 + const wrappedCommand = `${params.command} 2>&1`; + + const proc = Bun.spawn([shellPath, "-c", wrappedCommand], { + stdout: "pipe", + stderr: "pipe", // stderr still piped for safety (but most goes to stdout via 2>&1) + stdin: "pipe", + cwd: ctx.workingDir, + env: getNoninteractiveEnv(), + }); + + // Close stdin immediately so interactive prompts get EOF + try { + proc.stdin.end(); + } catch { + // Bun may not support .end() on all platforms + } + + let timedOut = false; + let timeoutId: ReturnType | null = null; + + try { + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("timeout")), + params.timeout * 1000, + ); + }); + + // Stream stdout to builder for real-time interleaved output + const readPromise = (async () => { + await readStreamToBuilder(proc.stdout as ReadableStream, builder); + // Also drain stderr (in case anything bypassed 2>&1) + const stderrBytes = await new Response(proc.stderr).arrayBuffer(); + const stderrStr = new TextDecoder("utf-8", { fatal: false }).decode(stderrBytes); + if (stderrStr) builder.write(stderrStr); + })(); + + await Promise.race([readPromise, timeoutPromise]); + if (timeoutId !== null) clearTimeout(timeoutId); + + await proc.exited; + } catch (e) { + if (timeoutId !== null) clearTimeout(timeoutId); + if (e instanceof Error && e.message === "timeout") { + proc.kill(); + timedOut = true; + } else { + throw e; + } + } + + if (timedOut) { + return builder.error( + `Command killed by timeout (${params.timeout}s)`, + ); + } + + const exitCode = proc.exitCode; + if (exitCode === 0) { + return builder.ok("Command executed successfully."); + } + return builder.error( + `Command failed with exit code: ${exitCode}.`, + ); + } catch (e) { + return builder.error(`Failed to execute command. Error: ${e}`); + } + } +} diff --git a/src/kimi_cli_ts/tools/think/think.ts b/src/kimi_cli_ts/tools/think/think.ts new file mode 100644 index 000000000..7b741db9f --- /dev/null +++ b/src/kimi_cli_ts/tools/think/think.ts @@ -0,0 +1,28 @@ +/** + * Think tool — give the LLM thinking space. + * Corresponds to Python tools/think/__init__.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolOk } from "../types.ts"; + +const DESCRIPTION = + "Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed."; + +const ParamsSchema = z.object({ + thought: z.string().describe("A thought to think about."), +}); + +type Params = z.infer; + +export class Think extends CallableTool { + readonly name = "Think"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(_params: Params, _ctx: ToolContext): Promise { + return ToolOk("", "Thought logged"); + } +} diff --git a/src/kimi_cli_ts/tools/todo/todo.ts b/src/kimi_cli_ts/tools/todo/todo.ts new file mode 100644 index 000000000..89e37c7a1 --- /dev/null +++ b/src/kimi_cli_ts/tools/todo/todo.ts @@ -0,0 +1,50 @@ +/** + * SetTodoList tool — manage a todo list. + * Corresponds to Python tools/todo/__init__.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; + +const DESCRIPTION = `Update the whole todo list. + +Todo list is a simple yet powerful tool to help you get things done. Use this tool when the given task involves multiple subtasks/milestones. + +Each time you want to operate on the todo list, you need to update the whole. Make sure to maintain the todo items and their statuses properly.`; + +const TodoSchema = z.object({ + title: z.string().min(1).describe("The title of the todo"), + status: z + .enum(["pending", "in_progress", "done"]) + .describe("The status of the todo"), +}); + +const ParamsSchema = z.object({ + todos: z.array(TodoSchema).describe("The updated todo list"), +}); + +type Params = z.infer; + +export class SetTodoList extends CallableTool { + readonly name = "SetTodoList"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, _ctx: ToolContext): Promise { + return { + isError: false, + output: "", + message: "Todo list updated", + display: [ + { + type: "todo", + items: params.todos.map((t) => ({ + title: t.title, + status: t.status, + })), + }, + ], + }; + } +} diff --git a/src/kimi_cli_ts/tools/types.ts b/src/kimi_cli_ts/tools/types.ts new file mode 100644 index 000000000..fc4029ce0 --- /dev/null +++ b/src/kimi_cli_ts/tools/types.ts @@ -0,0 +1,349 @@ +/** + * Tool-related types — corresponds to Python tools/utils.py and kosong.tooling types. + */ + +import type { ApprovalDecision, JsonValue } from "../types.ts"; +import type { Runtime } from "../soul/agent.ts"; + +// ── ToolContext ────────────────────────────────────────── + +/** Context injected into every tool execution. */ +export interface ToolContext { + /** Current working directory. */ + workingDir: string; + /** AbortSignal for cooperative cancellation. */ + signal?: AbortSignal; + /** Request user approval; returns the decision. */ + approval: ( + toolName: string, + action: string, + summary: string, + ) => Promise; + /** Emit a wire event (for UI communication). */ + wireEmit?: (event: unknown) => void; + /** Toggle plan mode on/off. */ + setPlanMode?: (on: boolean) => void; + /** Get current plan mode status. */ + getPlanMode?: () => boolean; + /** Get plan file path. */ + getPlanFilePath?: () => string | undefined; + /** Toggle plan mode (manual toggle from slash command). */ + togglePlanMode?: () => void; + /** Ask the user a question and get the answer (for AskUserQuestion tool). */ + askUser?: (question: string, options?: string[]) => Promise; + /** Access to service config (for SearchWeb, FetchURL). */ + serviceConfig?: { + moonshotSearch?: { baseUrl: string; apiKey: string; customHeaders?: Record }; + moonshotFetch?: { baseUrl: string; apiKey: string; customHeaders?: Record }; + }; + /** Runtime reference for tools that need full runtime access (e.g. Agent tool). */ + runtime?: Runtime; +} + +// ── ToolResult ────────────────────────────────────────── + +export interface ToolResult { + isError: boolean; + output: string; + message?: string; + display?: unknown[]; + extras?: Record; +} + +/** Create a successful ToolResult. */ +export function ToolOk( + output: string, + message?: string, + display?: unknown[], + extras?: Record, +): ToolResult { + return { isError: false, output, message, display, extras }; +} + +/** Create an error ToolResult. */ +export function ToolError( + message: string, + output = "", + display?: unknown[], +): ToolResult { + return { isError: true, output, message, display }; +} + +// ── ToolDefinition ────────────────────────────────────── + +export interface ToolDefinition { + name: string; + description: string; + parameters: Record; +} + +// ── ToolResultBuilder ─────────────────────────────────── + +const DEFAULT_MAX_CHARS = 50_000; +const DEFAULT_MAX_LINE_LENGTH = 2000; + +function truncateLine(line: string, maxLength: number, marker = "..."): string { + if (line.length <= maxLength) return line; + + // Find trailing line breaks + const m = line.match(/[\r\n]+$/); + const linebreak = m ? m[0] : ""; + const end = marker + linebreak; + const effectiveMax = Math.max(maxLength, end.length); + return line.slice(0, effectiveMax - end.length) + end; +} + +export class ToolResultBuilder { + private maxChars: number; + private maxLineLength: number | null; + private marker = "[...truncated]"; + private buffer: string[] = []; + private _nChars = 0; + private _nLines = 0; + private _truncationHappened = false; + private _display: unknown[] = []; + private _extras: Record | null = null; + + constructor( + maxChars = DEFAULT_MAX_CHARS, + maxLineLength: number | null = DEFAULT_MAX_LINE_LENGTH, + ) { + this.maxChars = maxChars; + this.maxLineLength = maxLineLength; + } + + get isFull(): boolean { + return this._nChars >= this.maxChars; + } + + get nChars(): number { + return this._nChars; + } + + get nLines(): number { + return this._nLines; + } + + /** Write text to the output buffer. Returns number of characters written. */ + write(text: string): number { + if (this.isFull) return 0; + + // Split keeping line endings + const lines = text.split(/(?<=\n)/); + if (lines.length === 0) return 0; + + let charsWritten = 0; + + for (const originalLine of lines) { + if (this.isFull) break; + if (!originalLine) continue; + + const remainingChars = this.maxChars - this._nChars; + const limit = + this.maxLineLength !== null + ? Math.min(remainingChars, this.maxLineLength) + : remainingChars; + const line = truncateLine(originalLine, limit, this.marker); + if (line !== originalLine) { + this._truncationHappened = true; + } + + this.buffer.push(line); + charsWritten += line.length; + this._nChars += line.length; + if (line.endsWith("\n")) { + this._nLines += 1; + } + } + + return charsWritten; + } + + display(...blocks: unknown[]): void { + this._display.push(...blocks); + } + + extras(extra: Record): void { + if (this._extras === null) { + this._extras = {}; + } + Object.assign(this._extras, extra); + } + + ok(message = ""): ToolResult { + const output = this.buffer.join(""); + + let finalMessage = message; + if (finalMessage && !finalMessage.endsWith(".")) { + finalMessage += "."; + } + const truncationMsg = "Output is truncated to fit in the message."; + if (this._truncationHappened) { + finalMessage = finalMessage + ? `${finalMessage} ${truncationMsg}` + : truncationMsg; + } + return { + isError: false, + output, + message: finalMessage || undefined, + display: this._display.length > 0 ? this._display : undefined, + extras: this._extras ?? undefined, + }; + } + + error(message: string): ToolResult { + const output = this.buffer.join(""); + + let finalMessage = message; + if (this._truncationHappened) { + const truncationMsg = "Output is truncated to fit in the message."; + finalMessage = finalMessage + ? `${finalMessage} ${truncationMsg}` + : truncationMsg; + } + + return { + isError: true, + output, + message: finalMessage, + display: this._display.length > 0 ? this._display : undefined, + extras: this._extras ?? undefined, + }; + } +} + +// ── ToolRejectedError ─────────────────────────────── + +/** + * Thrown / returned when a tool call is rejected by the user. + * Corresponds to Python utils.ToolRejectedError. + */ +export class ToolRejectedError extends Error { + readonly isError = true as const; + readonly hasFeedback: boolean; + readonly brief: string; + + constructor(opts?: { + message?: string; + brief?: string; + hasFeedback?: boolean; + }) { + super( + opts?.message ?? + "The tool call is rejected by the user. " + + "Stop what you are doing and wait for the user to tell you how to proceed.", + ); + this.name = "ToolRejectedError"; + this.brief = opts?.brief ?? "Rejected by user"; + this.hasFeedback = opts?.hasFeedback ?? false; + } + + /** Convert to a ToolResult for returning from a tool handler. */ + toToolResult(): ToolResult { + return { + isError: true, + output: "", + message: this.message, + }; + } +} + +// ── SkipThisTool ──────────────────────────────────── + +/** + * Thrown when a tool decides to skip itself from the loading process. + * Corresponds to Python __init__.SkipThisTool. + */ +export class SkipThisTool extends Error { + constructor(reason?: string) { + super(reason ?? "Tool skipped"); + this.name = "SkipThisTool"; + } +} + +// ── extractKeyArgument ────────────────────────────── + +/** + * Extract a key argument string from tool call JSON arguments. + * Used for logging / display summaries. + * Corresponds to Python __init__.extract_key_argument. + */ +export function extractKeyArgument( + jsonContent: string, + toolName: string, +): string | null { + let args: Record; + try { + args = JSON.parse(jsonContent); + } catch { + return null; + } + if (!args || typeof args !== "object") return null; + + let keyArg = ""; + + switch (toolName) { + case "Agent": + if (!args.description) return null; + keyArg = String(args.description); + break; + case "SendDMail": + case "SetTodoList": + return null; + case "Think": + if (!args.thought) return null; + keyArg = String(args.thought); + break; + case "Shell": + if (!args.command) return null; + keyArg = String(args.command); + break; + case "TaskOutput": + case "TaskStop": + if (!args.task_id) return null; + keyArg = String(args.task_id); + break; + case "TaskList": + keyArg = args.active_only !== false ? "active" : "all"; + break; + case "ReadFile": + case "ReadMediaFile": + case "WriteFile": + case "StrReplaceFile": + if (!args.path) return null; + keyArg = _normalizePath(String(args.path)); + break; + case "Glob": + case "Grep": + if (!args.pattern) return null; + keyArg = String(args.pattern); + break; + case "SearchWeb": + if (!args.query) return null; + keyArg = String(args.query); + break; + case "FetchURL": + if (!args.url) return null; + keyArg = String(args.url); + break; + default: + keyArg = jsonContent; + } + + return _shortenMiddle(keyArg, 50); +} + +function _normalizePath(path: string): string { + const cwd = process.cwd(); + if (path.startsWith(cwd)) { + path = path.slice(cwd.length).replace(/^[/\\]/, ""); + } + return path; +} + +function _shortenMiddle(s: string, width: number): string { + if (s.length <= width) return s; + const half = Math.floor((width - 3) / 2); + return s.slice(0, half) + "..." + s.slice(s.length - half); +} diff --git a/src/kimi_cli_ts/tools/web/fetch.ts b/src/kimi_cli_ts/tools/web/fetch.ts new file mode 100644 index 000000000..8136e7fa5 --- /dev/null +++ b/src/kimi_cli_ts/tools/web/fetch.ts @@ -0,0 +1,252 @@ +/** + * FetchURL tool — fetch a web page and extract main text content. + * Corresponds to Python tools/web/fetch.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolResultBuilder } from "../types.ts"; + +const DESCRIPTION = + "Fetch a web page from a URL and extract main text content from it."; + +const ParamsSchema = z.object({ + url: z.string().describe("The URL to fetch content from."), +}); + +type Params = z.infer; + +export class FetchURL extends CallableTool { + readonly name = "FetchURL"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + const builder = new ToolResultBuilder(50_000, null); + + try { + // Try service-based fetch first (if configured) + const fetchConfig = ctx.serviceConfig?.moonshotFetch; + if (fetchConfig?.baseUrl && fetchConfig?.apiKey) { + try { + const serviceResult = await fetchViaService(params.url, fetchConfig); + if (serviceResult) { + builder.write(serviceResult); + return builder.ok("Content fetched via service."); + } + } catch { + // Fall through to direct fetch + } + } + + // Direct HTTP fetch + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60_000); + + const response = await fetch(params.url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + signal: controller.signal, + redirect: "follow", + }); + + clearTimeout(timeout); + + if (response.status >= 400) { + return builder.error( + `Failed to fetch URL. Status: ${response.status}. This may indicate the page is not accessible or the server is down.`, + ); + } + + const respText = await response.text(); + const contentType = response.headers.get("content-type") || ""; + + if ( + contentType.startsWith("text/plain") || + contentType.startsWith("text/markdown") || + contentType.startsWith("application/json") + ) { + builder.write(respText); + return builder.ok( + "The returned content is the full content of the page.", + ); + } + + if (!respText) { + return builder.ok("The response body is empty."); + } + + // Extract main content from HTML + const extracted = extractContent(respText); + + if (!extracted || extracted.length < 10) { + return builder.error( + "Failed to extract meaningful content from the page. " + + "The page may require JavaScript to render its content.", + ); + } + + builder.write(extracted); + return builder.ok( + "The returned content is the main text content extracted from the page.", + ); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") { + return builder.error( + "Failed to fetch URL: request timed out. The server may be slow or unreachable.", + ); + } + return builder.error( + `Failed to fetch URL due to network error: ${e}. The URL may be invalid or the server is unreachable.`, + ); + } + } +} + +/** Try fetching via moonshot fetch service. */ +async function fetchViaService( + url: string, + config: { baseUrl: string; apiKey: string; customHeaders?: Record }, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 90_000); + + const response = await fetch(config.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + ...(config.customHeaders ?? {}), + }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) return null; + + const data = (await response.json()) as { content?: string; text?: string }; + return data.content || data.text || null; +} + +// ── HTML Content Extraction ────────────────────────── +// A proper content extraction that handles structure, entities, and main content detection. + +/** Decode all HTML entities (named + numeric). */ +function decodeEntities(text: string): string { + const namedEntities: Record = { + nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", + ldquo: "\u201C", rdquo: "\u201D", lsquo: "\u2018", rsquo: "\u2019", + mdash: "\u2014", ndash: "\u2013", hellip: "\u2026", + copy: "\u00A9", reg: "\u00AE", trade: "\u2122", + bull: "\u2022", middot: "\u00B7", laquo: "\u00AB", raquo: "\u00BB", + }; + + return text + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16))) + .replace(/&(\w+);/g, (match, name) => namedEntities[name.toLowerCase()] ?? match); +} + +/** Extract readable text content from HTML. */ +function extractContent(html: string): string { + // Step 1: Remove non-content elements entirely + let text = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/noscript>/gi, "") + .replace(/]*>[\s\S]*?<\/svg>/gi, "") + .replace(/]*>[\s\S]*?<\/nav>/gi, "") // Navigation + .replace(/]*>[\s\S]*?<\/footer>/gi, "") // Footer + .replace(//g, ""); // HTML comments + + // Step 2: Extract title + const titleMatch = text.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch ? decodeEntities(titleMatch[1]!.trim()) : ""; + + // Step 3: Try to find main content area + let mainContent = ""; + const mainPatterns = [ + /]*>([\s\S]*?)<\/main>/i, + /]*>([\s\S]*?)<\/article>/i, + /]*(?:class|id)="[^"]*(?:content|article|post|entry|main)[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + ]; + + for (const pattern of mainPatterns) { + const match = text.match(pattern); + if (match && match[1]!.length > 200) { + mainContent = match[1]!; + break; + } + } + + // Fall back to body or full text + if (!mainContent) { + const bodyMatch = text.match(/]*>([\s\S]*?)<\/body>/i); + mainContent = bodyMatch ? bodyMatch[1]! : text; + } + + // Step 4: Convert headings to markdown-style + mainContent = mainContent + .replace(/]*>([\s\S]*?)<\/h1>/gi, "\n\n# $1\n\n") + .replace(/]*>([\s\S]*?)<\/h2>/gi, "\n\n## $1\n\n") + .replace(/]*>([\s\S]*?)<\/h3>/gi, "\n\n### $1\n\n") + .replace(/]*>([\s\S]*?)<\/h[4-6]>/gi, "\n\n#### $1\n\n"); + + // Step 5: Handle links — extract text with URL + mainContent = mainContent.replace( + /]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, + (_, href, text) => { + const linkText = text.replace(/<[^>]+>/g, "").trim(); + if (!linkText) return ""; + // Only show URL for external links + if (href.startsWith("http")) return `[${linkText}](${href})`; + return linkText; + }, + ); + + // Step 6: Handle lists + mainContent = mainContent + .replace(/]*>/gi, "\n- ") + .replace(/<\/li>/gi, ""); + + // Step 7: Handle tables — convert to simple text + mainContent = mainContent + .replace(/]*>/gi, "\n") + .replace(/<\/tr>/gi, "") + .replace(/]*>/gi, "\t") + .replace(/<\/t[hd]>/gi, ""); + + // Step 8: Handle block elements + mainContent = mainContent + .replace(/<\/?(p|div|section|header|blockquote)[^>]*>/gi, "\n\n") + .replace(//gi, "\n") + .replace(//gi, "\n---\n") + .replace(/<\/?(pre|code)[^>]*>/gi, "\n```\n"); + + // Step 9: Remove all remaining HTML tags + mainContent = mainContent.replace(/<[^>]+>/g, ""); + + // Step 10: Decode entities + mainContent = decodeEntities(mainContent); + + // Step 11: Clean up whitespace + mainContent = mainContent + .replace(/[ \t]+/g, " ") // Collapse horizontal whitespace + .replace(/\n[ \t]+/g, "\n") // Remove leading whitespace on lines + .replace(/[ \t]+\n/g, "\n") // Remove trailing whitespace on lines + .replace(/\n{3,}/g, "\n\n") // Max 2 consecutive newlines + .trim(); + + // Prepend title if found + if (title && !mainContent.startsWith(title)) { + mainContent = `# ${title}\n\n${mainContent}`; + } + + return mainContent; +} diff --git a/src/kimi_cli_ts/tools/web/search.ts b/src/kimi_cli_ts/tools/web/search.ts new file mode 100644 index 000000000..e1da67dae --- /dev/null +++ b/src/kimi_cli_ts/tools/web/search.ts @@ -0,0 +1,119 @@ +/** + * SearchWeb tool — web search via moonshot search service. + * Corresponds to Python tools/web/search.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolResultBuilder } from "../types.ts"; + +const DESCRIPTION = + "WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc."; + +const ParamsSchema = z.object({ + query: z.string().describe("The query text to search for."), + limit: z + .number() + .int() + .min(1) + .max(20) + .default(5) + .describe("The number of results to return."), + include_content: z + .boolean() + .default(false) + .describe( + "Whether to include the content of the web pages in the results. Can consume many tokens.", + ), +}); + +type Params = z.infer; + +interface SearchResult { + site_name: string; + title: string; + url: string; + snippet: string; + content?: string; + date?: string; +} + +export class SearchWeb extends CallableTool { + readonly name = "SearchWeb"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + async execute(params: Params, ctx: ToolContext): Promise { + const builder = new ToolResultBuilder(50_000, null); + + const searchConfig = ctx.serviceConfig?.moonshotSearch; + if (!searchConfig?.baseUrl || !searchConfig?.apiKey) { + return builder.error( + "Search service is not configured. You may want to try other methods to search.", + ); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 180_000); // 3 min total timeout + + const response = await fetch(searchConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${searchConfig.apiKey}`, + ...(searchConfig.customHeaders ?? {}), + }, + body: JSON.stringify({ + text_query: params.query, + limit: params.limit, + enable_page_crawling: params.include_content, + timeout_seconds: 30, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return builder.error( + `Failed to search. Status: ${response.status}. ` + + "This may indicate that the search service is currently unavailable.", + ); + } + + const data = (await response.json()) as { search_results?: SearchResult[] }; + const results = data.search_results ?? []; + + if (results.length === 0) { + return builder.ok("No search results found."); + } + + for (let i = 0; i < results.length; i++) { + const result = results[i]!; + if (i > 0) builder.write("---\n\n"); + builder.write( + `Title: ${result.title}\n` + + `Date: ${result.date ?? ""}\n` + + `URL: ${result.url}\n` + + `Summary: ${result.snippet}\n\n`, + ); + if (result.content) { + builder.write(`${result.content}\n\n`); + } + } + + return builder.ok(`Found ${results.length} search results.`); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return builder.error( + "Search request timed out. The search service may be slow or unavailable.", + ); + } + return builder.error( + `Search request failed: ${err instanceof Error ? err.message : err}. The search service may be unavailable.`, + ); + } + } +} diff --git a/src/kimi_cli_ts/types.ts b/src/kimi_cli_ts/types.ts new file mode 100644 index 000000000..8fa01ab70 --- /dev/null +++ b/src/kimi_cli_ts/types.ts @@ -0,0 +1,133 @@ +/** + * Shared types used across the codebase + * Corresponds to common types from Python's Pydantic models + */ + +import { z } from "zod/v4"; + +// ── Content Types (LLM message content) ────────────────── + +export const TextPart = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +export const ImagePart = z.object({ + type: z.literal("image"), + source: z.object({ + type: z.enum(["base64", "url"]), + mediaType: z.string().optional(), + data: z.string(), + }), +}); + +export const ToolUsePart = z.object({ + type: z.literal("tool_use"), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), +}); + +export const ToolResultPart = z.object({ + type: z.literal("tool_result"), + toolUseId: z.string(), + content: z.string(), + isError: z.boolean().optional(), +}); + +export const ContentPart = z.union([TextPart, ImagePart, ToolUsePart, ToolResultPart]); +export type ContentPart = z.infer; + +// ── Message Types ──────────────────────────────────────── + +export const Message = z.object({ + role: z.enum(["user", "assistant", "system", "tool"]), + content: z.union([z.string(), z.array(ContentPart)]), +}); +export type Message = z.infer; + +// ── Usage / Token Tracking ────────────────────────────── + +export const TokenUsage = z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number().optional(), + cacheWriteTokens: z.number().optional(), +}); +export type TokenUsage = z.infer; + +// ── Model Capabilities ────────────────────────────────── + +export const ModelCapability = z.enum([ + "image_in", + "video_in", + "thinking", + "always_thinking", +]); +export type ModelCapability = z.infer; + +// ── Tool Types ────────────────────────────────────────── + +export const ToolCall = z.object({ + id: z.string(), + name: z.string(), + arguments: z.string(), // JSON string +}); +export type ToolCall = z.infer; + +export const ToolReturnValue = z.object({ + isError: z.boolean().default(false), + output: z.string(), + message: z.string().optional(), + display: z.array(z.unknown()).optional(), + extras: z.record(z.string(), z.unknown()).optional(), +}); +export type ToolReturnValue = z.infer; + +// ── Approval ──────────────────────────────────────────── + +export type ApprovalDecision = "approve" | "approve_for_session" | "reject"; + +// ── Status ────────────────────────────────────────────── + +export interface StatusSnapshot { + contextUsage: number | null; + contextTokens: number | null; + maxContextTokens: number | null; + tokenUsage: TokenUsage | null; + planMode: boolean; + mcpStatus: Record | null; +} + +// ── Slash Commands ────────────────────────────────────── + +export interface PanelChoiceItem { + label: string; + value: string; + description?: string; + current?: boolean; +} + +export type CommandPanelConfig = + | { type: "choice"; title: string; items: PanelChoiceItem[]; onSelect: (value: string) => CommandPanelConfig | Promise | void } + | { type: "content"; title: string; content: string } + | { type: "input"; title: string; placeholder?: string; password?: boolean; onSubmit: (value: string) => CommandPanelConfig | Promise | void }; + +export interface SlashCommand { + name: string; + description: string; + aliases?: string[]; + handler: (args: string) => Promise; + /** If defined, selecting from menu renders a secondary panel instead of executing handler */ + panel?: () => CommandPanelConfig | null; +} + +// ── JSON utility type ─────────────────────────────────── + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; diff --git a/src/kimi_cli_ts/ui/components/ApprovalPrompt.tsx b/src/kimi_cli_ts/ui/components/ApprovalPrompt.tsx new file mode 100644 index 000000000..eac3d26fb --- /dev/null +++ b/src/kimi_cli_ts/ui/components/ApprovalPrompt.tsx @@ -0,0 +1,105 @@ +/** + * ApprovalPrompt component — approval request UI. + * Corresponds to Python's approval_panel.py. + * + * Shows: action description, display blocks, and approval choices. + * [y] Allow / [n] Deny / [a] Always allow + */ + +import React, { useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import { getMessageColors } from "../theme"; +import type { ApprovalRequest, ApprovalResponseKind } from "../../wire/types"; + +interface ApprovalPromptProps { + request: ApprovalRequest; + onRespond: (decision: ApprovalResponseKind, feedback?: string) => void; +} + +export function ApprovalPrompt({ request, onRespond }: ApprovalPromptProps) { + const colors = getMessageColors(); + + useInput((input, key) => { + switch (input.toLowerCase()) { + case "y": + onRespond("approve"); + break; + case "n": + onRespond("reject"); + break; + case "a": + onRespond("approve_for_session"); + break; + } + }); + + return ( + + + ⚠ Approval Required + + + {/* Source info */} + {request.source_description && ( + + From: {request.source_description} + + )} + + {/* Action */} + + + {request.action} + + + + {/* Description */} + {request.description} + + {/* Display blocks preview */} + {request.display.length > 0 && ( + + {request.display.slice(0, 3).map((block, idx) => { + if (block.type === "brief") { + return ( + + {(block as { brief: string }).brief} + + ); + } + if (block.type === "shell") { + return ( + + $ {(block as { command: string }).command} + + ); + } + return null; + })} + + )} + + {/* Choices */} + + + [y] + Allow + + + [n] + Deny + + + [a] + Always + + + + ); +} diff --git a/src/kimi_cli_ts/ui/components/CommandPanel.tsx b/src/kimi_cli_ts/ui/components/CommandPanel.tsx new file mode 100644 index 000000000..3de4df9a9 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/CommandPanel.tsx @@ -0,0 +1,288 @@ +/** + * CommandPanel.tsx — Secondary interactive panel for slash commands. + * Renders below the input area when a command needs a secondary menu. + * + * Supports three modes: + * - "choice": selectable list (↑↓ navigate, Enter select, Esc close) + * - "content": scrollable text (↑↓ scroll, Esc close) + * - "input": text input field (type, Enter submit, Esc close) + * + * Panels can chain: onSelect/onSubmit may return a new CommandPanelConfig + * to transition to the next step (wizard pattern). + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import TextInput from "ink-text-input"; +import type { CommandPanelConfig } from "../../types.ts"; + +const DIM = "#888888"; +const HIGHLIGHT = "#1e90ff"; +const BORDER_COLOR = "#555555"; + +interface CommandPanelProps { + config: CommandPanelConfig; + onClose: () => void; +} + +export function CommandPanel({ config, onClose }: CommandPanelProps) { + // Support panel transitions: when a child panel returns a new config, + // we replace the current config with the new one. + const [currentConfig, setCurrentConfig] = useState(config); + + // Reset when external config changes (e.g. opening a different command panel) + React.useEffect(() => { + setCurrentConfig(config); + }, [config]); + + const handleTransition = useCallback( + (result: CommandPanelConfig | Promise | void) => { + if (!result) return; + if (result instanceof Promise) { + result.then((next) => { + if (next) setCurrentConfig(next); + }); + } else { + setCurrentConfig(result); + } + }, + [], + ); + + if (currentConfig.type === "choice") { + return ; + } + if (currentConfig.type === "input") { + return ; + } + return ; +} + +// ── Choice Panel ──────────────────────────────────────── + +function ChoicePanel({ + config, + onClose, + onTransition, +}: { + config: Extract; + onClose: () => void; + onTransition: (result: CommandPanelConfig | Promise | void) => void; +}) { + const { items, onSelect, title } = config; + const defaultIndex = items.findIndex((item) => item.current); + const [selectedIndex, setSelectedIndex] = useState( + defaultIndex >= 0 ? defaultIndex : 0, + ); + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + useInput( + useCallback( + (_input: string, key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }) => { + if (key.escape) { + onClose(); + return; + } + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex((i) => Math.min(items.length - 1, i + 1)); + return; + } + if (key.return) { + const item = items[selectedIndex]; + if (item) { + const result = onSelect(item.value); + if (result) { + onTransition(result); + } else { + onClose(); + } + } + return; + } + }, + [items, selectedIndex, onSelect, onClose, onTransition], + ), + ); + + return ( + + {"─".repeat(columns)} + + + {title} + + (↑↓ select, Enter confirm, Esc cancel) + + {"─".repeat(columns)} + {items.map((item, i) => { + const isSelected = i === selectedIndex; + return ( + + + {isSelected ? "▸ " : " "} + + + {item.label} + + {item.description && ( + {" " + item.description} + )} + {item.current && ( + (current) + )} + + ); + })} + + ); +} + +// ── Input Panel ───────────────────────────────────────── + +function InputPanel({ + config, + onClose, + onTransition, +}: { + config: Extract; + onClose: () => void; + onTransition: (result: CommandPanelConfig | Promise | void) => void; +}) { + const { title, placeholder, password, onSubmit } = config; + const [value, setValue] = useState(""); + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + useInput( + useCallback( + (_input: string, key: { escape?: boolean }) => { + if (key.escape) { + onClose(); + } + }, + [onClose], + ), + ); + + const handleSubmit = useCallback( + (input: string) => { + const trimmed = input.trim(); + if (!trimmed) return; + const result = onSubmit(trimmed); + if (result) { + onTransition(result); + } else { + onClose(); + } + }, + [onSubmit, onClose, onTransition], + ); + + // For password fields, mask the input + const displayValue = password ? "•".repeat(value.length) : value; + + return ( + + {"─".repeat(columns)} + + + {title} + + (Enter submit, Esc cancel) + + {"─".repeat(columns)} + + {"▸ "} + {password ? ( + + ) : ( + + )} + + + ); +} + +// ── Content Panel ─────────────────────────────────────── + +function ContentPanel({ + config, + onClose, +}: { + config: Extract; + onClose: () => void; +}) { + const { content, title } = config; + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + const maxVisibleLines = Math.max((stdout?.rows ?? 24) - 8, 10); + + const lines = content.split("\n"); + const [scrollOffset, setScrollOffset] = useState(0); + const maxScroll = Math.max(0, lines.length - maxVisibleLines); + + useInput( + useCallback( + (_input: string, key: { upArrow?: boolean; downArrow?: boolean; escape?: boolean }) => { + if (key.escape) { + onClose(); + return; + } + if (key.upArrow) { + setScrollOffset((o) => Math.max(0, o - 1)); + return; + } + if (key.downArrow) { + setScrollOffset((o) => Math.min(maxScroll, o + 1)); + return; + } + }, + [maxScroll, onClose], + ), + ); + + const visibleLines = lines.slice(scrollOffset, scrollOffset + maxVisibleLines); + const hasMore = scrollOffset < maxScroll; + + return ( + + {"─".repeat(columns)} + + + {title} + + (↑↓ scroll, Esc close) + {maxScroll > 0 && ( + + {` [${scrollOffset + 1}-${Math.min(scrollOffset + maxVisibleLines, lines.length)}/${lines.length}]`} + + )} + + {"─".repeat(columns)} + + {visibleLines.map((line, i) => ( + {line || " "} + ))} + + {hasMore && ( + ↓ more... + )} + + ); +} diff --git a/src/kimi_cli_ts/ui/components/SlashMenu.tsx b/src/kimi_cli_ts/ui/components/SlashMenu.tsx new file mode 100644 index 000000000..9524b83b1 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/SlashMenu.tsx @@ -0,0 +1,90 @@ +/** + * SlashMenu.tsx — Slash command completion menu. + * Renders a list of matching commands when user types '/'. + * Corresponds to Python's SlashCommandCompletionMenu. + */ + +import React from "react"; +import { Box, Text, useStdout } from "ink"; +import type { SlashCommand } from "../../types.ts"; + +const DIM = "#888888"; +const HIGHLIGHT_BG = "#1e90ff"; + +interface SlashMenuProps { + /** All available commands */ + commands: SlashCommand[]; + /** Current filter text (what user typed after '/') */ + filter: string; + /** Currently selected index */ + selectedIndex: number; +} + +export function SlashMenu({ commands, filter, selectedIndex }: SlashMenuProps) { + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + // Fuzzy filter commands + const filtered = filterCommands(commands, filter); + + if (filtered.length === 0) return null; + + const separator = "─".repeat(columns); + + return ( + + {separator} + {filtered.map((cmd, i) => { + const isSelected = i === selectedIndex; + return ( + + + {isSelected ? "▸ " : " "} + + + /{cmd.name} + + + {" " + cmd.description} + + + ); + })} + + ); +} + +/** Fuzzy-filter commands by name or alias */ +function filterCommands( + commands: SlashCommand[], + filter: string, +): SlashCommand[] { + if (!filter) return commands; + + const lower = filter.toLowerCase(); + return commands.filter((cmd) => { + if (cmd.name.toLowerCase().includes(lower)) return true; + if (cmd.aliases) { + return cmd.aliases.some((a) => a.toLowerCase().includes(lower)); + } + return false; + }); +} + +/** Get filtered command count (used by parent to know menu size) */ +export function getFilteredCommandCount( + commands: SlashCommand[], + filter: string, +): number { + return filterCommands(commands, filter).length; +} + +/** Get the command at a given index after filtering */ +export function getFilteredCommand( + commands: SlashCommand[], + filter: string, + index: number, +): SlashCommand | undefined { + const filtered = filterCommands(commands, filter); + return filtered[index]; +} diff --git a/src/kimi_cli_ts/ui/components/Spinner.tsx b/src/kimi_cli_ts/ui/components/Spinner.tsx new file mode 100644 index 000000000..edc77c274 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/Spinner.tsx @@ -0,0 +1,54 @@ +/** + * Spinner component — loading indicators. + * Uses ink-spinner for animated spinners. + */ + +import React from "react"; +import { Box, Text } from "ink"; +import InkSpinner from "ink-spinner"; +import { getMessageColors } from "../theme"; + +interface SpinnerProps { + /** Text to display next to the spinner */ + label?: string; + /** Spinner color */ + color?: string; +} + +export function Spinner({ label = "Thinking...", color }: SpinnerProps) { + const colors = getMessageColors(); + const spinnerColor = color || colors.highlight; + + return ( + + + + + {label && ( + {label} + )} + + ); +} + +interface CompactionSpinnerProps { + /** Whether compaction is in progress */ + active: boolean; +} + +export function CompactionSpinner({ active }: CompactionSpinnerProps) { + if (!active) return null; + return ; +} + +interface StreamingSpinnerProps { + stepCount: number; +} + +export function StreamingSpinner({ stepCount }: StreamingSpinnerProps) { + return ( + 0 ? `Thinking... (step ${stepCount})` : "Thinking..."} + /> + ); +} diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx new file mode 100644 index 000000000..cb742dd25 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -0,0 +1,110 @@ +/** + * StatusBar component — bottom status bar. + * Matches Python's toolbar: separator line + single status line. + * + * Layout: + * ────────────────────────────────────────────────────── + * agent (kimi-k2.5 ●) ~/workdir main context: 0.0% + */ + +import React from "react"; +import { Box, Text, useStdout } from "ink"; +import type { StatusUpdate } from "../../wire/types"; + +const DIM = "#888888"; + +interface StatusBarProps { + modelName?: string; + workDir?: string; + status: StatusUpdate | null; + isStreaming: boolean; + stepCount: number; + isCompacting?: boolean; + planMode?: boolean; + yolo?: boolean; + thinking?: boolean; +} + +export function StatusBar({ + modelName = "", + workDir, + status, + isStreaming, + stepCount, + isCompacting = false, + planMode = false, + yolo = false, + thinking = false, +}: StatusBarProps) { + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + // Context usage + const contextUsage = status?.context_usage; + const contextPercent = + contextUsage != null ? (contextUsage * 100).toFixed(1) : "0.0"; + + // Shorten workDir + const home = process.env.HOME || process.env.USERPROFILE || ""; + const displayDir = workDir + ? workDir.startsWith(home) + ? "~" + workDir.slice(home.length) + : workDir + : ""; + + // Build left section: [yolo] [plan] agent (model ●) + const leftParts: string[] = []; + if (yolo) leftParts.push("yolo"); + if (planMode) leftParts.push("plan"); + + const thinkingDot = thinking ? "●" : "○"; + const modeStr = modelName + ? `agent (${modelName} ${thinkingDot})` + : "agent"; + leftParts.push(modeStr); + const leftText = leftParts.join(" "); + + // Build right section + const rightText = `context: ${contextPercent}%`; + + // Separator + const separator = "─".repeat(columns); + + return ( + + {separator} + + + {yolo && ( + + yolo + + )} + {planMode && ( + + plan + + )} + {modeStr} + {displayDir && {displayDir}} + {isStreaming && ( + step {stepCount} + )} + {isCompacting && compacting...} + + + + shift-tab: plan mode | ctrl-o: editor + + {rightText} + + + + ); +} + +function formatTokenCount(count: number): string { + if (count < 1000) return String(count); + if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`; + return `${(count / 1_000_000).toFixed(1)}M`; +} diff --git a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx new file mode 100644 index 000000000..fa2797c8d --- /dev/null +++ b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx @@ -0,0 +1,91 @@ +/** + * WelcomeBox.tsx — Welcome panel displayed on first render. + * Matches Python's welcome box layout with logo, directory, session, model info. + */ + +import React from "react"; +import { Box, Text } from "ink"; + +const KIMI_BLUE = "#1e90ff"; + +interface WelcomeBoxProps { + workDir?: string; + sessionId?: string; + modelName?: string; + tip?: string; +} + +export function WelcomeBox({ + workDir, + sessionId, + modelName, + tip, +}: WelcomeBoxProps) { + // Shorten home directory + const home = process.env.HOME || process.env.USERPROFILE || ""; + const displayDir = workDir + ? workDir.startsWith(home) + ? "~" + workDir.slice(home.length) + : workDir + : "~"; + + return ( + + {/* Logo + Welcome */} + + + ▐█▛█▛█▌ + ▐█████▌ + + + Welcome to Kimi Code CLI! + Send /help for help information. + + + + {/* Blank line */} + + + {/* Directory */} + + Directory: + {displayDir} + + + {/* Session */} + {sessionId && ( + + Session: + {sessionId} + + )} + + {/* Model */} + + Model: + {modelName ? ( + {modelName} + ) : ( + not set, send /login to login + )} + + + {/* Tip */} + {tip && ( + <> + + + Tip: + {tip} + + + )} + + ); +} diff --git a/src/kimi_cli_ts/ui/hooks/index.ts b/src/kimi_cli_ts/ui/hooks/index.ts new file mode 100644 index 000000000..e6ee5b445 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/index.ts @@ -0,0 +1,6 @@ +export { useWire } from "./useWire"; +export type { WireState, UseWireOptions } from "./useWire"; +export { useApproval, createApprovalManager } from "./useApproval"; +export type { ApprovalState, UseApprovalOptions } from "./useApproval"; +export { useInputHistory } from "./useInput"; +export type { InputHistoryState } from "./useInput"; diff --git a/src/kimi_cli_ts/ui/hooks/useApproval.ts b/src/kimi_cli_ts/ui/hooks/useApproval.ts new file mode 100644 index 000000000..1c6aae74f --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useApproval.ts @@ -0,0 +1,66 @@ +/** + * useApproval hook — manages approval request state machine. + * Corresponds to approval handling in Python's visualize.py. + */ + +import { useState, useCallback } from "react"; +import type { ApprovalRequest, ApprovalResponseKind } from "../../wire/types"; + +export interface ApprovalState { + pending: ApprovalRequest | null; + respond: (decision: ApprovalResponseKind, feedback?: string) => void; + dismiss: () => void; +} + +export interface UseApprovalOptions { + onRespond?: ( + requestId: string, + decision: ApprovalResponseKind, + feedback?: string, + ) => void; +} + +/** + * Hook for managing approval request lifecycle. + */ +export function useApproval(options?: UseApprovalOptions): ApprovalState { + const [pending, setPending] = useState(null); + + const respond = useCallback( + (decision: ApprovalResponseKind, feedback?: string) => { + if (!pending) return; + options?.onRespond?.(pending.id, decision, feedback); + setPending(null); + }, + [pending, options], + ); + + const dismiss = useCallback(() => { + setPending(null); + }, []); + + return { pending, respond, dismiss }; +} + +/** + * Set the pending approval from external source (e.g., wire events). + * This is used by the Shell to inject approval requests into the hook. + */ +export function createApprovalManager(options?: UseApprovalOptions) { + let _pending: ApprovalRequest | null = null; + let _setPending: ((req: ApprovalRequest | null) => void) | null = null; + + return { + setPendingRef: (setter: (req: ApprovalRequest | null) => void) => { + _setPending = setter; + }, + enqueue: (request: ApprovalRequest) => { + _pending = request; + _setPending?.(request); + }, + clear: () => { + _pending = null; + _setPending?.(null); + }, + }; +} diff --git a/src/kimi_cli_ts/ui/hooks/useInput.ts b/src/kimi_cli_ts/ui/hooks/useInput.ts new file mode 100644 index 000000000..fc4c0623a --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useInput.ts @@ -0,0 +1,103 @@ +/** + * useInputHistory hook — manages input history and slash command parsing. + * Corresponds to history logic in Python's prompt.py. + */ + +import { useState, useCallback, useRef } from "react"; +import type { SlashCommand } from "../../types"; + +export interface InputHistoryState { + /** Current input value */ + value: string; + /** Set input value */ + setValue: (v: string) => void; + /** Navigate to previous history entry */ + historyPrev: () => void; + /** Navigate to next history entry */ + historyNext: () => void; + /** Add current value to history */ + addToHistory: (entry: string) => void; + /** Check if current input is a slash command */ + isSlashCommand: boolean; + /** Parse slash command name and args */ + parseSlashCommand: () => { name: string; args: string } | null; +} + +/** + * Hook for input history management and slash command parsing. + */ +export function useInputHistory(maxHistory = 100): InputHistoryState { + const [value, setValue] = useState(""); + const history = useRef([]); + const historyIndex = useRef(-1); + const savedInput = useRef(""); + + const addToHistory = useCallback( + (entry: string) => { + const trimmed = entry.trim(); + if (!trimmed) return; + // Deduplicate: remove if already exists at end + if ( + history.current.length > 0 && + history.current[history.current.length - 1] === trimmed + ) { + // Already the last entry + } else { + history.current.push(trimmed); + if (history.current.length > maxHistory) { + history.current.shift(); + } + } + historyIndex.current = -1; + savedInput.current = ""; + }, + [maxHistory], + ); + + const historyPrev = useCallback(() => { + if (history.current.length === 0) return; + if (historyIndex.current === -1) { + savedInput.current = value; + historyIndex.current = history.current.length - 1; + } else if (historyIndex.current > 0) { + historyIndex.current -= 1; + } + setValue(history.current[historyIndex.current] ?? ""); + }, [value]); + + const historyNext = useCallback(() => { + if (historyIndex.current === -1) return; + if (historyIndex.current < history.current.length - 1) { + historyIndex.current += 1; + setValue(history.current[historyIndex.current] ?? ""); + } else { + historyIndex.current = -1; + setValue(savedInput.current); + } + }, []); + + const isSlashCommand = value.startsWith("/"); + + const parseSlashCommand = useCallback(() => { + if (!value.startsWith("/")) return null; + const trimmed = value.slice(1).trim(); + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) { + return { name: trimmed, args: "" }; + } + return { + name: trimmed.slice(0, spaceIdx), + args: trimmed.slice(spaceIdx + 1).trim(), + }; + }, [value]); + + return { + value, + setValue, + historyPrev, + historyNext, + addToHistory, + isSlashCommand, + parseSlashCommand, + }; +} diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts new file mode 100644 index 000000000..74147c719 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -0,0 +1,238 @@ +/** + * useWire hook — subscribes to Wire EventBus and accumulates renderable messages. + * Corresponds to the event-processing logic in Python's visualize.py. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import type { + UIMessage, + WireUIEvent, + TextSegment, + ThinkSegment, + ToolCallSegment, +} from "../shell/events"; +import type { StatusUpdate, ApprovalRequest } from "../../wire/types"; +import { nanoid } from "nanoid"; + +export interface WireState { + messages: UIMessage[]; + isStreaming: boolean; + pendingApproval: ApprovalRequest | null; + status: StatusUpdate | null; + stepCount: number; + isCompacting: boolean; +} + +export interface UseWireOptions { + /** External event source — call pushEvent to feed events */ + onReady?: (pushEvent: (event: WireUIEvent) => void) => void; +} + +/** + * Hook that accumulates wire events into a renderable message list. + */ +export function useWire(options?: UseWireOptions): WireState & { + pushEvent: (event: WireUIEvent) => void; + clearMessages: () => void; +} { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [pendingApproval, setPendingApproval] = + useState(null); + const [status, setStatus] = useState(null); + const [stepCount, setStepCount] = useState(0); + const [isCompacting, setIsCompacting] = useState(false); + + // Use ref for current assistant message being built + const currentAssistantRef = useRef(null); + + const pushEvent = useCallback((event: WireUIEvent) => { + switch (event.type) { + case "turn_begin": { + // Add user message + const userMsg: UIMessage = { + id: nanoid(), + role: "user", + segments: [{ type: "text", text: event.userInput }], + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, userMsg]); + setIsStreaming(true); + setStepCount(0); + // Start new assistant message + const assistantMsg: UIMessage = { + id: nanoid(), + role: "assistant", + segments: [], + timestamp: Date.now(), + }; + currentAssistantRef.current = assistantMsg; + setMessages((prev) => [...prev, assistantMsg]); + break; + } + + case "turn_end": { + currentAssistantRef.current = null; + setIsStreaming(false); + break; + } + + case "step_begin": { + setStepCount(event.n); + break; + } + + case "step_interrupted": { + setIsStreaming(false); + break; + } + + case "text_delta": { + if (!currentAssistantRef.current) break; + const msg = currentAssistantRef.current; + const lastSeg = msg.segments[msg.segments.length - 1]; + if (lastSeg && lastSeg.type === "text") { + (lastSeg as TextSegment).text += event.text; + } else { + msg.segments.push({ type: "text", text: event.text }); + } + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === msg.id); + if (idx === -1) return prev; + return [...prev.slice(0, idx), { ...msg }, ...prev.slice(idx + 1)]; + }); + break; + } + + case "think_delta": { + if (!currentAssistantRef.current) break; + const msg = currentAssistantRef.current; + const lastSeg = msg.segments[msg.segments.length - 1]; + if (lastSeg && lastSeg.type === "think") { + (lastSeg as ThinkSegment).text += event.text; + } else { + msg.segments.push({ type: "think", text: event.text }); + } + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === msg.id); + if (idx === -1) return prev; + return [...prev.slice(0, idx), { ...msg }, ...prev.slice(idx + 1)]; + }); + break; + } + + case "tool_call": { + if (!currentAssistantRef.current) break; + const msg = currentAssistantRef.current; + msg.segments.push({ + type: "tool_call", + id: event.id, + name: event.name, + arguments: event.arguments, + collapsed: false, + }); + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === msg.id); + if (idx === -1) return prev; + return [...prev.slice(0, idx), { ...msg }, ...prev.slice(idx + 1)]; + }); + break; + } + + case "tool_result": { + if (!currentAssistantRef.current) break; + const msg = currentAssistantRef.current; + const toolSeg = msg.segments.find( + (s) => + s.type === "tool_call" && + (s as ToolCallSegment).id === event.toolCallId, + ) as ToolCallSegment | undefined; + if (toolSeg) { + toolSeg.result = event.result; + toolSeg.collapsed = true; + } + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === msg.id); + if (idx === -1) return prev; + return [...prev.slice(0, idx), { ...msg }, ...prev.slice(idx + 1)]; + }); + break; + } + + case "approval_request": { + setPendingApproval(event.request); + break; + } + + case "approval_response": { + setPendingApproval(null); + break; + } + + case "status_update": { + setStatus(event.status); + break; + } + + case "compaction_begin": { + setIsCompacting(true); + break; + } + + case "compaction_end": { + setIsCompacting(false); + break; + } + + case "notification": { + const sysMsg: UIMessage = { + id: nanoid(), + role: "system", + segments: [ + { type: "text", text: `${event.title}: ${event.body}` }, + ], + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, sysMsg]); + break; + } + + case "error": { + const errMsg: UIMessage = { + id: nanoid(), + role: "system", + segments: [{ type: "text", text: `Error: ${event.message}` }], + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, errMsg]); + setIsStreaming(false); + break; + } + } + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + currentAssistantRef.current = null; + setIsStreaming(false); + setPendingApproval(null); + setStepCount(0); + }, []); + + // Notify caller that pushEvent is ready + const onReady = options?.onReady; + useEffect(() => { + onReady?.(pushEvent); + }, [pushEvent, onReady]); + + return { + messages, + isStreaming, + pendingApproval, + status, + stepCount, + isCompacting, + pushEvent, + clearMessages, + }; +} diff --git a/src/kimi_cli_ts/ui/print/index.ts b/src/kimi_cli_ts/ui/print/index.ts new file mode 100644 index 000000000..26018ef15 --- /dev/null +++ b/src/kimi_cli_ts/ui/print/index.ts @@ -0,0 +1,464 @@ +/** + * Print mode — non-interactive output. + * Corresponds to Python's ui/print/__init__.py + ui/print/visualize.py. + * + * Provides multiple printer strategies: + * - TextPrinter: prints wire events as rich text + * - JsonPrinter: outputs JSON messages with content merging + * - FinalOnlyTextPrinter: only prints the final assistant text + * - FinalOnlyJsonPrinter: only prints the final assistant message as JSON + * - PrintMode: legacy event-based printer (wraps the above) + */ + +import chalk from "chalk"; +import type { WireUIEvent } from "../shell/events"; + +export type OutputFormat = "text" | "stream-json"; + +export interface PrintOptions { + outputFormat: OutputFormat; + finalOnly: boolean; +} + +// ── Printer Protocol ──────────────────────────────────── + +export interface Printer { + feed(event: WireUIEvent): void; + flush(): void; +} + +// ── Content Part Merging ──────────────────────────────── + +interface ContentBuffer { + type: "text" | "think"; + text: string; +} + +function mergeContent(buffer: ContentBuffer[], part: ContentBuffer): void { + const last = buffer[buffer.length - 1]; + if (last && last.type === part.type) { + last.text += part.text; + } else { + buffer.push({ ...part }); + } +} + +// ── TextPrinter ───────────────────────────────────────── + +export class TextPrinter implements Printer { + feed(event: WireUIEvent): void { + switch (event.type) { + case "text_delta": + process.stdout.write(event.text); + break; + case "think_delta": + process.stdout.write(chalk.italic.grey(event.text)); + break; + case "tool_call": + process.stderr.write( + chalk.dim(`[tool] ${event.name}(${truncateStr(event.arguments, 60)})\n`), + ); + break; + case "tool_call_delta": + // Streaming tool call args — ignore in text mode + break; + case "plan_display": + process.stdout.write(chalk.blue.bold("📋 Plan") + chalk.grey(` (${(event as any).filePath})`) + "\n"); + process.stdout.write((event as any).content + "\n"); + break; + case "tool_result": + if (event.result.return_value.isError) { + process.stderr.write( + chalk.red(`[error] ${truncateStr(event.result.return_value.output, 100)}\n`), + ); + } + break; + case "step_begin": + break; + case "step_interrupted": + process.stderr.write(chalk.yellow("[interrupted]\n")); + break; + case "error": + process.stderr.write(chalk.red(`Error: ${event.message}\n`)); + break; + case "notification": { + const sev = (event as any).severity; + const prefix = sev === "error" ? chalk.red("[error]") : sev === "warning" ? chalk.yellow("[warn]") : chalk.dim(`[${event.title}]`); + process.stderr.write(`${prefix} ${event.body}\n`); + break; + } + case "turn_end": + process.stdout.write("\n"); + break; + } + } + + flush(): void {} +} + +// ── JsonPrinter ───────────────────────────────────────── + +export class JsonPrinter implements Printer { + private contentBuffer: ContentBuffer[] = []; + private toolCalls: Array<{ id: string; name: string; arguments: string }> = []; + private pendingNotifications: Array<{ title: string; body: string }> = []; + + feed(event: WireUIEvent): void { + switch (event.type) { + case "step_begin": + case "step_interrupted": + this.flush(); + break; + case "notification": + if (this.contentBuffer.length > 0 || this.toolCalls.length > 0) { + this.pendingNotifications.push({ title: event.title, body: event.body }); + } else { + this.flushAssistantMessage(); + this.flushNotifications(); + this.emitJson({ type: "notification", title: event.title, body: event.body }); + } + break; + case "text_delta": + mergeContent(this.contentBuffer, { type: "text", text: event.text }); + break; + case "think_delta": + mergeContent(this.contentBuffer, { type: "think", text: event.text }); + break; + case "tool_call": + this.toolCalls.push({ id: event.id, name: event.name, arguments: event.arguments }); + break; + case "tool_result": + this.flushAssistantMessage(); + this.flushNotifications(); + this.emitJson({ + role: "tool", + tool_call_id: event.toolCallId, + content: event.result.return_value.output, + is_error: event.result.return_value.isError, + }); + break; + case "plan_display": + this.flushAssistantMessage(); + this.flushNotifications(); + this.emitJson({ + type: "plan_display", + content: (event as any).content, + file_path: (event as any).filePath, + }); + break; + case "error": + process.stderr.write(chalk.red(`Error: ${event.message}\n`)); + break; + } + } + + private flushAssistantMessage(): void { + if (this.contentBuffer.length === 0 && this.toolCalls.length === 0) return; + const content = this.contentBuffer.map((part) => ({ + type: part.type, + [part.type === "think" ? "think" : "text"]: part.text, + })); + const msg: Record = { role: "assistant", content }; + if (this.toolCalls.length > 0) { + msg.tool_calls = this.toolCalls.map((tc) => ({ + id: tc.id, + type: "function", + function: { name: tc.name, arguments: tc.arguments }, + })); + } + this.emitJson(msg); + this.contentBuffer = []; + this.toolCalls = []; + } + + private flushNotifications(): void { + for (const n of this.pendingNotifications) { + this.emitJson({ type: "notification", ...n }); + } + this.pendingNotifications = []; + } + + private emitJson(data: Record): void { + process.stdout.write(JSON.stringify(data) + "\n"); + } + + flush(): void { + this.flushAssistantMessage(); + this.flushNotifications(); + } +} + +// ── FinalOnlyTextPrinter ──────────────────────────────── + +export class FinalOnlyTextPrinter implements Printer { + private contentBuffer: ContentBuffer[] = []; + + feed(event: WireUIEvent): void { + switch (event.type) { + case "step_begin": + case "step_interrupted": + this.contentBuffer = []; + break; + case "text_delta": + mergeContent(this.contentBuffer, { type: "text", text: event.text }); + break; + case "error": + process.stderr.write(chalk.red(`Error: ${event.message}\n`)); + break; + } + } + + flush(): void { + const text = this.contentBuffer + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + if (text) { + process.stdout.write(text + "\n"); + } + this.contentBuffer = []; + } +} + +// ── FinalOnlyJsonPrinter ──────────────────────────────── + +export class FinalOnlyJsonPrinter implements Printer { + private contentBuffer: ContentBuffer[] = []; + + feed(event: WireUIEvent): void { + switch (event.type) { + case "step_begin": + case "step_interrupted": + this.contentBuffer = []; + break; + case "text_delta": + mergeContent(this.contentBuffer, { type: "text", text: event.text }); + break; + case "error": + process.stderr.write(chalk.red(`Error: ${event.message}\n`)); + break; + } + } + + flush(): void { + const text = this.contentBuffer + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + if (text) { + process.stdout.write( + JSON.stringify({ + role: "assistant", + content: [{ type: "text", text }], + }) + "\n", + ); + } + this.contentBuffer = []; + } +} + +// ── StreamJsonPrinter ────────────────────────────────── + +export class StreamJsonPrinter implements Printer { + feed(event: WireUIEvent): void { + switch (event.type) { + case "text_delta": + case "think_delta": + case "tool_call": + case "tool_result": + case "notification": + case "step_begin": + case "step_interrupted": + case "turn_end": + this.emitJson(event as unknown as Record); + break; + case "error": + process.stderr.write(chalk.red(`Error: ${(event as any).message}\n`)); + break; + } + } + + private emitJson(data: Record): void { + process.stdout.write(JSON.stringify(data) + "\n"); + } + + flush(): void {} +} + +// ── FinalOnlyStreamJsonPrinter ──────────────────────── + +export class FinalOnlyStreamJsonPrinter implements Printer { + private textBuffer = ""; + + feed(event: WireUIEvent): void { + switch (event.type) { + case "text_delta": + this.textBuffer += event.text; + break; + case "step_begin": + case "step_interrupted": + this.textBuffer = ""; + break; + case "error": + process.stderr.write(chalk.red(`Error: ${(event as any).message}\n`)); + break; + } + } + + flush(): void { + if (this.textBuffer) { + process.stdout.write(JSON.stringify({ type: "final_text", text: this.textBuffer }) + "\n"); + } + this.textBuffer = ""; + } +} + +// ── Factory ───────────────────────────────────────────── + +export function createPrinter(options: PrintOptions): Printer { + if (options.finalOnly) { + return options.outputFormat === "text" + ? new FinalOnlyTextPrinter() + : new FinalOnlyStreamJsonPrinter(); + } + return options.outputFormat === "text" + ? new TextPrinter() + : new StreamJsonPrinter(); +} + +// ── Legacy PrintMode (wraps Printer) ──────────────────── + +export class PrintMode { + private printer: Printer; + + constructor(options: PrintOptions) { + this.printer = createPrinter(options); + } + + handleEvent(event: WireUIEvent): void { + this.printer.feed(event); + if (event.type === "turn_end") { + this.printer.flush(); + } + } + + flush(): void { + this.printer.flush(); + } +} + +/** + * Classify error for exit codes. + */ +export function classifyError( + error: unknown, +): "retryable" | "permanent" | "unknown" { + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + if ( + msg.includes("429") || + msg.includes("500") || + msg.includes("502") || + msg.includes("503") || + msg.includes("504") || + msg.includes("timeout") || + msg.includes("connection") + ) { + return "retryable"; + } + return "permanent"; + } + return "unknown"; +} + +// ── Stream-JSON Input Parser ───────────────────────────── + +/** + * Parse a stream-json input line into a user command. + * Returns null if the line is invalid or non-user role. + * Corresponds to Python Print._read_next_command(). + */ +export function parseStreamJsonInput(jsonLine: string): string | null { + const trimmed = jsonLine.trim(); + if (!trimmed) return null; + + try { + const data = JSON.parse(trimmed); + if (!data || typeof data !== "object") return null; + + // Expect { role: "user", content: "..." } or { role: "user", content: [...] } + if (data.role !== "user") return null; + + if (typeof data.content === "string") { + return data.content; + } + + if (Array.isArray(data.content)) { + // Extract text parts and join + const texts: string[] = []; + for (const part of data.content) { + if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") { + texts.push(part.text); + } + } + return texts.length > 0 ? texts.join("\n") : null; + } + + return null; + } catch { + return null; + } +} + +/** + * Read stream-json lines from a ReadableStream, yielding user commands. + * Corresponds to the Python Print._read_next_command loop. + */ +export async function* readStreamJsonInput( + input: ReadableStream | AsyncIterable, +): AsyncGenerator { + let buffer = ""; + + if ("getReader" in input) { + const reader = (input as ReadableStream).getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + const command = parseStreamJsonInput(line); + if (command) yield command; + } + } + // Process remaining buffer + if (buffer.trim()) { + const command = parseStreamJsonInput(buffer); + if (command) yield command; + } + } finally { + reader.releaseLock(); + } + } else { + for await (const line of input as AsyncIterable) { + const command = parseStreamJsonInput(line); + if (command) yield command; + } + } +} + +// ── Exit Codes ────────────────────────────────────────────── + +export const ExitCode = { + SUCCESS: 0, + FAILURE: 1, + RETRYABLE: 2, +} as const; + +function truncateStr(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "…"; +} diff --git a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx new file mode 100644 index 000000000..39badd006 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx @@ -0,0 +1,328 @@ +/** + * ApprovalPanel.tsx — Full approval request panel with React Ink. + * Corresponds to Python's ui/shell/approval_panel.py. + * + * Features: + * - 4 options: approve once (y), approve for session (a), reject (n), reject with feedback (f) + * - Diff preview panel + * - Inline feedback input + * - Truncation with expand hint + * - Keyboard navigation (↑↓ or 1-4 number keys) + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import type { + ApprovalRequest, + ApprovalResponseKind, + DisplayBlock, + DiffDisplayBlock, + ShellDisplayBlock, + BriefDisplayBlock, +} from "../../wire/types"; + +const MAX_PREVIEW_LINES = 4; + +interface ApprovalOption { + label: string; + response: ApprovalResponseKind; +} + +const OPTIONS: ApprovalOption[] = [ + { label: "Approve once", response: "approve" }, + { label: "Approve for this session", response: "approve_for_session" }, + { label: "Reject", response: "reject" }, + { label: "Reject, tell the model what to do instead", response: "reject" }, +]; + +const FEEDBACK_OPTION_INDEX = 3; + +// ── DiffPreview ────────────────────────────────────────── + +function DiffPreview({ blocks }: { blocks: DisplayBlock[] }) { + const diffBlocks = blocks.filter( + (b): b is DiffDisplayBlock => b.type === "diff", + ); + if (diffBlocks.length === 0) return null; + + // Group by path + const byPath = new Map(); + for (const block of diffBlocks) { + const existing = byPath.get(block.path) || []; + existing.push(block); + byPath.set(block.path, existing); + } + + return ( + + {[...byPath.entries()].map(([path, diffs]) => ( + + + {path} + + {diffs.map((diff, idx) => ( + + {diff.old_text + .split("\n") + .slice(0, MAX_PREVIEW_LINES) + .map((line, lineIdx) => ( + + - {line} + + ))} + {diff.new_text + .split("\n") + .slice(0, MAX_PREVIEW_LINES) + .map((line, lineIdx) => ( + + + {line} + + ))} + + ))} + + ))} + + ); +} + +// ── ContentPreview ─────────────────────────────────────── + +function ContentPreview({ blocks }: { blocks: DisplayBlock[] }) { + let budget = MAX_PREVIEW_LINES; + let truncated = false; + const elements: React.ReactNode[] = []; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (budget <= 0) { + truncated = true; + break; + } + + if (!block) continue; + if (block.type === "shell") { + const shellBlock = block as ShellDisplayBlock; + const lines = shellBlock.command.trim().split("\n"); + const showLines = lines.slice(0, budget); + if (lines.length > budget) truncated = true; + budget -= showLines.length; + elements.push( + + {showLines.join("\n")} + , + ); + } else if (block.type === "brief") { + const briefBlock = block as BriefDisplayBlock; + const lines = briefBlock.brief.trim().split("\n"); + const showLines = lines.slice(0, budget); + if (lines.length > budget) truncated = true; + budget -= showLines.length; + elements.push( + + {showLines.join("\n")} + , + ); + } + } + + return ( + + {elements} + {truncated && ( + + ... (truncated, ctrl-e to expand) + + )} + + ); +} + +// ── ApprovalPanel ──────────────────────────────────────── + +export interface ApprovalPanelProps { + request: ApprovalRequest; + onRespond: ( + decision: ApprovalResponseKind, + feedback?: string, + ) => void; +} + +export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [feedbackMode, setFeedbackMode] = useState(false); + const [feedbackText, setFeedbackText] = useState(""); + + const isFeedbackSelected = selectedIndex === FEEDBACK_OPTION_INDEX; + + const submit = useCallback( + (index: number) => { + if (index === FEEDBACK_OPTION_INDEX) { + setFeedbackMode(true); + return; + } + onRespond(OPTIONS[index]!.response); + }, + [onRespond], + ); + + useInput((input, key) => { + if (feedbackMode) { + if (key.return) { + if (feedbackText.trim()) { + onRespond("reject", feedbackText.trim()); + } + return; + } + if (key.escape) { + onRespond("reject", ""); + return; + } + if (key.backspace || key.delete) { + setFeedbackText((t) => t.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setFeedbackText((t) => t + input); + } + return; + } + + // Normal navigation + if (key.upArrow) { + setSelectedIndex((i) => (i - 1 + OPTIONS.length) % OPTIONS.length); + } else if (key.downArrow) { + setSelectedIndex((i) => (i + 1) % OPTIONS.length); + } else if (key.return) { + submit(selectedIndex); + } else if (key.escape) { + onRespond("reject"); + } else if (input >= "1" && input <= "4") { + const idx = parseInt(input) - 1; + if (idx < OPTIONS.length) { + setSelectedIndex(idx); + if (idx !== FEEDBACK_OPTION_INDEX) { + submit(idx); + } else { + setFeedbackMode(true); + } + } + } + }); + + const hasDiff = request.display.some((b) => b.type === "diff"); + const hasContent = + hasDiff || + !!request.description || + request.display.some((b) => b.type === "shell" || b.type === "brief"); + + return ( + + {/* Title */} + + ⚠ ACTION REQUIRED + + + + {/* Request header */} + + + {request.sender} is requesting approval to {request.action}: + + + {/* Source metadata */} + {(request.subagent_type || request.agent_id) && ( + + Subagent:{" "} + {request.subagent_type && request.agent_id + ? `${request.subagent_type} (${request.agent_id})` + : request.subagent_type || request.agent_id} + + )} + {request.source_description && ( + Task: {request.source_description} + )} + + + + + {/* Description */} + {request.description && !request.display.length && ( + + {truncateLines(request.description, MAX_PREVIEW_LINES)} + + )} + + {/* Diff preview */} + {hasDiff && ( + + + + )} + + {/* Non-diff content preview */} + {request.display.some( + (b) => b.type === "shell" || b.type === "brief", + ) && ( + + + + )} + + + + {/* Options */} + {OPTIONS.map((option, i) => { + const num = i + 1; + const isSelected = i === selectedIndex; + const isFeedback = i === FEEDBACK_OPTION_INDEX; + + if (isFeedback && feedbackMode && isSelected) { + return ( + + → [{num}] Reject: {feedbackText}█ + + ); + } + + return ( + + {isSelected ? "→" : " "} [{num}] {option.label} + + ); + })} + + + + {/* Keyboard hints */} + {feedbackMode ? ( + + {" "}Type your feedback, then press Enter to submit. + + ) : ( + + {" "}▲/▼ select {" "}1/2/3/4 choose {" "}↵ confirm + {hasContent ? " ctrl-e expand" : ""} + + )} + + ); +} + +// ── Helpers ────────────────────────────────────────────── + +function truncateLines(text: string, maxLines: number): string { + const lines = text.split("\n"); + if (lines.length <= maxLines) return text; + return lines.slice(0, maxLines).join("\n") + "\n..."; +} + +export default ApprovalPanel; diff --git a/src/kimi_cli_ts/ui/shell/DebugPanel.tsx b/src/kimi_cli_ts/ui/shell/DebugPanel.tsx new file mode 100644 index 000000000..459f35c09 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/DebugPanel.tsx @@ -0,0 +1,183 @@ +/** + * DebugPanel.tsx — Context debug viewer. + * Corresponds to Python's ui/shell/debug.py. + * + * Features: + * - Display full context (all messages with role colors) + * - Token count + * - Checkpoint information + */ + +import React from "react"; +import { Box, Text } from "ink"; + +// ── Types ─────────────────────────────────────────────── + +export interface ContextInfo { + totalMessages: number; + tokenCount: number; + checkpoints: number; + trajectory?: string; +} + +export interface DebugMessage { + role: string; + content: string; + name?: string; + toolCallId?: string; + toolCalls?: Array<{ + id: string; + name: string; + arguments: string; + }>; + partial?: boolean; +} + +export interface DebugPanelProps { + context: ContextInfo; + messages: DebugMessage[]; +} + +// ── Role colors ───────────────────────────────────────── + +const ROLE_COLORS: Record = { + system: "magenta", + developer: "magenta", + user: "green", + assistant: "blue", + tool: "yellow", +}; + +function getRoleColor(role: string): string { + return ROLE_COLORS[role] || "white"; +} + +// ── ContentPart formatting ────────────────────────────── + +function formatContent(content: string): React.ReactNode { + const trimmed = content.trim(); + if (trimmed.startsWith("") && trimmed.endsWith("")) { + const inner = trimmed.slice(8, -9).trim(); + return ( + + system + {inner} + + ); + } + return {content}; +} + +// ── ToolCall formatting ───────────────────────────────── + +function ToolCallDebugView({ + toolCall, +}: { + toolCall: { id: string; name: string; arguments: string }; +}) { + let argsFormatted: string; + try { + argsFormatted = JSON.stringify(JSON.parse(toolCall.arguments), null, 2); + } catch { + argsFormatted = toolCall.arguments; + } + + return ( + + Tool Call + Function: {toolCall.name} + Call ID: {toolCall.id} + Arguments: + {argsFormatted} + + ); +} + +// ── Message formatting ────────────────────────────────── + +function MessageDebugView({ + msg, + index, +}: { + msg: DebugMessage; + index: number; +}) { + const roleColor = getRoleColor(msg.role); + let title = `#${index + 1} ${msg.role.toUpperCase()}`; + if (msg.name) title += ` (${msg.name})`; + if (msg.toolCallId) title += ` → ${msg.toolCallId}`; + if (msg.partial) title += " (partial)"; + + return ( + + {title} + {msg.content ? ( + formatContent(msg.content) + ) : ( + [empty message] + )} + {msg.toolCalls?.map((tc) => ( + + ))} + + ); +} + +// ── DebugPanel ────────────────────────────────────────── + +export function DebugPanel({ context, messages }: DebugPanelProps) { + if (messages.length === 0) { + return ( + + Context is empty - no messages yet + + ); + } + + return ( + + {/* Context info */} + + Context Info + Total messages: {context.totalMessages} + Token count: {context.tokenCount.toLocaleString()} + Checkpoints: {context.checkpoints} + {context.trajectory && ( + Trajectory: {context.trajectory} + )} + + + {/* Separator */} + {"─".repeat(60)} + + {/* All messages */} + {messages.map((msg, idx) => ( + + ))} + + ); +} + +export default DebugPanel; diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx new file mode 100644 index 000000000..9c2ed9660 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -0,0 +1,183 @@ +/** + * Prompt.tsx — Input prompt component with slash command completion. + * Uses ✨ sparkles emoji matching Python version. + * Slash menu renders BELOW the input (pushes up from bottom). + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import TextInput from "ink-text-input"; +import { useInputHistory } from "../hooks/useInput.ts"; +import { + SlashMenu, + getFilteredCommandCount, + getFilteredCommand, +} from "../components/SlashMenu.tsx"; +import type { SlashCommand } from "../../types.ts"; + +interface PromptProps { + onSubmit: (input: string) => void; + onOpenPanel?: (cmd: SlashCommand) => void; + disabled?: boolean; + placeholder?: string; + isStreaming?: boolean; + commands?: SlashCommand[]; + onSlashMenuChange?: (visible: boolean) => void; + /** Incremented by parent to signal "clear the input box" */ + clearSignal?: number; + /** One-shot prefill text for the input (e.g. from /undo) */ + prefillText?: string; +} + +export function Prompt({ + onSubmit, + onOpenPanel, + disabled = false, + placeholder = "Send a message... (/ for commands)", + isStreaming = false, + commands = [], + onSlashMenuChange, + clearSignal = 0, + prefillText, +}: PromptProps) { + const { value, setValue, historyPrev, historyNext, addToHistory } = + useInputHistory(); + + const [slashMenuIndex, setSlashMenuIndex] = useState(0); + + // React to clearSignal from parent (double-Esc) + React.useEffect(() => { + if (clearSignal > 0) { + setValue(""); + } + }, [clearSignal, setValue]); + + // Consume one-shot prefill text + React.useEffect(() => { + if (prefillText) { + setValue(prefillText); + } + }, [prefillText, setValue]); + + // Detect slash completion mode + const isSlashMode = + value.startsWith("/") && !value.includes(" ") && commands.length > 0; + const slashFilter = isSlashMode ? value.slice(1) : ""; + const menuCount = isSlashMode + ? getFilteredCommandCount(commands, slashFilter) + : 0; + const showSlashMenu = isSlashMode && menuCount > 0; + + // Notify parent about slash menu visibility + React.useEffect(() => { + onSlashMenuChange?.(showSlashMenu); + }, [showSlashMenu, onSlashMenuChange]); + + // Reset menu index when filter changes + React.useEffect(() => { + setSlashMenuIndex(0); + }, [slashFilter]); + + const handleChange = useCallback( + (newValue: string) => { + setValue(newValue); + }, + [setValue], + ); + + const handleSubmit = useCallback( + (input: string) => { + if (showSlashMenu) { + const selected = getFilteredCommand( + commands, + slashFilter, + slashMenuIndex, + ); + if (selected) { + const cmd = `/${selected.name}`; + addToHistory(cmd); + setValue(""); + // If the command has a panel, open it instead of submitting + if (selected.panel && onOpenPanel) { + onOpenPanel(selected); + return; + } + onSubmit(cmd); + return; + } + } + + const trimmed = input.trim(); + if (!trimmed) return; + addToHistory(trimmed); + setValue(""); + onSubmit(trimmed); + }, + [ + onSubmit, + onOpenPanel, + addToHistory, + setValue, + showSlashMenu, + commands, + slashFilter, + slashMenuIndex, + ], + ); + + // Handle up/down/tab for navigation + useInput( + (_input, key) => { + if (showSlashMenu) { + if (key.upArrow) { + setSlashMenuIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow) { + setSlashMenuIndex((i) => Math.min(menuCount - 1, i + 1)); + } else if (key.tab && !key.shift) { + const selected = getFilteredCommand( + commands, + slashFilter, + slashMenuIndex, + ); + if (selected) { + setValue(`/${selected.name} `); + } + } + } else { + if (key.upArrow) historyPrev(); + else if (key.downArrow) historyNext(); + } + }, + { isActive: !disabled }, + ); + + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + return ( + + {/* Separator line above input */} + {"─".repeat(columns)} + + {/* Input line — always rendered, always on top */} + + {isStreaming ? "🔄 " : "✨ "} + + + + {/* Slash command menu — renders below input, pushes up from bottom */} + {showSlashMenu && ( + + )} + + ); +} diff --git a/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx new file mode 100644 index 000000000..e517fdab9 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx @@ -0,0 +1,406 @@ +/** + * QuestionPanel.tsx — Interactive question panel with React Ink. + * Corresponds to Python's ui/shell/question_panel.py. + * + * Features: + * - Multi-question tabs (◀/▶ to switch) + * - Number key selection (1-6) + * - Multi-select with space toggle + * - "Other" free text input + * - Body content area + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import type { QuestionRequest, QuestionItem, QuestionOption } from "../../wire/types"; + +const OTHER_OPTION_LABEL = "Other"; + +interface OptionEntry { + label: string; + description: string; +} + +export interface QuestionPanelProps { + request: QuestionRequest; + onAnswer: (answers: Record) => void; + onCancel: () => void; +} + +export function QuestionPanel({ + request, + onAnswer, + onCancel, +}: QuestionPanelProps) { + const [questionIndex, setQuestionIndex] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); + const [multiSelected, setMultiSelected] = useState>(new Set()); + const [answers, setAnswers] = useState>({}); + const [otherMode, setOtherMode] = useState(false); + const [otherText, setOtherText] = useState(""); + const [otherDrafts, setOtherDrafts] = useState>({}); + + const question: QuestionItem = request.questions[questionIndex]!; + const options: OptionEntry[] = [ + ...question.options.map((o) => ({ + label: o.label, + description: o.description, + })), + { + label: question.other_label || OTHER_OPTION_LABEL, + description: question.other_description || "", + }, + ]; + const isMultiSelect = question.multi_select; + const isOtherSelected = selectedIndex === options.length - 1; + const otherIdx = options.length - 1; + + const advance = useCallback(() => { + const newAnswers = { ...answers }; + // Find next unanswered + const total = request.questions.length; + if (Object.keys(newAnswers).length >= total) { + onAnswer(newAnswers); + return true; + } + for (let offset = 1; offset <= total; offset++) { + const idx = (questionIndex + offset) % total; + if (!(request.questions[idx]!.question in newAnswers)) { + setQuestionIndex(idx); + setSelectedIndex(0); + setMultiSelected(new Set()); + setOtherMode(false); + setOtherText(""); + return false; + } + } + onAnswer(newAnswers); + return true; + }, [answers, questionIndex, request.questions, onAnswer]); + + const submitCurrent = useCallback(() => { + if (isMultiSelect) { + if (otherIdx in [...multiSelected] && multiSelected.has(otherIdx)) { + // Need other input first + setOtherMode(true); + return; + } + const selected = [...multiSelected] + .filter((i) => i < question.options.length) + .sort() + .map((i) => options[i]!.label); + if (selected.length === 0) return; + const newAnswers = { ...answers, [question.question]: selected.join(", ") }; + setAnswers(newAnswers); + // Check if all answered + if (Object.keys(newAnswers).length >= request.questions.length) { + onAnswer(newAnswers); + } else { + advance(); + } + } else { + if (isOtherSelected) { + setOtherMode(true); + return; + } + const newAnswers = { + ...answers, + [question.question]: options[selectedIndex]!.label, + }; + setAnswers(newAnswers); + if (Object.keys(newAnswers).length >= request.questions.length) { + onAnswer(newAnswers); + } else { + // Find next unanswered + const total = request.questions.length; + for (let offset = 1; offset <= total; offset++) { + const idx = (questionIndex + offset) % total; + if (!(request.questions[idx]!.question in newAnswers)) { + setQuestionIndex(idx); + setSelectedIndex(0); + setMultiSelected(new Set()); + return; + } + } + onAnswer(newAnswers); + } + } + }, [ + isMultiSelect, + multiSelected, + otherIdx, + question, + options, + selectedIndex, + isOtherSelected, + answers, + request.questions, + questionIndex, + onAnswer, + advance, + ]); + + const submitOther = useCallback( + (text: string) => { + let newAnswers: Record; + if (isMultiSelect) { + const selected = [...multiSelected] + .filter((i) => i < question.options.length && i !== otherIdx) + .sort() + .map((i) => options[i]!.label); + if (text) selected.push(text); + newAnswers = { + ...answers, + [question.question]: selected.join(", ") || text, + }; + } else { + newAnswers = { ...answers, [question.question]: text }; + } + setAnswers(newAnswers); + setOtherMode(false); + setOtherText(""); + if (Object.keys(newAnswers).length >= request.questions.length) { + onAnswer(newAnswers); + } else { + const total = request.questions.length; + for (let offset = 1; offset <= total; offset++) { + const idx = (questionIndex + offset) % total; + if (!(request.questions[idx]!.question in newAnswers)) { + setQuestionIndex(idx); + setSelectedIndex(0); + setMultiSelected(new Set()); + return; + } + } + onAnswer(newAnswers); + } + }, + [ + isMultiSelect, + multiSelected, + question, + otherIdx, + options, + answers, + request.questions, + questionIndex, + onAnswer, + ], + ); + + useInput((input, key) => { + // Other text input mode + if (otherMode) { + if (key.return) { + submitOther(otherText.trim()); + return; + } + if (key.escape) { + setOtherMode(false); + setOtherText(""); + onCancel(); + return; + } + if (key.backspace || key.delete) { + setOtherText((t) => t.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setOtherText((t) => t + input); + } + return; + } + + // Navigation + if (key.upArrow) { + setSelectedIndex((i) => (i - 1 + options.length) % options.length); + } else if (key.downArrow) { + setSelectedIndex((i) => (i + 1) % options.length); + } else if (key.leftArrow) { + // Previous tab + if (questionIndex > 0) { + setQuestionIndex(questionIndex - 1); + setSelectedIndex(0); + setMultiSelected(new Set()); + } + } else if (key.rightArrow || key.tab) { + // Next tab + if (questionIndex < request.questions.length - 1) { + setQuestionIndex(questionIndex + 1); + setSelectedIndex(0); + setMultiSelected(new Set()); + } + } else if (input === " " && isMultiSelect) { + // Toggle multi-select + setMultiSelected((prev) => { + const next = new Set(prev); + if (next.has(selectedIndex)) { + next.delete(selectedIndex); + } else { + next.add(selectedIndex); + } + return next; + }); + } else if (key.return) { + submitCurrent(); + } else if (key.escape) { + onCancel(); + } else if (input >= "1" && input <= "6") { + const idx = parseInt(input) - 1; + if (idx < options.length) { + setSelectedIndex(idx); + if (isMultiSelect) { + setMultiSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) { + next.delete(idx); + } else { + next.add(idx); + } + return next; + }); + } else if (idx !== otherIdx) { + // Direct submit for non-other + const newAnswers = { + ...answers, + [question.question]: options[idx]!.label, + }; + setAnswers(newAnswers); + if (Object.keys(newAnswers).length >= request.questions.length) { + onAnswer(newAnswers); + } else { + const total = request.questions.length; + for (let offset = 1; offset <= total; offset++) { + const nextIdx = (questionIndex + offset) % total; + if ( + !(request.questions[nextIdx]!.question in newAnswers) + ) { + setQuestionIndex(nextIdx); + setSelectedIndex(0); + setMultiSelected(new Set()); + return; + } + } + onAnswer(newAnswers); + } + } else { + setOtherMode(true); + } + } + } + }); + + return ( + + {/* Title */} + + ? QUESTION + + + + {/* Tabs for multi-question */} + {request.questions.length > 1 && ( + <> + + {request.questions.map((q, i) => { + const label = q.header || `Q${i + 1}`; + const isActive = i === questionIndex; + const isAnswered = q.question in answers; + const icon = isActive ? "●" : isAnswered ? "✓" : "○"; + const color = isActive ? "cyan" : isAnswered ? "green" : "grey"; + return ( + + ({icon}) {label} + + ); + })} + + + + )} + + {/* Question text */} + ? {question.question} + {isMultiSelect && ( + + {" "}(SPACE to toggle, ENTER to submit) + + )} + + + {/* Body hint */} + {question.body && ( + <> + + {" "}▶ Press ctrl-e to view full content + + + + )} + + {/* Options */} + {options.map((option, i) => { + const num = i + 1; + const isSelected = i === selectedIndex; + const isOther = i === otherIdx; + + if (isMultiSelect) { + const checked = multiSelected.has(i) ? "✓" : " "; + return ( + + + [{checked}] {option.label} + + {option.description && !isSelected && ( + {" "}{option.description} + )} + + ); + } + + if (isOther && otherMode && isSelected) { + return ( + + → [{num}] {option.label}: {otherText}█ + + ); + } + + return ( + + + {isSelected ? "→" : " "} [{num}] {option.label} + + {option.description && !(isOther && otherMode) && ( + {" "}{option.description} + )} + + ); + })} + + {/* Hints */} + + {otherMode ? ( + + {" "}Type your answer, then press Enter to submit. + + ) : request.questions.length > 1 ? ( + + {" "}◄/► switch question {" "}▲/▼ select {" "}↵ submit {" "}esc + exit + + ) : ( + + {" "}▲/▼ select {" "}↵ submit {" "}esc exit + + )} + + ); +} + +export default QuestionPanel; diff --git a/src/kimi_cli_ts/ui/shell/ReplayPanel.tsx b/src/kimi_cli_ts/ui/shell/ReplayPanel.tsx new file mode 100644 index 000000000..732226551 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/ReplayPanel.tsx @@ -0,0 +1,219 @@ +/** + * ReplayPanel.tsx — Session reconnection replay. + * Corresponds to Python's ui/shell/replay.py. + * + * Features: + * - Replays the most recent turns when reconnecting to a session + * - Shows user messages and assistant responses + * - Renders tool calls and results + */ + +import React from "react"; +import { Box, Text } from "ink"; +import type { WireUIEvent } from "./events"; + +const MAX_REPLAY_TURNS = 5; + +// ── Types ─────────────────────────────────────────────── + +export interface ReplayTurn { + userInput: string; + events: ReplayEvent[]; + stepCount: number; +} + +export interface ReplayEvent { + type: "text" | "think" | "tool_call" | "tool_result" | "step_begin" | "notification" | "plan_display"; + text?: string; + toolName?: string; + toolArgs?: string; + toolCallId?: string; + isError?: boolean; + title?: string; + body?: string; + content?: string; + filePath?: string; +} + +export interface ReplayPanelProps { + turns: ReplayTurn[]; +} + +// ── ReplayTurnView ────────────────────────────────────── + +function ReplayTurnView({ turn }: { turn: ReplayTurn }) { + return ( + + + You: + {turn.userInput} + + {turn.events.map((event, idx) => ( + + ))} + + ); +} + +// ── ReplayEventView ───────────────────────────────────── + +function ReplayEventView({ event }: { event: ReplayEvent }) { + switch (event.type) { + case "text": + return {event.text}; + case "think": + return ( + + 💭 {event.text} + + ); + case "tool_call": + return ( + + + {event.toolName} + {event.toolArgs && {truncate(event.toolArgs, 60)}} + + ); + case "tool_result": + return ( + + + {event.isError ? "✗" : "✓"}{" "} + + {event.text && {truncate(event.text, 100)}} + + ); + case "notification": + return ( + + + [{event.title}] {event.body} + + ); + case "plan_display": + return ( + + 📋 Plan + {event.content && {truncate(event.content, 200)}} + + ); + case "step_begin": + return null; + default: + return null; + } +} + +// ── ReplayPanel ───────────────────────────────────────── + +export function ReplayPanel({ turns }: ReplayPanelProps) { + if (turns.length === 0) return null; + + const recentTurns = turns.slice(-MAX_REPLAY_TURNS); + + return ( + + ─── Replaying recent history ─── + {recentTurns.map((turn, idx) => ( + + ))} + ─── End of replay ─── + + ); +} + +// ── Helpers ───────────────────────────────────────────── + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "…"; +} + +/** + * Build replay turns from wire events. + */ +export function buildReplayTurnsFromEvents(events: WireUIEvent[]): ReplayTurn[] { + const turns: ReplayTurn[] = []; + let currentTurn: ReplayTurn | null = null; + + for (const event of events) { + switch (event.type) { + case "turn_begin": + currentTurn = { userInput: event.userInput, events: [], stepCount: 0 }; + turns.push(currentTurn); + break; + case "step_begin": + if (currentTurn) { + currentTurn.stepCount = event.n; + currentTurn.events.push({ type: "step_begin" }); + } + break; + case "text_delta": + if (currentTurn) { + const last = currentTurn.events[currentTurn.events.length - 1]; + if (last && last.type === "text") { + last.text = (last.text || "") + event.text; + } else { + currentTurn.events.push({ type: "text", text: event.text }); + } + } + break; + case "think_delta": + if (currentTurn) { + const last = currentTurn.events[currentTurn.events.length - 1]; + if (last && last.type === "think") { + last.text = (last.text || "") + event.text; + } else { + currentTurn.events.push({ type: "think", text: event.text }); + } + } + break; + case "tool_call": + if (currentTurn) { + currentTurn.events.push({ + type: "tool_call", + toolName: event.name, + toolArgs: event.arguments, + toolCallId: event.id, + }); + } + break; + case "tool_result": + if (currentTurn) { + currentTurn.events.push({ + type: "tool_result", + toolCallId: event.toolCallId, + text: event.result.return_value.output, + isError: event.result.return_value.isError, + }); + } + break; + case "notification": + if (currentTurn) { + currentTurn.events.push({ + type: "notification", + title: event.title, + body: event.body, + }); + } + break; + case "plan_display": + if (currentTurn) { + currentTurn.events.push({ + type: "plan_display", + content: (event as any).content, + filePath: (event as any).filePath, + }); + } + break; + case "turn_end": + currentTurn = null; + break; + } + } + + return turns.slice(-MAX_REPLAY_TURNS); +} + +export default ReplayPanel; diff --git a/src/kimi_cli_ts/ui/shell/SetupWizard.tsx b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx new file mode 100644 index 000000000..d17aa2618 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx @@ -0,0 +1,258 @@ +/** + * SetupWizard.tsx — First-time setup wizard. + * Corresponds to Python's ui/shell/setup.py. + * + * Features: + * - Platform selection + * - API key input + * - Model selection + * - Thinking mode toggle + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; + +// ── Types ─────────────────────────────────────────────── + +export interface PlatformInfo { + id: string; + name: string; +} + +export interface ModelInfo { + id: string; + contextLength?: number; + capabilities?: string[]; +} + +export type SetupStep = + | "platform" + | "api_key" + | "verifying" + | "model" + | "thinking" + | "done" + | "error"; + +export interface SetupResult { + platformId: string; + platformName: string; + apiKey: string; + modelId: string; + thinking: boolean; +} + +export interface SetupWizardProps { + platforms: PlatformInfo[]; + onVerifyKey?: (platformId: string, apiKey: string) => Promise; + onComplete?: (result: SetupResult) => void; + onCancel?: () => void; +} + +// ── SetupWizard ───────────────────────────────────────── + +export function SetupWizard({ + platforms, + onVerifyKey, + onComplete, + onCancel, +}: SetupWizardProps) { + const [step, setStep] = useState("platform"); + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedPlatform, setSelectedPlatform] = useState(null); + const [apiKey, setApiKey] = useState(""); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(null); + const [thinking, setThinking] = useState(false); + const [error, setError] = useState(""); + + const finishSetup = useCallback( + (model: ModelInfo, thinkingMode: boolean) => { + setStep("done"); + setSelectedModel(model); + setThinking(thinkingMode); + onComplete?.({ + platformId: selectedPlatform!.id, + platformName: selectedPlatform!.name, + apiKey: apiKey.trim(), + modelId: model.id, + thinking: thinkingMode, + }); + }, + [selectedPlatform, apiKey, onComplete], + ); + + const handleVerifyKey = useCallback(async () => { + if (!selectedPlatform || !apiKey.trim()) return; + setStep("verifying"); + try { + const result = await onVerifyKey?.(selectedPlatform.id, apiKey.trim()); + if (result && result.length > 0) { + setModels(result); + setStep("model"); + setSelectedIndex(0); + } else { + setError("No models available for the selected platform."); + setStep("error"); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to verify API key."); + setStep("error"); + } + }, [selectedPlatform, apiKey, onVerifyKey]); + + useInput((input, key) => { + if (key.escape) { + onCancel?.(); + return; + } + + switch (step) { + case "platform": { + if (key.upArrow) { + setSelectedIndex((i) => (i - 1 + platforms.length) % platforms.length); + } else if (key.downArrow) { + setSelectedIndex((i) => (i + 1) % platforms.length); + } else if (key.return) { + const platform = platforms[selectedIndex]!; + setSelectedPlatform(platform); + setStep("api_key"); + setSelectedIndex(0); + } + break; + } + + case "api_key": { + if (key.return && apiKey.trim()) { + handleVerifyKey(); + } else if (key.backspace || key.delete) { + setApiKey((k) => k.slice(0, -1)); + } else if (input && !key.ctrl && !key.meta) { + setApiKey((k) => k + input); + } + break; + } + + case "model": { + if (key.upArrow) { + setSelectedIndex((i) => (i - 1 + models.length) % models.length); + } else if (key.downArrow) { + setSelectedIndex((i) => (i + 1) % models.length); + } else if (key.return) { + const model = models[selectedIndex]!; + const caps = model.capabilities || []; + if (caps.includes("always_thinking")) { + finishSetup(model, true); + } else if (caps.includes("thinking")) { + setSelectedModel(model); + setStep("thinking"); + setSelectedIndex(0); + } else { + finishSetup(model, false); + } + } + break; + } + + case "thinking": { + const choices = ["on", "off"]; + if (key.upArrow) { + setSelectedIndex((i) => (i - 1 + choices.length) % choices.length); + } else if (key.downArrow) { + setSelectedIndex((i) => (i + 1) % choices.length); + } else if (key.return) { + finishSetup(selectedModel!, selectedIndex === 0); + } + break; + } + + case "error": { + if (key.return) { + setStep("api_key"); + setApiKey(""); + setError(""); + } + break; + } + } + }); + + return ( + + 🔧 Setup Wizard + + + {step === "platform" && ( + <> + Select a platform (↑↓ navigate, Enter select, Esc cancel): + + {platforms.map((platform, i) => ( + + {i === selectedIndex ? "→" : " "} {platform.name} + + ))} + + )} + + {step === "api_key" && ( + <> + Enter your API key for {selectedPlatform?.name}: + + + {">"} + {apiKey ? "•".repeat(apiKey.length) : ""} + + + + Press Enter to verify, Esc to cancel. + + )} + + {step === "verifying" && Verifying API key...} + + {step === "model" && ( + <> + Select a model (↑↓ navigate, Enter select): + + {models.map((model, i) => ( + + {i === selectedIndex ? "→" : " "} {model.id} + + ))} + + )} + + {step === "thinking" && ( + <> + Enable thinking mode? (↑↓ navigate, Enter select): + + {["on", "off"].map((choice, i) => ( + + {i === selectedIndex ? "→" : " "} {choice} + + ))} + + )} + + {step === "done" && ( + <> + ✓ Setup complete! + Platform: {selectedPlatform?.name} + Model: {selectedModel?.id} + Thinking: {thinking ? "on" : "off"} + Reloading... + + )} + + {step === "error" && ( + <> + {error} + + Press Enter to try again, Esc to cancel. + + )} + + ); +} + +export default SetupWizard; diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx new file mode 100644 index 000000000..82b78d08c --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -0,0 +1,310 @@ +/** + * Shell.tsx — Main REPL component. + * Corresponds to Python's ui/shell/__init__.py. + * + * Layout logic: + * - WelcomeBox: fixed at top (will scroll off when content grows) + * - ChatList: height = content lines (grows as messages added) + * - InputBox: flexGrow=1 + minHeight=6, fills remaining space + * - text starts from top (row 0) + * - when ChatList grows, InputBox shrinks down to minHeight + * - when InputBox is at minHeight, total layout exceeds screen → scrollable + * - StatusBar: always at bottom + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { Box, useApp, useStdout } from "ink"; +import { MessageList } from "./Visualize.tsx"; +import { Prompt } from "./Prompt.tsx"; +import { WelcomeBox } from "../components/WelcomeBox.tsx"; +import { StatusBar } from "../components/StatusBar.tsx"; +import { ApprovalPrompt } from "../components/ApprovalPrompt.tsx"; +import { CommandPanel } from "../components/CommandPanel.tsx"; +import { StreamingSpinner, CompactionSpinner } from "../components/Spinner.tsx"; +import { useWire } from "../hooks/useWire.ts"; +import { useKeyboard } from "./keyboard.ts"; +import { + createShellSlashCommands, + parseSlashCommand, + findSlashCommand, +} from "./slash.ts"; +import { setActiveTheme } from "../theme.ts"; +import type { WireUIEvent } from "./events.ts"; +import type { ApprovalResponseKind } from "../../wire/types.ts"; +import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; + +const INPUT_MIN_HEIGHT = 6; + +/** Deduplicate commands by name, shell commands take priority */ +function deduplicateCommands(commands: SlashCommand[]): SlashCommand[] { + const seen = new Map(); + for (const cmd of commands) { + if (!seen.has(cmd.name)) { + seen.set(cmd.name, cmd); + } + } + return [...seen.values()]; +} + +export interface ShellProps { + modelName?: string; + workDir?: string; + sessionId?: string; + sessionDir?: string; + sessionTitle?: string; + thinking?: boolean; + prefillText?: string; + onSubmit?: (input: string) => void; + onInterrupt?: () => void; + onPlanModeToggle?: () => Promise; + onApprovalResponse?: ( + requestId: string, + decision: ApprovalResponseKind, + feedback?: string, + ) => void; + onWireReady?: (pushEvent: (event: WireUIEvent) => void) => void; + onReload?: (sessionId: string, prefillText?: string) => void; + extraSlashCommands?: SlashCommand[]; +} + +export function Shell({ + modelName = "", + workDir, + sessionId, + sessionDir, + sessionTitle, + thinking = false, + prefillText, + onSubmit, + onInterrupt, + onPlanModeToggle, + onApprovalResponse, + onWireReady, + onReload, + extraSlashCommands = [], +}: ShellProps) { + const { exit } = useApp(); + const { stdout } = useStdout(); + const [termHeight, setTermHeight] = useState(stdout?.rows || 24); + const [slashMenuVisible, setSlashMenuVisible] = useState(false); + const [activePanel, setActivePanel] = useState(null); + const [clearInputSignal, setClearInputSignal] = useState(0); + + // Wire state + const wire = useWire({ onReady: onWireReady }); + + // Helper to push notifications to chat area + const pushNotification = useCallback( + (title: string, body: string) => { + wire.pushEvent({ type: "notification", title, body }); + }, + [wire], + ); + + // Shell slash commands + const shellCommands = createShellSlashCommands({ + clearMessages: wire.clearMessages, + exit: () => exit(), + setTheme: (theme) => setActiveTheme(theme), + getAllCommands: () => allCommands, + pushNotification, + getSessionInfo: () => { + if (!sessionDir || !workDir) return null; + return { sessionDir, workDir, title: sessionTitle ?? "Untitled" }; + }, + triggerReload: (newSessionId: string, prefill?: string) => { + onReload?.(newSessionId, prefill); + }, + }); + + const allCommands = deduplicateCommands([ + ...shellCommands, + ...extraSlashCommands, + ]); + + // Handle terminal resize + useEffect(() => { + const onResize = () => setTermHeight(stdout?.rows || 24); + stdout?.on("resize", onResize); + return () => { + stdout?.off("resize", onResize); + }; + }, [stdout]); + + // Global keyboard handling: Ctrl+C / Esc / Shift+Tab + useKeyboard({ + onAction: (action) => { + switch (action) { + case "interrupt": + if (activePanel) { + // Close command panel on interrupt + setActivePanel(null); + } else if (wire.isStreaming) { + // Interrupt the running turn: abort the soul + push UI event + onInterrupt?.(); + wire.pushEvent({ type: "error", message: "Interrupted by user" }); + } + break; + case "clear-input": + // Double-Esc: clear the input box + setClearInputSignal((n) => n + 1); + break; + case "toggle-plan-mode": + if (onPlanModeToggle) { + onPlanModeToggle() + .then((newState) => { + pushNotification( + "Plan mode", + newState ? "Plan mode ON" : "Plan mode OFF", + ); + }) + .catch((err: unknown) => { + pushNotification("Plan mode", `Error: ${String(err)}`); + }); + } + break; + // "exit" is handled internally by useKeyboard (calls exit()) + } + }, + active: true, + }); + + // Handle user input + const handleSubmit = useCallback( + (input: string) => { + const parsed = parseSlashCommand(input); + if (parsed) { + const cmd = findSlashCommand(allCommands, parsed.name); + if (cmd) { + // If command has panel and no args provided, try opening panel + if (cmd.panel && !parsed.args) { + const panelConfig = cmd.panel(); + if (panelConfig) { + setActivePanel(panelConfig); + return; + } + } + cmd.handler(parsed.args); + return; + } + wire.pushEvent({ + type: "notification", + title: "Unknown command", + body: `/${parsed.name} is not a recognized command. Type /help for available commands.`, + }); + return; + } + onSubmit?.(input); + }, + [allCommands, onSubmit, wire], + ); + + // Handle opening a command panel from slash menu + const handleOpenPanel = useCallback( + (cmd: SlashCommand) => { + if (cmd.panel) { + const panelConfig = cmd.panel(); + if (panelConfig) { + setActivePanel(panelConfig); + return; + } + } + // Fallback: execute handler directly + cmd.handler(""); + }, + [], + ); + + // Close command panel + const handleClosePanel = useCallback(() => { + setActivePanel(null); + }, []); + + // Handle approval response + const handleApprovalResponse = useCallback( + (decision: ApprovalResponseKind, feedback?: string) => { + if (wire.pendingApproval) { + onApprovalResponse?.(wire.pendingApproval.id, decision, feedback); + wire.pushEvent({ + type: "approval_response", + requestId: wire.pendingApproval.id, + response: decision, + }); + } + }, + [wire.pendingApproval, onApprovalResponse, wire], + ); + + // Calculate status bar height (separator + 2 lines of status) + const statusBarHeight = slashMenuVisible ? 0 : 3; + + return ( + + {/* ═══ Top: Welcome box ═══ */} + + + {/* ═══ ChatList: height follows content ═══ */} + + + + {wire.isStreaming && !wire.isCompacting && ( + + )} + + + + {wire.pendingApproval && ( + + )} + + + {/* ═══ InputBox: fills remaining, min 6 lines, text at top ═══ */} + + {activePanel ? ( + + ) : ( + + )} + + + {/* ═══ Bottom: Status bar (always visible, hidden when slash menu or panel) ═══ */} + {!slashMenuVisible && !activePanel && ( + + )} + + ); +} diff --git a/src/kimi_cli_ts/ui/shell/TaskBrowser.tsx b/src/kimi_cli_ts/ui/shell/TaskBrowser.tsx new file mode 100644 index 000000000..3beb2bd78 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/TaskBrowser.tsx @@ -0,0 +1,284 @@ +/** + * TaskBrowser.tsx — Background task browser with React Ink. + * Corresponds to Python's ui/shell/task_browser.py. + * + * Features: + * - Background task list + * - Detail/preview panel + * - Stop/confirm operations + * - Filter (all/active) + */ + +import React, { useState, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; + +// ── Types ─────────────────────────────────────────────── + +export type TaskStatus = + | "running" + | "starting" + | "completed" + | "failed" + | "killed" + | "lost"; + +export type TaskBrowserFilter = "all" | "active"; + +export interface TaskViewSpec { + id: string; + description: string; + kind: string; + command?: string; + cwd?: string; + createdAt: number; +} + +export interface TaskViewRuntime { + status: TaskStatus; + exitCode?: number | null; + failureReason?: string; + startedAt?: number | null; + finishedAt?: number | null; + updatedAt: number; + timedOut?: boolean; +} + +export interface TaskView { + spec: TaskViewSpec; + runtime: TaskViewRuntime; +} + +export interface TaskBrowserProps { + tasks: TaskView[]; + onStop?: (taskId: string) => void; + onViewOutput?: (taskId: string) => void; + onRefresh?: () => void; + onClose?: () => void; +} + +const TERMINAL_STATUSES = new Set([ + "completed", + "failed", + "killed", + "lost", +]); + +function isTerminal(status: TaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +// ── Helpers ───────────────────────────────────────────── + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return m > 0 ? `${h}h${m}m` : `${h}h`; +} + +function formatRelativeTime(ts: number): string { + const delta = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (delta < 5) return "just now"; + if (delta < 60) return `${delta}s ago`; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + return `${Math.floor(delta / 3600)}h ago`; +} + +function taskTimingLabel(view: TaskView): string { + const now = Date.now() / 1000; + if (view.runtime.finishedAt != null) { + return `finished ${formatRelativeTime(view.runtime.finishedAt)}`; + } + if (view.runtime.startedAt != null) { + const seconds = Math.max(0, Math.floor(now - view.runtime.startedAt)); + return `running ${formatDuration(seconds)}`; + } + return `updated ${formatRelativeTime(view.runtime.updatedAt)}`; +} + +// ── TaskBrowser ───────────────────────────────────────── + +export function TaskBrowser({ + tasks, + onStop, + onViewOutput, + onRefresh, + onClose, +}: TaskBrowserProps) { + const [filterMode, setFilterMode] = useState("all"); + const [selectedIndex, setSelectedIndex] = useState(0); + const [pendingStopId, setPendingStopId] = useState(null); + const [flashMessage, setFlashMessage] = useState(""); + + // Filter tasks + const visibleTasks = + filterMode === "active" + ? tasks.filter((t) => !isTerminal(t.runtime.status)) + : [...tasks]; + + // Sort: active first, then by created time + visibleTasks.sort((a, b) => { + const aTerminal = isTerminal(a.runtime.status) ? 1 : 0; + const bTerminal = isTerminal(b.runtime.status) ? 1 : 0; + if (aTerminal !== bTerminal) return aTerminal - bTerminal; + return a.spec.createdAt - b.spec.createdAt; + }); + + const clampedIndex = Math.min(selectedIndex, Math.max(0, visibleTasks.length - 1)); + const selectedTask = visibleTasks[clampedIndex] || null; + + // Status counts + const counts: Record = { + running: 0, starting: 0, completed: 0, failed: 0, killed: 0, lost: 0, + }; + for (const t of tasks) { + counts[t.runtime.status] = (counts[t.runtime.status] || 0) + 1; + } + + const flash = useCallback((msg: string) => { + setFlashMessage(msg); + setTimeout(() => setFlashMessage(""), 3000); + }, []); + + useInput((input, key) => { + // Confirm stop mode + if (pendingStopId !== null) { + if (input === "y" || input === "Y") { + onStop?.(pendingStopId); + flash(`Stop requested for task ${pendingStopId}.`); + setPendingStopId(null); + return; + } + if (input === "n" || input === "N" || key.escape) { + flash("Stop cancelled."); + setPendingStopId(null); + return; + } + return; + } + + // Normal mode + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow) { + setSelectedIndex((i) => Math.min(visibleTasks.length - 1, i + 1)); + } else if (input === "q" || key.escape) { + onClose?.(); + } else if (key.tab) { + const newFilter = filterMode === "all" ? "active" : "all"; + setFilterMode(newFilter); + flash(newFilter === "active" ? "Showing active tasks only." : "Showing all tasks."); + } else if (input === "r" || input === "R") { + onRefresh?.(); + flash("Refreshed."); + } else if (input === "s" || input === "S") { + if (selectedTask) { + if (isTerminal(selectedTask.runtime.status)) { + flash(`Task ${selectedTask.spec.id} is already ${selectedTask.runtime.status}.`); + } else { + setPendingStopId(selectedTask.spec.id); + } + } + } else if (key.return || input === "o") { + if (selectedTask) { + onViewOutput?.(selectedTask.spec.id); + } + } + }); + + return ( + + {/* Header */} + + TASK BROWSER + filter={filterMode.toUpperCase()} + {counts.running} running + {counts.starting} starting + {counts.failed} failed + {counts.completed} completed + {(counts.killed || 0) + (counts.lost || 0)} interrupted + {tasks.length} total + + + + {/* Task list */} + + Tasks [{filterMode}] + {visibleTasks.length === 0 ? ( + + {filterMode === "active" ? "No active background tasks." : "No background tasks in this session."} + + ) : ( + visibleTasks.map((task, idx) => { + const isSelected = idx === clampedIndex; + const description = task.spec.description.trim() || "(no description)"; + const timing = taskTimingLabel(task); + const line = `[${task.runtime.status}] ${description} · ${task.spec.id} · ${task.spec.kind} · ${timing}`; + return ( + + {isSelected ? ">" : " "} {line} + + ); + }) + )} + + + {/* Detail + Preview */} + + + Detail + {selectedTask ? ( + + Task ID: {selectedTask.spec.id} + Status: {selectedTask.runtime.status} + Description: {selectedTask.spec.description} + Kind: {selectedTask.spec.kind} + Time: {taskTimingLabel(selectedTask)} + {selectedTask.spec.cwd && Cwd: {selectedTask.spec.cwd}} + {selectedTask.spec.command && Command: {selectedTask.spec.command}} + {selectedTask.runtime.exitCode != null && Exit code: {selectedTask.runtime.exitCode}} + {selectedTask.runtime.failureReason && Reason: {selectedTask.runtime.failureReason}} + + ) : ( + Select a task from the list. + )} + + + Preview Output + + {selectedTask ? "Press Enter or O to view full output." : "No output to preview."} + + + + + + {/* Footer */} + + {pendingStopId !== null ? ( + <> + Confirm stop {pendingStopId}? + Y confirm + N cancel + + ) : ( + <> + Enter output + S stop + R refresh + Tab filter + Q exit + {flashMessage && | {flashMessage}} + + )} + + + ); +} + +export default TaskBrowser; diff --git a/src/kimi_cli_ts/ui/shell/UsagePanel.tsx b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx new file mode 100644 index 000000000..f61b9c735 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx @@ -0,0 +1,257 @@ +/** + * UsagePanel.tsx — API usage and quota display panel. + * Corresponds to Python's ui/shell/usage.py. + * + * Features: + * - API quota usage display + * - Progress bars + * - Reset timer + */ + +import React from "react"; +import { Box, Text } from "ink"; + +export interface UsageRow { + label: string; + used: number; + limit: number; + resetHint?: string | null; +} + +export interface UsagePanelProps { + summary?: UsageRow | null; + limits: UsageRow[]; + loading?: boolean; + error?: string | null; +} + +function ProgressBar({ + completed, + total, + width = 20, +}: { + completed: number; + total: number; + width?: number; +}) { + const ratio = total > 0 ? Math.min(completed / total, 1) : 0; + const filledWidth = Math.round(ratio * width); + const emptyWidth = width - filledWidth; + const color = ratioColor(total > 0 ? (total - completed) / total : 0); + + return ( + + {"█".repeat(filledWidth)} + {"░".repeat(emptyWidth)} + + ); +} + +function ratioColor(ratio: number): string { + if (ratio >= 0.9) return "red"; + if (ratio >= 0.7) return "yellow"; + return "green"; +} + +function UsageRowView({ row, labelWidth }: { row: UsageRow; labelWidth: number }) { + const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; + const percent = remaining * 100; + + return ( + + + {row.label.padEnd(labelWidth)} + + + + + + {` ${percent.toFixed(0)}% left`} + {row.resetHint && ( + {` (${row.resetHint})`} + )} + + + ); +} + +export function UsagePanel({ summary, limits, loading, error }: UsagePanelProps) { + if (loading) { + return ( + + Fetching usage... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + const rows = [...(summary ? [summary] : []), ...limits]; + if (rows.length === 0) { + return ( + + No usage data + + ); + } + + const labelWidth = Math.max(6, ...rows.map((r) => r.label.length)); + + return ( + + API Usage + + {rows.map((row, idx) => ( + + ))} + + ); +} + +// ── Usage data parsing helpers ────────────────────────── + +export function parseUsagePayload(payload: Record): { + summary: UsageRow | null; + limits: UsageRow[]; +} { + let summary: UsageRow | null = null; + const limits: UsageRow[] = []; + + const usage = payload.usage; + if (usage && typeof usage === "object") { + summary = toUsageRow(usage as Record, "Weekly limit"); + } + + const rawLimits = payload.limits; + if (Array.isArray(rawLimits)) { + for (let idx = 0; idx < rawLimits.length; idx++) { + const item = rawLimits[idx]; + if (!item || typeof item !== "object") continue; + const itemMap = item as Record; + const detail = + itemMap.detail && typeof itemMap.detail === "object" + ? (itemMap.detail as Record) + : itemMap; + const window = + itemMap.window && typeof itemMap.window === "object" + ? (itemMap.window as Record) + : {}; + const label = limitLabel(itemMap, detail, window, idx); + const row = toUsageRow(detail, label); + if (row) limits.push(row); + } + } + + return { summary, limits }; +} + +function toUsageRow( + data: Record, + defaultLabel: string, +): UsageRow | null { + const limit = toInt(data.limit); + let used = toInt(data.used); + if (used == null) { + const remaining = toInt(data.remaining); + if (remaining != null && limit != null) { + used = limit - remaining; + } + } + if (used == null && limit == null) return null; + return { + label: String(data.name || data.title || defaultLabel), + used: used || 0, + limit: limit || 0, + resetHint: resetHint(data), + }; +} + +function limitLabel( + item: Record, + detail: Record, + window: Record, + idx: number, +): string { + for (const key of ["name", "title", "scope"]) { + const val = item[key] || detail[key]; + if (val) return String(val); + } + const duration = toInt( + window.duration || item.duration || detail.duration, + ); + const timeUnit = String( + window.timeUnit || item.timeUnit || detail.timeUnit || "", + ); + if (duration) { + if (timeUnit.includes("MINUTE")) { + if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`; + return `${duration}m limit`; + } + if (timeUnit.includes("HOUR")) return `${duration}h limit`; + if (timeUnit.includes("DAY")) return `${duration}d limit`; + return `${duration}s limit`; + } + return `Limit #${idx + 1}`; +} + +function resetHint(data: Record): string | null { + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + if (data[key]) return formatResetTime(String(data[key])); + } + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = toInt(data[key]); + if (seconds) return `resets in ${formatDuration(seconds)}`; + } + return null; +} + +function formatResetTime(val: string): string { + try { + const dt = new Date(val); + const now = Date.now(); + const delta = dt.getTime() - now; + if (delta <= 0) return "reset"; + return `resets in ${formatDuration(Math.floor(delta / 1000))}`; + } catch { + return `resets at ${val}`; + } +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; +} + +function toInt(value: unknown): number | null { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? Math.floor(n) : null; +} + +export default UsagePanel; diff --git a/src/kimi_cli_ts/ui/shell/Visualize.tsx b/src/kimi_cli_ts/ui/shell/Visualize.tsx new file mode 100644 index 000000000..4eaf90fad --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/Visualize.tsx @@ -0,0 +1,917 @@ +/** + * Visualize.tsx — Message visualization components. + * Corresponds to Python's ui/shell/visualize.py. + * + * Components: + * - MessageList: renders all messages with step headers + * - MessageView: single message with role-based styling + * - ToolCallView: tool call display (collapsible) with key argument extraction + * - StreamingText: streaming text with cursor + full markdown rendering + * - ThinkingView: thinking/reasoning display + * - CodeBlockView: syntax-highlighted code blocks + * - TableView: markdown table rendering + * - ErrorRecoveryView: API error classification display + */ + +import React, { useState, useMemo } from "react"; +import { Box, Text, Newline } from "ink"; +import chalk from "chalk"; +import { getStyles, getMessageColors, getDiffColors } from "../theme.ts"; +import type { + UIMessage, + MessageSegment, + TextSegment, + ThinkSegment, + ToolCallSegment, +} from "./events.ts"; +import type { ToolResult, DisplayBlock } from "../../wire/types.ts"; + +// ── MessageList ──────────────────────────────────────────── + +interface MessageListProps { + messages: UIMessage[]; + isStreaming: boolean; + stepCount?: number; +} + +export function MessageList({ messages, isStreaming, stepCount }: MessageListProps) { + return ( + + {messages.map((msg, idx) => ( + + ))} + + ); +} + +// ── MessageView ──────────────────────────────────────────── + +interface MessageViewProps { + message: UIMessage; + isLast: boolean; + isStreaming: boolean; + stepCount?: number; +} + +function MessageView({ message, isLast, isStreaming, stepCount }: MessageViewProps) { + const colors = getMessageColors(); + + const roleLabel = getRoleLabel(message.role); + const roleColor = getRoleColor(message.role, colors); + + return ( + + {/* Step count header for assistant messages */} + {message.role === "assistant" && stepCount != null && stepCount > 0 && ( + + ─── Step {stepCount} ─── + + )} + + {roleLabel} + + {message.segments.map((segment, idx) => ( + + ))} + + ); +} + +function getRoleLabel(role: string): string { + switch (role) { + case "user": + return "You"; + case "assistant": + return "Assistant"; + case "system": + return "System"; + case "tool": + return "Tool"; + default: + return role; + } +} + +function getRoleColor( + role: string, + colors: ReturnType, +): string { + switch (role) { + case "user": + return colors.user; + case "assistant": + return colors.assistant; + case "system": + return colors.system; + case "tool": + return colors.tool; + default: + return colors.dim; + } +} + +// ── SegmentView ──────────────────────────────────────────── + +interface SegmentViewProps { + segment: MessageSegment; + isStreaming: boolean; +} + +function SegmentView({ segment, isStreaming }: SegmentViewProps) { + switch (segment.type) { + case "text": + return ; + case "think": + return ; + case "tool_call": + return ; + default: + return null; + } +} + +// ── StreamingText ────────────────────────────────────────── + +interface StreamingTextProps { + text: string; + isStreaming: boolean; +} + +export function StreamingText({ text, isStreaming }: StreamingTextProps) { + const rendered = useMemo(() => renderMarkdown(text), [text]); + + return ( + + {rendered} + {isStreaming && } + + ); +} + +// ── ThinkingView ─────────────────────────────────────────── + +interface ThinkingViewProps { + text: string; +} + +export function ThinkingView({ text }: ThinkingViewProps) { + const colors = getMessageColors(); + // Truncate thinking to max 6 lines for preview + const lines = text.split("\n"); + const preview = lines.length > 6 + ? lines.slice(0, 6).join("\n") + `\n… ${lines.length - 6} more lines` + : text; + + return ( + + + 💭 {preview} + + + ); +} + +// ── ToolCallView ─────────────────────────────────────────── + +interface ToolCallViewProps { + toolCall: ToolCallSegment; +} + +export function ToolCallView({ toolCall }: ToolCallViewProps) { + const [collapsed, setCollapsed] = useState(toolCall.collapsed); + const colors = getMessageColors(); + const statusIcon = toolCall.result + ? toolCall.result.return_value.isError + ? "✗" + : "✓" + : "⟳"; + const statusColor = toolCall.result + ? toolCall.result.return_value.isError + ? colors.error + : colors.highlight + : colors.dim; + + // Format arguments for display — extract key argument + let argsPreview = ""; + try { + const parsed = JSON.parse(toolCall.arguments); + const key = extractKeyArgument(toolCall.name, parsed); + argsPreview = key || truncate(toolCall.arguments, 60); + } catch { + // Streaming JSON: show partial arguments + argsPreview = renderStreamingJson(toolCall.arguments); + } + + return ( + + + {statusIcon} + + {toolCall.name} + + {argsPreview} + + {!collapsed && toolCall.result && ( + + + + )} + + ); +} + +// ── ToolResultView ───────────────────────────────────────── + +interface ToolResultViewProps { + result: ToolResult; +} + +function ToolResultView({ result }: ToolResultViewProps) { + const colors = getMessageColors(); + const output = result.return_value.output; + const isError = result.return_value.isError; + const truncated = truncate(output, 500); + + return ( + + {result.display.map((block, idx) => ( + + ))} + {!result.display.length && ( + {truncated} + )} + + ); +} + +// ── DisplayBlockView ─────────────────────────────────────── + +interface DisplayBlockViewProps { + block: DisplayBlock; +} + +function DisplayBlockView({ block }: DisplayBlockViewProps) { + const colors = getMessageColors(); + const diffColors = getDiffColors(); + const b = block as Record; + + switch (block.type) { + case "brief": + return {b.brief as string}; + case "diff": + return ( + + ); + case "shell": + return ( + + $ + {b.command as string} + + ); + case "todo": { + const items = b.items as Array<{ + title: string; + status: string; + }>; + return ( + + {items.map((item, idx) => ( + + + {item.status === "done" + ? "✓" + : item.status === "in_progress" + ? "⟳" + : "○"}{" "} + {item.title} + + + ))} + + ); + } + case "background_task": { + return ( + + + [{b.kind as string}] + {b.description as string} + ({b.status as string}) + + ); + } + default: + return null; + } +} + +// ── DiffView ─────────────────────────────────────────────── + +function DiffView({ + block, +}: { + block: { path: string; old_text: string; new_text: string }; +}) { + const diffColors = getDiffColors(); + return ( + + + {block.path} + + {block.old_text + .split("\n") + .filter(Boolean) + .map((line, idx) => ( + + - {line} + + ))} + {block.new_text + .split("\n") + .filter(Boolean) + .map((line, idx) => ( + + + {line} + + ))} + + ); +} + +// ── ErrorRecoveryView ────────────────────────────────────── + +export interface ErrorInfo { + type: "rate_limit" | "server_error" | "network" | "auth" | "unknown"; + message: string; + retryable: boolean; + retryAfter?: number; +} + +export function ErrorRecoveryView({ error }: { error: ErrorInfo }) { + const icon = error.retryable ? "⟳" : "✗"; + const color = error.retryable ? "#f2cc60" : "#ff7b72"; + const typeLabel = { + rate_limit: "Rate Limited", + server_error: "Server Error", + network: "Network Error", + auth: "Authentication Error", + unknown: "Error", + }[error.type]; + + return ( + + + + {icon} {typeLabel} + + + + {error.message} + + {error.retryable && error.retryAfter && ( + + + Retrying in {error.retryAfter}s… + + + )} + + ); +} + +/** + * Classify API error for display. + */ +export function classifyApiError(err: unknown): ErrorInfo { + const msg = err instanceof Error ? err.message : String(err); + const lower = msg.toLowerCase(); + + if (lower.includes("429") || lower.includes("rate limit")) { + const retryMatch = lower.match(/retry.after.*?(\d+)/); + return { + type: "rate_limit", + message: msg, + retryable: true, + retryAfter: retryMatch ? parseInt(retryMatch[1]!, 10) : 60, + }; + } + if (lower.includes("500") || lower.includes("502") || lower.includes("503") || lower.includes("504")) { + return { type: "server_error", message: msg, retryable: true, retryAfter: 5 }; + } + if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("network")) { + return { type: "network", message: msg, retryable: true, retryAfter: 3 }; + } + if (lower.includes("401") || lower.includes("403") || lower.includes("auth")) { + return { type: "auth", message: msg, retryable: false }; + } + return { type: "unknown", message: msg, retryable: false }; +} + +// ── Markdown Rendering ───────────────────────────────────── + +/** + * Full markdown rendering to React Ink components. + * Supports: headings, code blocks (with language hint), tables, lists, + * blockquotes, horizontal rules, and inline formatting. + */ +function renderMarkdown(text: string): React.ReactNode { + const lines = text.split("\n"); + const elements: React.ReactNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + + // Fenced code block + const codeMatch = line.match(/^```(\w*)/); + if (codeMatch) { + const lang = codeMatch[1] || ""; + const codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i]!.startsWith("```")) { + codeLines.push(lines[i]!); + i++; + } + if (i < lines.length) i++; // skip closing ``` + elements.push( + , + ); + continue; + } + + // Table detection (| header | header |) + if (line.includes("|") && line.trim().startsWith("|")) { + const tableLines: string[] = [line]; + i++; + while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim().startsWith("|")) { + tableLines.push(lines[i]!); + i++; + } + if (tableLines.length >= 2) { + elements.push( + , + ); + continue; + } + // Not a real table, render as text + for (const tl of tableLines) { + elements.push( + {renderInlineFormatting(tl)}, + ); + } + continue; + } + + // Heading + const headingMatch = line.match(/^(#{1,6})\s+(.+)/); + if (headingMatch) { + const level = headingMatch[1]!.length; + const headingText = headingMatch[2]!; + const color = level <= 2 ? "#56a4ff" : level <= 4 ? "#e6e6e6" : "#9ca3af"; + elements.push( + + {level <= 2 ? "█ " : level <= 4 ? "▌ " : "▎ "}{renderInlineFormatting(headingText)} + , + ); + i++; + continue; + } + + // Blockquote + if (line.startsWith("> ") || line === ">") { + const quoteLines: string[] = []; + while (i < lines.length && (lines[i]!.startsWith("> ") || lines[i] === ">")) { + quoteLines.push(lines[i]!.replace(/^>\s?/, "")); + i++; + } + elements.push( + + + {quoteLines.join("\n")} + + , + ); + continue; + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { + elements.push( + + {"─".repeat(60)} + , + ); + i++; + continue; + } + + // Unordered list + if (/^\s*[-*+]\s+/.test(line)) { + const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0; + const bullet = indent >= 4 ? " ◦ " : indent >= 2 ? " ◦ " : "• "; + const content = line.replace(/^\s*[-*+]\s+/, ""); + elements.push( + + {" ".repeat(Math.floor(indent / 2))}{bullet}{renderInlineFormatting(content)} + , + ); + i++; + continue; + } + + // Ordered list + const olMatch = line.match(/^\s*(\d+)[.)]\s+(.*)/); + if (olMatch) { + const num = olMatch[1]!; + const content = olMatch[2]!; + elements.push( + + {" "}{chalk.bold(num + ".")} {renderInlineFormatting(content)} + , + ); + i++; + continue; + } + + // Regular text + if (line.trim() === "") { + elements.push({" "}); + } else { + elements.push( + {renderInlineFormatting(line)}, + ); + } + i++; + } + + return <>{elements}; +} + +// ── Code Block View ──────────────────────────────────────── + +function CodeBlockView({ code, language }: { code: string; language: string }) { + const colors = getMessageColors(); + + // Simple keyword-based syntax coloring + const highlighted = language + ? highlightCode(code, language) + : code; + + return ( + + {language && ( + + {language} + + )} + {highlighted} + + ); +} + +/** + * Simple syntax highlighting using chalk. + * Covers common patterns: keywords, strings, comments, numbers. + */ +function highlightCode(code: string, language: string): string { + const lang = language.toLowerCase(); + + // Language-specific keywords + const KEYWORDS: Record = { + js: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "import", "export", "from", "async", "await", "new", "this", "try", "catch", "throw", "typeof", "instanceof", "switch", "case", "default", "break", "continue"], + ts: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "import", "export", "from", "async", "await", "new", "this", "try", "catch", "throw", "typeof", "instanceof", "interface", "type", "enum", "implements", "extends", "switch", "case", "default", "break", "continue"], + typescript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "import", "export", "from", "async", "await", "new", "this", "try", "catch", "throw", "typeof", "instanceof", "interface", "type", "enum", "implements", "extends", "switch", "case", "default", "break", "continue"], + javascript: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "class", "import", "export", "from", "async", "await", "new", "this", "try", "catch", "throw", "typeof", "instanceof", "switch", "case", "default", "break", "continue"], + python: ["def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as", "try", "except", "raise", "with", "yield", "lambda", "pass", "break", "continue", "and", "or", "not", "in", "is", "None", "True", "False", "self", "async", "await"], + py: ["def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as", "try", "except", "raise", "with", "yield", "lambda", "pass", "break", "continue", "and", "or", "not", "in", "is", "None", "True", "False", "self", "async", "await"], + rust: ["fn", "let", "mut", "const", "if", "else", "for", "while", "loop", "match", "struct", "enum", "impl", "trait", "pub", "use", "mod", "crate", "self", "super", "return", "async", "await", "move", "type", "where"], + go: ["func", "var", "const", "if", "else", "for", "range", "switch", "case", "default", "return", "type", "struct", "interface", "package", "import", "go", "chan", "select", "defer", "map", "make", "new", "nil", "true", "false"], + bash: ["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "local", "export", "echo", "exit"], + sh: ["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "local", "export", "echo", "exit"], + }; + + const keywords = KEYWORDS[lang] || []; + if (keywords.length === 0) return code; + + // Apply highlighting line-by-line + return code.split("\n").map((line) => { + // Comments + if (line.trimStart().startsWith("//") || line.trimStart().startsWith("#")) { + return chalk.hex("#6b7280")(line); + } + + // String literals (basic) + let result = line; + result = result.replace(/(["'`])(?:(?!\1|\\).|\\.)*\1/g, (m) => chalk.hex("#a5d6a7")(m)); + + // Numbers + result = result.replace(/\b(\d+\.?\d*)\b/g, (m) => chalk.hex("#f2cc60")(m)); + + // Keywords + const kwPattern = new RegExp(`\\b(${keywords.join("|")})\\b`, "g"); + result = result.replace(kwPattern, (m) => chalk.hex("#c792ea")(m)); + + return result; + }).join("\n"); +} + +// ── Table View ───────────────────────────────────────────── + +function TableView({ lines }: { lines: string[] }) { + const colors = getMessageColors(); + + // Parse table + const rows = lines + .filter((line) => !line.match(/^\|[\s-:|]+\|$/)) // Skip separator rows + .map((line) => + line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()), + ); + + if (rows.length === 0) return null; + + const header = rows[0]!; + const body = rows.slice(1); + + // Calculate column widths + const colWidths = header.map((h, colIdx) => { + const maxContent = Math.max( + h.length, + ...body.map((row) => (row[colIdx] || "").length), + ); + return Math.min(maxContent + 2, 40); + }); + + const separator = "┼" + colWidths.map((w) => "─".repeat(w)).join("┼") + "┼"; + + return ( + + {/* Header */} + + {"┌" + colWidths.map((w) => "─".repeat(w)).join("┬") + "┐"} + + + {"│"} + {header.map((cell, idx) => ( + chalk.bold(cell.padEnd(colWidths[idx]!)) + )).join("│")} + {"│"} + + + {"├" + colWidths.map((w) => "─".repeat(w)).join("┼") + "┤"} + + {/* Body */} + {body.map((row, rowIdx) => ( + + {"│"} + {row.map((cell, colIdx) => ( + (cell || "").padEnd(colWidths[colIdx]!) + )).join("│")} + {"│"} + + ))} + + {"└" + colWidths.map((w) => "─".repeat(w)).join("┴") + "┘"} + + + ); +} + +// ── Inline Formatting ────────────────────────────────────── + +/** + * Render inline markdown formatting: bold, italic, code, strikethrough, links. + */ +function renderInlineFormatting(text: string): string { + return text + // Bold + italic + .replace(/\*\*\*(.+?)\*\*\*/g, (_, p1) => chalk.bold.italic(p1)) + // Bold + .replace(/\*\*(.+?)\*\*/g, (_, p1) => chalk.bold(p1)) + // Italic + .replace(/\*(.+?)\*/g, (_, p1) => chalk.italic(p1)) + .replace(/_(.+?)_/g, (_, p1) => chalk.italic(p1)) + // Strikethrough + .replace(/~~(.+?)~~/g, (_, p1) => chalk.strikethrough(p1)) + // Inline code + .replace(/`(.+?)`/g, (_, p1) => chalk.cyan(p1)) + // Links [text](url) + .replace(/\[(.+?)\]\((.+?)\)/g, (_, text, url) => + chalk.underline.hex("#56a4ff")(text) + chalk.hex("#6b7280")(` (${url})`), + ); +} + +// ── Streaming JSON Rendering ─────────────────────────────── + +/** + * Render partial/streaming JSON arguments for tool calls. + * Shows key-value pairs as they arrive. + */ +function renderStreamingJson(partial: string): string { + // Try to extract readable key-value pairs from partial JSON + const pairs: string[] = []; + const kvPattern = /"(\w+)":\s*"([^"]*)"?/g; + let match; + while ((match = kvPattern.exec(partial)) !== null) { + const key = match[1]!; + const value = match[2]!; + if (key.length < 20 && value.length < 80) { + pairs.push(`${key}=${truncate(value, 40)}`); + } + } + if (pairs.length > 0) { + return pairs.slice(0, 3).join(", "); + } + return truncate(partial, 60); +} + +// ── NotificationView ──────────────────────────────────────── + +export interface NotificationViewProps { + title: string; + body: string; + severity?: string; +} + +export function NotificationView({ title, body, severity }: NotificationViewProps) { + const icon = severity === "error" ? "✗" : severity === "warning" ? "⚠" : "ℹ"; + const color = severity === "error" ? "#ff7b72" : severity === "warning" ? "#f2cc60" : "#56a4ff"; + + return ( + + + {icon} {title} + + {body && ( + + {body} + + )} + + ); +} + +// ── StatusView (context token usage) ──────────────────────── + +export interface StatusViewProps { + contextTokens: number; + maxContextTokens: number; + contextUsage?: number | null; +} + +export function StatusView({ contextTokens, maxContextTokens, contextUsage }: StatusViewProps) { + const ratio = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0; + const percent = (ratio * 100).toFixed(0); + const barWidth = 20; + const filled = Math.round(ratio * barWidth); + const empty = barWidth - filled; + const color = ratio >= 0.9 ? "#ff7b72" : ratio >= 0.7 ? "#f2cc60" : "#56d364"; + + return ( + + context + {"█".repeat(filled)} + {"░".repeat(empty)} + {percent}% ({(contextTokens / 1000).toFixed(1)}k/{(maxContextTokens / 1000).toFixed(1)}k) + + ); +} + +// ── PlanDisplayView ───────────────────────────────────────── + +export function PlanDisplayView({ content, filePath }: { content: string; filePath: string }) { + const rendered = renderMarkdown(content); + return ( + + + 📋 Plan + ({filePath}) + + + {rendered} + + + ); +} + +// ── HookView ──────────────────────────────────────────────── + +export function HookTriggeredView({ event, target, hookCount }: { event: string; target: string; hookCount: number }) { + return ( + + ⟳ hook + {event} + {target && → {target}} + {hookCount > 1 && ({hookCount} hooks)} + + ); +} + +export function HookResolvedView({ event, target, action, reason, durationMs }: { event: string; target: string; action: string; reason: string; durationMs: number }) { + const icon = action === "allow" ? "✓" : "✗"; + const color = action === "allow" ? "#56d364" : "#ff7b72"; + return ( + + {icon} hook + {event} + {target && → {target}} + ({action}{reason ? `: ${reason}` : ""}) {durationMs}ms + + ); +} + +// ── Enhanced DiffView with line numbers and context ───────── + +function EnhancedDiffView({ + block, +}: { + block: { path: string; old_text: string; new_text: string; old_start?: number; new_start?: number }; +}) { + const diffColors = getDiffColors(); + const oldStart = block.old_start ?? 1; + const newStart = block.new_start ?? 1; + const oldLines = block.old_text.split("\n").filter(Boolean); + const newLines = block.new_text.split("\n").filter(Boolean); + + // Determine max line number width for alignment + const maxLineNum = Math.max(oldStart + oldLines.length, newStart + newLines.length); + const lineNumWidth = String(maxLineNum).length; + + return ( + + + {block.path} + + + @@ -{oldStart},{oldLines.length} +{newStart},{newLines.length} @@ + + {oldLines.map((line, idx) => ( + + {String(oldStart + idx).padStart(lineNumWidth)} - {line} + + ))} + {newLines.map((line, idx) => ( + + {String(newStart + idx).padStart(lineNumWidth)} + {line} + + ))} + + ); +} + +// ── Helpers ──────────────────────────────────────────────── + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen)}…`; +} + +/** + * Extract the most relevant argument from a tool call for preview. + */ +function extractKeyArgument( + toolName: string, + args: Record, +): string { + // Try common key argument names + const keyNames = ["path", "file_path", "command", "query", "url", "name", "pattern", "description"]; + for (const key of keyNames) { + if (key in args && typeof args[key] === "string") { + return truncate(args[key] as string, 80); + } + } + // Fall back to first string argument + for (const [_, val] of Object.entries(args)) { + if (typeof val === "string" && val.length < 100) { + return val; + } + } + return ""; +} diff --git a/src/kimi_cli_ts/ui/shell/commands/add_dir.ts b/src/kimi_cli_ts/ui/shell/commands/add_dir.ts new file mode 100644 index 000000000..5d804f537 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/add_dir.ts @@ -0,0 +1,91 @@ +/** + * /add-dir slash command handler. + * Adds a directory to the workspace scope. + * Corresponds to Python soul/slash.py add_dir command. + */ + +import { resolve } from "node:path"; +import { logger } from "../../../utils/logging.ts"; +import type { Session } from "../../../session.ts"; +import { saveSessionState } from "../../../session.ts"; + +export async function handleAddDir( + session: Session, + workDir: string, + args: string, +): Promise { + const arg = args.trim(); + + // No args: list currently added directories + if (!arg) { + const dirs = session.state.additional_dirs; + if (!dirs.length) { + logger.info("No additional directories. Usage: /add-dir "); + } else { + logger.info("Additional directories:"); + for (const d of dirs) { + logger.info(` - ${d}`); + } + } + return null; + } + + // Resolve the path + const dirPath = resolve(arg.replace(/^~/, process.env.HOME ?? "~")); + + // Check existence + const dirFile = Bun.file(dirPath); + try { + const stat = await Bun.$`test -d ${dirPath}`.quiet(); + if (stat.exitCode !== 0) { + logger.info(`Not a directory: ${dirPath}`); + return null; + } + } catch { + logger.info(`Directory does not exist: ${dirPath}`); + return null; + } + + // Check if already added + if (session.state.additional_dirs.includes(dirPath)) { + logger.info(`Directory already in workspace: ${dirPath}`); + return null; + } + + // Check if within work dir + if (dirPath.startsWith(workDir + "/") || dirPath === workDir) { + logger.info(`Directory is already within the working directory: ${dirPath}`); + return null; + } + + // Check if within an already-added directory + for (const existing of session.state.additional_dirs) { + if (dirPath.startsWith(existing + "/") || dirPath === existing) { + logger.info(`Directory is already within added directory ${existing}: ${dirPath}`); + return null; + } + } + + // Validate readability + let lsOutput = ""; + try { + lsOutput = await Bun.$`ls -la ${dirPath}`.quiet().text(); + } catch (e) { + logger.info(`Cannot read directory: ${dirPath}`); + return null; + } + + // Add the directory + session.state.additional_dirs.push(dirPath); + await saveSessionState(session.state, session.dir); + + logger.info(`Added directory to workspace: ${dirPath}`); + + // Return info string for injecting into context + return ( + `The user has added an additional directory to the workspace: \`${dirPath}\`\n\n` + + `Directory listing:\n\`\`\`\n${lsOutput.trim()}\n\`\`\`\n\n` + + "You can now read, write, search, and glob files in this directory " + + "as if it were part of the working directory." + ); +} diff --git a/src/kimi_cli_ts/ui/shell/commands/editor.ts b/src/kimi_cli_ts/ui/shell/commands/editor.ts new file mode 100644 index 000000000..bb8091deb --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/editor.ts @@ -0,0 +1,47 @@ +import { loadConfig, saveConfig, type Config, type ConfigMeta } from "../../../config.ts"; +import { logger } from "../../../utils/logging.ts"; +import { which } from "bun"; + +export async function handleEditor(config: Config, configMeta: ConfigMeta, args: string): Promise { + const currentEditor = config.default_editor; + + if (!args.trim()) { + // Show current and available options + logger.info(`Current editor: ${currentEditor || "auto-detect"}`); + logger.info(""); + logger.info("Available editors:"); + logger.info(" /editor code --wait (VS Code)"); + logger.info(" /editor vim"); + logger.info(" /editor nano"); + logger.info(" /editor (any editor command)"); + logger.info(' /editor "" (auto-detect from $VISUAL/$EDITOR)'); + return; + } + + const newEditor = args.trim(); + + // Validate binary exists + if (newEditor) { + const binary = newEditor.split(/\s+/)[0]!; + const found = which(binary); + if (!found) { + logger.info(`Warning: '${binary}' not found in PATH. Saving anyway.`); + } + } + + if (newEditor === currentEditor) { + logger.info(`Editor is already set to: ${newEditor || "auto-detect"}`); + return; + } + + // Save to config + try { + const freshConfig = (await loadConfig(configMeta.sourceFile ?? undefined)).config; + freshConfig.default_editor = newEditor; + await saveConfig(freshConfig, configMeta.sourceFile ?? undefined); + config.default_editor = newEditor; + logger.info(`Editor set to: ${newEditor || "auto-detect"}`); + } catch (err) { + logger.info(`Failed to save config: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/src/kimi_cli_ts/ui/shell/commands/export_import.ts b/src/kimi_cli_ts/ui/shell/commands/export_import.ts new file mode 100644 index 000000000..87fe40654 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/export_import.ts @@ -0,0 +1,112 @@ +import type { Context } from "../../../soul/context.ts"; +import type { Session } from "../../../session.ts"; +import type { ContentPart } from "../../../types.ts"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { logger } from "../../../utils/logging.ts"; + +export async function handleExport(context: Context, session: Session, args: string): Promise { + const history = context.history; + if (!history.length) { + logger.info("Nothing to export - context is empty."); + return; + } + + // Determine output path + const outputDir = args.trim() || session.workDir; + const filename = `kimi-export-${session.id.slice(0, 8)}.md`; + const outputPath = join(outputDir, filename); + + // Build markdown + const lines: string[] = []; + lines.push(`# Kimi CLI Session Export`); + lines.push(`Session: ${session.id}`); + lines.push(`Exported: ${new Date().toISOString()}`); + lines.push(`Messages: ${history.length}`); + lines.push(`Tokens: ${context.tokenCountWithPending}`); + lines.push(""); + + for (let i = 0; i < history.length; i++) { + const msg = history[i]!; + lines.push(`## ${msg.role.toUpperCase()} (#${i + 1})`); + lines.push(""); + if (typeof msg.content === "string") { + lines.push(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content as ContentPart[]) { + if (part.type === "text") { + lines.push(part.text); + } else if (part.type === "tool_use") { + lines.push(`**Tool Call: ${part.name}**`); + lines.push("```json"); + lines.push(JSON.stringify(part.input, null, 2)); + lines.push("```"); + } else if (part.type === "tool_result") { + lines.push(`**Tool Result** (${part.isError ? "error" : "success"})`); + lines.push("```"); + lines.push(part.content); + lines.push("```"); + } + } + } + lines.push(""); + } + + try { + await Bun.write(outputPath, lines.join("\n")); + // Shorten home dir for display + const display = outputPath.replace(homedir(), "~"); + logger.info(`Exported ${history.length} messages to ${display}`); + logger.info("Note: The exported file may contain sensitive information."); + } catch (err) { + logger.info(`Failed to export: ${err instanceof Error ? err.message : err}`); + } +} + +export async function handleImport(context: Context, session: Session, args: string): Promise { + const target = args.trim(); + if (!target) { + logger.info("Usage: /import "); + return; + } + + // Check if it's a file path + const file = Bun.file(target); + if (await file.exists()) { + try { + const content = await file.text(); + // Append as a user message with import marker + await context.appendMessage({ + role: "user", + content: `[Imported from ${target}]\n\n${content}`, + }); + logger.info(`Imported ${content.length} chars from ${target}`); + } catch (err) { + logger.info(`Failed to import: ${err instanceof Error ? err.message : err}`); + } + return; + } + + // Try as session ID + const { Session: SessionClass } = await import("../../../session.ts"); + const otherSession = await SessionClass.find(session.workDir, target); + if (!otherSession) { + logger.info(`File not found and no session with ID: ${target}`); + return; + } + + // Read other session's context + const contextFile = Bun.file(otherSession.contextFile); + if (!(await contextFile.exists())) { + logger.info("Target session has no context."); + return; + } + + const text = await contextFile.text(); + const messageCount = text.split("\n").filter(l => l.trim()).length; + await context.appendMessage({ + role: "user", + content: `[Imported context from session ${target}]\n\n${text}`, + }); + logger.info(`Imported context from session ${target} (~${messageCount} entries)`); +} diff --git a/src/kimi_cli_ts/ui/shell/commands/feedback.ts b/src/kimi_cli_ts/ui/shell/commands/feedback.ts new file mode 100644 index 000000000..2df5416ff --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/feedback.ts @@ -0,0 +1,67 @@ +import { loadTokens } from "../../../auth/oauth.ts"; +import type { Config } from "../../../config.ts"; +import { logger } from "../../../utils/logging.ts"; +import { platform, release } from "node:os"; + +const ISSUE_URL = "https://github.com/MoonshotAI/kimi-cli/issues"; + +export async function handleFeedback( + config: Config, + args: string, + sessionId: string, + modelKey: string | undefined, +): Promise { + const content = args.trim(); + if (!content) { + logger.info("Usage: /feedback "); + logger.info(`Or submit at: ${ISSUE_URL}`); + return; + } + + // Try to find a provider with OAuth for posting feedback + let apiKey: string | null = null; + let baseUrl: string | null = null; + + for (const [, provider] of Object.entries(config.providers)) { + if (provider.oauth) { + const token = await loadTokens(provider.oauth); + if (token) { + apiKey = token.access_token; + baseUrl = provider.base_url.replace(/\/+$/, ""); + break; + } + } + } + + if (!apiKey || !baseUrl) { + logger.info(`No authenticated platform found. Please submit feedback at: ${ISSUE_URL}`); + return; + } + + const payload = { + session_id: sessionId, + content, + version: "2.0.0", + os: `${platform()} ${release()}`, + model: modelKey || null, + }; + + try { + const res = await fetch(`${baseUrl}/feedback`, { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (res.ok) { + logger.info(`Feedback submitted, thank you! Session ID: ${sessionId}`); + } else { + logger.info(`Failed to submit feedback (HTTP ${res.status}). Try: ${ISSUE_URL}`); + } + } catch (err) { + logger.info(`Failed to submit feedback: ${err instanceof Error ? err.message : err}`); + logger.info(`Please submit at: ${ISSUE_URL}`); + } +} diff --git a/src/kimi_cli_ts/ui/shell/commands/info.ts b/src/kimi_cli_ts/ui/shell/commands/info.ts new file mode 100644 index 000000000..f989d8c20 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/info.ts @@ -0,0 +1,81 @@ +/** + * Info slash-command handlers: /hooks, /mcp, /debug, /changelog + * Corresponds to Python ui/shell/commands/info.py + */ + +import type { HookEngine } from "../../../hooks/engine.ts"; +import type { Config } from "../../../config.ts"; +import type { Context } from "../../../soul/context.ts"; +import type { ContentPart } from "../../../types.ts"; +import { CHANGELOG } from "../../../utils/changelog.ts"; +import { logger } from "../../../utils/logging.ts"; + +export function handleHooks(hookEngine: HookEngine): void { + const summary = hookEngine.summary; + if (!Object.keys(summary).length) { + logger.info("No hooks configured. Add [[hooks]] sections to config.toml."); + return; + } + logger.info("\nConfigured Hooks:"); + for (const [event, count] of Object.entries(summary)) { + logger.info(` ${event}: ${count} hook(s)`); + } + logger.info(""); +} + +export function handleMcp(config: Config): void { + logger.info("MCP Configuration:"); + logger.info(` Client timeout: ${config.mcp.client.tool_call_timeout_ms}ms`); + logger.info("\nNote: MCP server management available via 'kimi mcp' CLI commands."); +} + +export function handleDebug(context: Context): void { + const history = context.history; + if (!history.length) { + logger.info("Context is empty - no messages yet."); + return; + } + + logger.info(`\n=== Context Debug ===`); + logger.info(`Total messages: ${history.length}`); + logger.info(`Token count: ${context.tokenCountWithPending}`); + logger.info(`---`); + + for (let i = 0; i < history.length; i++) { + const msg = history[i]!; + const role = msg.role.toUpperCase(); + + if (typeof msg.content === "string") { + const preview = + msg.content.length > 200 + ? msg.content.slice(0, 200) + "..." + : msg.content; + logger.info(`#${i + 1} [${role}] ${preview}`); + } else if (Array.isArray(msg.content)) { + const parts = msg.content as ContentPart[]; + const summary = parts + .map((p: any) => { + if (p.type === "text") + return p.text.length > 100 ? p.text.slice(0, 100) + "..." : p.text; + if (p.type === "tool_use") return `[tool_use: ${p.name}]`; + if (p.type === "tool_result") return `[tool_result]`; + if (p.type === "image") return `[image]`; + return `[${p.type}]`; + }) + .join(" | "); + logger.info(`#${i + 1} [${role}] ${summary}`); + } + } + logger.info(`=== End Debug ===\n`); +} + +export function handleChangelog(): void { + logger.info("\n Release Notes:\n"); + for (const [version, entry] of Object.entries(CHANGELOG)) { + logger.info(` ${version}: ${entry.description}`); + for (const item of entry.entries) { + logger.info(` \u2022 ${item}`); + } + logger.info(""); + } +} diff --git a/src/kimi_cli_ts/ui/shell/commands/init.ts b/src/kimi_cli_ts/ui/shell/commands/init.ts new file mode 100644 index 000000000..1e7516934 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/init.ts @@ -0,0 +1,64 @@ +/** + * /init slash command handler. + * Analyzes the codebase and generates an AGENTS.md file. + * Corresponds to Python soul/slash.py init command. + */ + +import { logger } from "../../../utils/logging.ts"; + +/** + * Handle /init command — trigger codebase analysis and AGENTS.md generation. + * In the Python version this creates a temporary context, runs a prompt through the LLM, + * then reloads the generated AGENTS.md. For now we provide a simplified version. + */ +export async function handleInit(workDir: string): Promise { + const agentsMdPath = `${workDir}/AGENTS.md`; + + // Check if AGENTS.md already exists + const existing = Bun.file(agentsMdPath); + if (await existing.exists()) { + logger.info(`AGENTS.md already exists at ${agentsMdPath}`); + logger.info("To regenerate, delete it first and run /init again."); + return null; + } + + logger.info("Analyzing codebase to generate AGENTS.md..."); + logger.info("Note: Full /init requires an LLM call. Generating a basic template."); + + // Generate a basic template + let lsOutput = ""; + try { + lsOutput = await Bun.$`ls -la ${workDir}`.quiet().text(); + } catch { + lsOutput = "(unable to list directory)"; + } + + const template = [ + "# AGENTS.md", + "", + "## Project Overview", + "", + "", + "", + "## Directory Structure", + "", + "```", + lsOutput.trim(), + "```", + "", + "## Conventions", + "", + "", + "", + ].join("\n"); + + try { + await Bun.write(agentsMdPath, template); + logger.info(`Generated AGENTS.md at ${agentsMdPath}`); + logger.info("Edit it to describe your project for better AI assistance."); + return template; + } catch (err) { + logger.info(`Failed to generate AGENTS.md: ${err instanceof Error ? err.message : err}`); + return null; + } +} diff --git a/src/kimi_cli_ts/ui/shell/commands/login.ts b/src/kimi_cli_ts/ui/shell/commands/login.ts new file mode 100644 index 000000000..46e48091c --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/login.ts @@ -0,0 +1,275 @@ +/** + * /login and /logout slash command handlers. + * Corresponds to Python's ui/shell/oauth.py + setup.py. + * + * /login uses a multi-level panel flow: + * 1. Select platform (choice) + * 2a. Kimi Code → OAuth device-code flow (events shown in message list) + * 2b. Others → Enter API key (input) → Select model (choice) → Thinking (choice) + */ + +import { + loginKimiCode, + logoutKimiCode, +} from "../../../auth/oauth.ts"; +import { + PLATFORMS, + KIMI_CODE_PLATFORM_ID, + getPlatformById, + managedProviderKey, + managedModelKey, + listModels, + deriveModelCapabilities, + type Platform, + type ModelInfo, +} from "../../../auth/platforms.ts"; +import { saveConfig, type Config } from "../../../config.ts"; +import type { CommandPanelConfig } from "../../../types.ts"; + +type Notify = (title: string, body: string) => void; + +// ── Kimi Code OAuth login (via async generator) ────────── + +async function runLoginKimiCode(config: Config, notify: Notify): Promise { + for await (const event of loginKimiCode(config)) { + switch (event.type) { + case "info": + case "verification_url": + case "waiting": + notify("Login", event.message); + break; + case "success": + notify("Login", `✓ ${event.message}`); + break; + case "error": + notify("Login", `✗ ${event.message}`); + break; + } + } +} + +// ── Non-OAuth platform setup (multi-step wizard) ───────── + +/** + * Step 2: After API key entered, verify it and show model selection. + */ +function buildModelSelectPanel( + platform: Platform, + apiKey: string, + config: Config, + notify: Notify, +): CommandPanelConfig { + // We return a content panel first as "loading", then transition + return { + type: "content", + title: `${platform.name} — Verifying API key...`, + content: "Fetching available models, please wait...", + }; +} + +/** + * Actually fetch models and build the model choice panel. + */ +async function fetchAndBuildModelPanel( + platform: Platform, + apiKey: string, + config: Config, + notify: Notify, +): Promise { + let models: ModelInfo[]; + try { + models = await listModels(platform, apiKey); + } catch (err: any) { + if (err?.message?.includes("401")) { + notify("Login", `✗ API key verification failed. Please check your key.`); + } else { + notify("Login", `✗ Failed to fetch models: ${err?.message ?? err}`); + } + return; + } + + if (!models.length) { + notify("Login", "✗ No models available for this platform."); + return; + } + + return { + type: "choice", + title: `${platform.name} — Select Model`, + items: models.map((m) => ({ + label: m.id, + value: m.id, + description: `ctx: ${m.contextLength}`, + })), + onSelect: (modelId: string): CommandPanelConfig | void => { + const model = models.find((m) => m.id === modelId); + if (!model) return; + const caps = deriveModelCapabilities(model); + + // If model supports optional thinking, ask + if (caps.has("thinking") && !caps.has("always_thinking")) { + return buildThinkingPanel(platform, apiKey, model, models, config, notify); + } + // Otherwise, auto-decide + const thinking = caps.has("always_thinking") || caps.has("thinking"); + applyNonOAuthConfig(platform, apiKey, model, models, thinking, config, notify); + }, + }; +} + +/** + * Step 3: Select thinking mode. + */ +function buildThinkingPanel( + platform: Platform, + apiKey: string, + selectedModel: ModelInfo, + models: ModelInfo[], + config: Config, + notify: Notify, +): CommandPanelConfig { + return { + type: "choice", + title: `${platform.name} — Thinking Mode`, + items: [ + { label: "On", value: "on", description: "Enable extended thinking" }, + { label: "Off", value: "off", description: "Standard mode" }, + ], + onSelect: (value: string) => { + const thinking = value === "on"; + applyNonOAuthConfig(platform, apiKey, selectedModel, models, thinking, config, notify); + }, + }; +} + +/** + * Apply config for non-OAuth platforms (API key based). + * Corresponds to Python's _apply_setup_result(). + */ +function applyNonOAuthConfig( + platform: Platform, + apiKey: string, + selectedModel: ModelInfo, + models: ModelInfo[], + thinking: boolean, + config: Config, + notify: Notify, +): void { + const providerKey = managedProviderKey(platform.id); + + config.providers[providerKey] = { + type: "kimi", + base_url: platform.baseUrl, + api_key: apiKey, + }; + + // Remove old models for this provider + for (const [key, model] of Object.entries(config.models)) { + if (model.provider === providerKey) delete config.models[key]; + } + + // Add all available models + for (const m of models) { + const caps = deriveModelCapabilities(m); + config.models[managedModelKey(platform.id, m.id)] = { + provider: providerKey, + model: m.id, + max_context_size: m.contextLength, + capabilities: caps.size > 0 ? ([...caps] as any) : undefined, + }; + } + + config.default_model = managedModelKey(platform.id, selectedModel.id); + config.default_thinking = thinking; + + if (platform.searchUrl) { + config.services = config.services ?? {}; + (config.services as any).moonshot_search = { + base_url: platform.searchUrl, + api_key: apiKey, + }; + } + if (platform.fetchUrl) { + config.services = config.services ?? {}; + (config.services as any).moonshot_fetch = { + base_url: platform.fetchUrl, + api_key: apiKey, + }; + } + + saveConfig(config).then(() => { + const thinkLabel = thinking ? "on" : "off"; + notify("Login", [ + `✓ Setup complete!`, + ` Platform: ${platform.name}`, + ` Model: ${selectedModel.id}`, + ` Thinking: ${thinkLabel}`, + ].join("\n")); + }).catch((err) => { + notify("Login", `✗ Failed to save config: ${err}`); + }); +} + +// ── Public API ─────────────────────────────────────────── + +/** + * Handle /login — dispatches to the correct flow based on platform. + * When called without a panel (e.g. `/login` typed directly), defaults to Kimi Code. + */ +export async function handleLogin(config: Config, notify: Notify): Promise { + await runLoginKimiCode(config, notify); +} + +/** + * Handle /logout — delete stored OAuth tokens and clean up config. + */ +export async function handleLogout(config: Config, notify: Notify): Promise { + for await (const event of logoutKimiCode(config)) { + switch (event.type) { + case "success": + notify("Logout", `✓ ${event.message}`); + break; + case "error": + notify("Logout", `✗ ${event.message}`); + break; + default: + notify("Logout", event.message); + } + } +} + +/** + * Create panel config for /login — platform selection → multi-step wizard. + * Corresponds to Python's select_platform() → setup_platform(). + */ +export function createLoginPanel(config: Config, notify: Notify): CommandPanelConfig { + return { + type: "choice", + title: "Login — Select Platform", + items: PLATFORMS.map((p) => ({ + label: p.name, + value: p.id, + })), + onSelect: (platformId: string): CommandPanelConfig | Promise | void => { + const platform = getPlatformById(platformId); + if (!platform) return; + + if (platform.id === KIMI_CODE_PLATFORM_ID) { + // Kimi Code uses OAuth — events go to message list + runLoginKimiCode(config, notify); + return; // Close panel, events stream into chat + } + + // Other platforms: multi-step wizard + return { + type: "input", + title: `${platform.name} — Enter API Key`, + placeholder: "Paste your API key here...", + password: true, + onSubmit: (apiKey: string): Promise => { + return fetchAndBuildModelPanel(platform, apiKey, config, notify); + }, + }; + }, + }; +} diff --git a/src/kimi_cli_ts/ui/shell/commands/misc.ts b/src/kimi_cli_ts/ui/shell/commands/misc.ts new file mode 100644 index 000000000..ac491f37e --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/misc.ts @@ -0,0 +1,20 @@ +import { logger } from "../../../utils/logging.ts"; + +export function handleWeb(sessionId: string): void { + logger.info("Web UI is not yet available in the TypeScript version."); + logger.info("Use 'kimi web' CLI command to start the web server."); +} + +export function handleVis(sessionId: string): void { + logger.info("Visualizer is not yet available in the TypeScript version."); + logger.info("Use 'kimi vis' CLI command to start the visualizer."); +} + +export function handleReload(): void { + logger.info("Configuration reloaded. If changes don't take effect, please restart the CLI."); +} + +export function handleTask(): void { + logger.info("Background task browser is not yet available in the TypeScript version."); + logger.info("Background tasks are managed automatically during agent execution."); +} diff --git a/src/kimi_cli_ts/ui/shell/commands/model.ts b/src/kimi_cli_ts/ui/shell/commands/model.ts new file mode 100644 index 000000000..c21ec222c --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/model.ts @@ -0,0 +1,31 @@ +import type { Config, ConfigMeta } from "../../../config.ts"; +import { logger } from "../../../utils/logging.ts"; + +export async function handleModel(config: Config, configMeta: ConfigMeta): Promise { + if (!Object.keys(config.models).length) { + logger.info("No models configured. Run /login to set up."); + return; + } + + if (!configMeta.isFromDefaultLocation) { + logger.info("Model switching requires the default config file."); + return; + } + + const currentModel = config.default_model; + logger.info("Available models:"); + + const modelNames = Object.keys(config.models).sort(); + for (let i = 0; i < modelNames.length; i++) { + const name = modelNames[i]!; + const modelCfg = config.models[name]!; + const providerName = modelCfg.provider; + const current = name === currentModel ? " (current)" : ""; + const capabilities = modelCfg.capabilities?.join(", ") || "none"; + logger.info(` [${i + 1}] ${modelCfg.model} (${providerName})${current} [${capabilities}]`); + } + + logger.info(""); + logger.info("To switch models, use: kimi --model "); + logger.info("Or edit ~/.kimi/config.toml and set default_model"); +} diff --git a/src/kimi_cli_ts/ui/shell/commands/session.ts b/src/kimi_cli_ts/ui/shell/commands/session.ts new file mode 100644 index 000000000..853d005b1 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/session.ts @@ -0,0 +1,53 @@ +/** + * Session-related slash commands: /new, /sessions, /title + */ + +import { Session, loadSessionState, saveSessionState } from "../../../session.ts"; +import { logger } from "../../../utils/logging.ts"; + +export async function handleNew(session: Session): Promise { + const workDir = session.workDir; + if (await session.isEmpty()) { + await session.delete(); + } + const newSession = await Session.create(workDir); + logger.info(`New session created: ${newSession.id}. Please restart to switch.`); +} + +export async function handleSessions(session: Session): Promise { + const sessions = await Session.list(session.workDir); + if (sessions.length === 0) { + logger.info("No sessions found."); + return; + } + for (const s of sessions) { + const current = s.id === session.id ? " (current)" : ""; + const timeAgo = formatRelativeTime(s.updatedAt); + logger.info(` ${s.title} (${s.id}) - ${timeAgo}${current}`); + } +} + +export async function handleTitle(session: Session, args: string): Promise { + if (!args.trim()) { + logger.info(`Session title: ${session.title}`); + return; + } + const newTitle = args.trim().slice(0, 200); + const freshState = await loadSessionState(session.dir); + freshState.custom_title = newTitle; + freshState.title_generated = true; + await saveSessionState(freshState, session.dir); + session.state.custom_title = newTitle; + session.title = newTitle; + logger.info(`Session title set to: ${newTitle}`); +} + +function formatRelativeTime(timestamp: number): string { + if (!timestamp) return "unknown"; + const now = Date.now() / 1000; + const diff = now - timestamp; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; + return `${Math.floor(diff / 86400)} days ago`; +} diff --git a/src/kimi_cli_ts/ui/shell/commands/usage.ts b/src/kimi_cli_ts/ui/shell/commands/usage.ts new file mode 100644 index 000000000..04fdc85b3 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/commands/usage.ts @@ -0,0 +1,69 @@ +import { loadTokens } from "../../../auth/oauth.ts"; +import type { Config } from "../../../config.ts"; +import { logger } from "../../../utils/logging.ts"; + +export async function handleUsage(config: Config, modelKey: string | undefined): Promise { + if (!modelKey || !config.models[modelKey]) { + logger.info("No model selected. Run /login first."); + return; + } + const modelCfg = config.models[modelKey]!; + const providerCfg = config.providers[modelCfg.provider]; + if (!providerCfg) { + logger.info("Provider not found."); + return; + } + + // Resolve API key (try OAuth token first) + let apiKey = providerCfg.api_key; + if (providerCfg.oauth) { + const token = await loadTokens(providerCfg.oauth); + if (token) apiKey = token.access_token; + } + + const baseUrl = providerCfg.base_url.replace(/\/+$/, ""); + const usageUrl = `${baseUrl}/usages`; + + try { + const res = await fetch(usageUrl, { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + if (!res.ok) { + if (res.status === 401) logger.info("Authorization failed. Please check your API key."); + else if (res.status === 404) logger.info("Usage endpoint not available."); + else logger.info(`Failed to fetch usage (HTTP ${res.status}).`); + return; + } + const data = await res.json() as Record; + + // Parse and display usage + const usage = data.usage; + if (usage) { + const limit = usage.limit || 0; + const used = usage.used ?? (limit - (usage.remaining || 0)); + const pct = limit > 0 ? ((limit - used) / limit * 100).toFixed(0) : "?"; + const label = usage.name || usage.title || "Weekly limit"; + logger.info(`\n API Usage:`); + logger.info(` ${label}: ${used}/${limit} used (${pct}% remaining)`); + } + + // Parse limits array + const limits = data.limits; + if (Array.isArray(limits) && limits.length > 0) { + for (const item of limits) { + const detail = item.detail || item; + const limit = detail.limit || 0; + const used = detail.used ?? (limit - (detail.remaining || 0)); + const pct = limit > 0 ? ((limit - used) / limit * 100).toFixed(0) : "?"; + const name = item.name || item.title || detail.name || "Limit"; + logger.info(` ${name}: ${used}/${limit} used (${pct}% remaining)`); + } + } + + if (!usage && (!limits || !limits.length)) { + logger.info("No usage data available."); + } + } catch (err) { + logger.info(`Failed to fetch usage: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/src/kimi_cli_ts/ui/shell/console.ts b/src/kimi_cli_ts/ui/shell/console.ts new file mode 100644 index 000000000..8483fcf9d --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/console.ts @@ -0,0 +1,24 @@ +/** + * Console utilities — corresponds to Python's ui/shell/console.py + * Terminal size detection and helpers. + */ + +/** + * Get current terminal dimensions. + */ +export function getTerminalSize(): { columns: number; rows: number } { + return { + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + }; +} + +/** + * Listen for terminal resize events. + */ +export function onResize(callback: () => void): () => void { + process.stdout.on("resize", callback); + return () => { + process.stdout.off("resize", callback); + }; +} diff --git a/src/kimi_cli_ts/ui/shell/events.ts b/src/kimi_cli_ts/ui/shell/events.ts new file mode 100644 index 000000000..d9a5aa6a4 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/events.ts @@ -0,0 +1,71 @@ +/** + * Wire event types for UI consumption. + * Simplified interface that UI components use to render messages. + */ + +import type { + StatusUpdate, + ApprovalRequest, + ToolResult, + DisplayBlock, +} from "../../wire/types"; + +// ── UI Message Types ────────────────────────────────────── + +export type UIMessageRole = "user" | "assistant" | "system" | "tool"; + +export interface TextSegment { + type: "text"; + text: string; +} + +export interface ThinkSegment { + type: "think"; + text: string; +} + +export interface ToolCallSegment { + type: "tool_call"; + id: string; + name: string; + arguments: string; + result?: ToolResult; + collapsed: boolean; +} + +export type MessageSegment = TextSegment | ThinkSegment | ToolCallSegment; + +export interface UIMessage { + id: string; + role: UIMessageRole; + segments: MessageSegment[]; + timestamp: number; +} + +// ── Wire Events (simplified for UI) ─────────────────────── + +export type WireUIEvent = + | { type: "turn_begin"; userInput: string } + | { type: "turn_end" } + | { type: "step_begin"; n: number } + | { type: "step_interrupted" } + | { type: "text_delta"; text: string } + | { type: "think_delta"; text: string } + | { type: "tool_call"; id: string; name: string; arguments: string } + | { type: "tool_call_delta"; id: string; arguments: string } + | { type: "tool_result"; toolCallId: string; result: ToolResult } + | { type: "approval_request"; request: ApprovalRequest } + | { type: "approval_response"; requestId: string; response: string } + | { type: "question_request"; request: import("../../wire/types.ts").QuestionRequest } + | { type: "question_response"; requestId: string; answers: Record } + | { type: "status_update"; status: StatusUpdate } + | { type: "compaction_begin" } + | { type: "compaction_end" } + | { type: "notification"; title: string; body: string; severity?: string } + | { type: "plan_display"; content: string; filePath: string } + | { type: "hook_triggered"; event: string; target: string; hookCount: number } + | { type: "hook_resolved"; event: string; target: string; action: string; reason: string; durationMs: number } + | { type: "mcp_loading_begin" } + | { type: "mcp_loading_end" } + | { type: "subagent_event"; parentToolCallId: string | null; agentId: string | null; subagentType: string | null; event: Record } + | { type: "error"; message: string; retryable?: boolean; retryAfter?: number }; diff --git a/src/kimi_cli_ts/ui/shell/index.ts b/src/kimi_cli_ts/ui/shell/index.ts new file mode 100644 index 000000000..2e8868240 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/index.ts @@ -0,0 +1,48 @@ +export { Shell } from "./Shell.tsx"; +export type { ShellProps } from "./Shell.tsx"; +export { Prompt } from "./Prompt.tsx"; +export { + MessageList, + StreamingText, + ThinkingView, + ToolCallView, + ErrorRecoveryView, + classifyApiError, + NotificationView, + StatusView, + PlanDisplayView, + HookTriggeredView, + HookResolvedView, +} from "./Visualize.tsx"; +export type { ErrorInfo, NotificationViewProps, StatusViewProps } from "./Visualize.tsx"; +export { ApprovalPanel } from "./ApprovalPanel.tsx"; +export type { ApprovalPanelProps } from "./ApprovalPanel.tsx"; +export { QuestionPanel } from "./QuestionPanel.tsx"; +export type { QuestionPanelProps } from "./QuestionPanel.tsx"; +export { DebugPanel } from "./DebugPanel.tsx"; +export type { DebugPanelProps, DebugMessage, ContextInfo } from "./DebugPanel.tsx"; +export { UsagePanel, parseUsagePayload } from "./UsagePanel.tsx"; +export type { UsagePanelProps, UsageRow } from "./UsagePanel.tsx"; +export { TaskBrowser } from "./TaskBrowser.tsx"; +export type { TaskBrowserProps, TaskView, TaskViewSpec, TaskViewRuntime, TaskStatus } from "./TaskBrowser.tsx"; +export { SetupWizard } from "./SetupWizard.tsx"; +export type { SetupWizardProps, SetupResult, PlatformInfo, ModelInfo } from "./SetupWizard.tsx"; +export { ReplayPanel, buildReplayTurnsFromEvents } from "./ReplayPanel.tsx"; +export type { ReplayPanelProps, ReplayTurn, ReplayEvent } from "./ReplayPanel.tsx"; +export { useKeyboard } from "./keyboard.ts"; +export type { KeyAction } from "./keyboard.ts"; +export { getTerminalSize, onResize } from "./console.ts"; +export { + createShellSlashCommands, + parseSlashCommand, + findSlashCommand, +} from "./slash.ts"; +export type { + WireUIEvent, + UIMessage, + UIMessageRole, + MessageSegment, + TextSegment, + ThinkSegment, + ToolCallSegment, +} from "./events.ts"; diff --git a/src/kimi_cli_ts/ui/shell/keyboard.ts b/src/kimi_cli_ts/ui/shell/keyboard.ts new file mode 100644 index 000000000..ccfd76503 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/keyboard.ts @@ -0,0 +1,105 @@ +/** + * Keyboard handling — corresponds to Python's ui/shell/keyboard.py + * Uses Ink's useInput hook for keyboard events in the React tree. + * + * Behavior: + * - Ctrl+C ×1: interrupt current streaming turn + * - Ctrl+C ×2 (within 500ms): exit the application + * - Esc ×1: interrupt current streaming turn + * - Esc ×2 (within 500ms): clear the input box + * - Shift+Tab: toggle plan mode + */ + +import { useInput, useApp } from "ink"; +import { useRef } from "react"; + +export type KeyAction = + | "interrupt" + | "exit" + | "clear-input" + | "toggle-plan-mode"; + +export interface UseKeyboardOptions { + onAction: (action: KeyAction) => void; + /** Whether keyboard input is active (default true) */ + active?: boolean; +} + +const DOUBLE_PRESS_WINDOW = 500; // ms + +/** + * Hook that handles global keyboard shortcuts for the shell. + * + * Ctrl+C: 1st press = interrupt, 2nd press within 500ms = exit + * Escape: 1st press = interrupt, 2nd press within 500ms = clear input + */ +export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { + const { exit } = useApp(); + + // Ctrl+C double-press tracking + const ctrlCCount = useRef(0); + const ctrlCTimer = useRef | null>(null); + + // Esc double-press tracking + const escCount = useRef(0); + const escTimer = useRef | null>(null); + + useInput( + (input, key) => { + // ── Ctrl+C ──────────────────────────────────── + if (input === "c" && key.ctrl) { + // Reset Esc counter on Ctrl+C + escCount.current = 0; + + ctrlCCount.current += 1; + if (ctrlCCount.current >= 2) { + // Double Ctrl+C → exit + ctrlCCount.current = 0; + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + exit(); + return; + } + // Start/reset the window timer + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + ctrlCTimer.current = setTimeout(() => { + ctrlCCount.current = 0; + }, DOUBLE_PRESS_WINDOW); + onAction("interrupt"); + return; + } + + // ── Escape ──────────────────────────────────── + if (key.escape) { + // Reset Ctrl+C counter on Esc + ctrlCCount.current = 0; + + escCount.current += 1; + if (escCount.current >= 2) { + // Double Esc → clear input + escCount.current = 0; + if (escTimer.current) clearTimeout(escTimer.current); + onAction("clear-input"); + return; + } + // Start/reset the window timer + if (escTimer.current) clearTimeout(escTimer.current); + escTimer.current = setTimeout(() => { + escCount.current = 0; + }, DOUBLE_PRESS_WINDOW); + onAction("interrupt"); + return; + } + + // ── Shift+Tab ───────────────────────────────── + if (key.shift && key.tab) { + onAction("toggle-plan-mode"); + return; + } + + // Any other key resets both counters + ctrlCCount.current = 0; + escCount.current = 0; + }, + { isActive: active }, + ); +} diff --git a/src/kimi_cli_ts/ui/shell/slash.ts b/src/kimi_cli_ts/ui/shell/slash.ts new file mode 100644 index 000000000..897261e40 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/slash.ts @@ -0,0 +1,303 @@ +/** + * Shell slash commands — corresponds to Python's ui/shell/slash.py. + * Shell-level commands: /clear, /help, /exit, /theme, /version. + */ + +import type { SlashCommand, CommandPanelConfig } from "../../types"; +import { getActiveTheme } from "../theme.ts"; + +export type SlashCommandHandler = (args: string) => Promise; + +export interface ShellSlashContext { + clearMessages: () => void; + exit: () => void; + setTheme: (theme: "dark" | "light") => void; + getAllCommands: () => SlashCommand[]; + pushNotification: (title: string, body: string) => void; + /** Get session dir + workDir + title for /undo and /fork. */ + getSessionInfo?: () => { sessionDir: string; workDir: string; title: string } | null; + /** Trigger a reload with a new session (and optional prefill text). */ + triggerReload?: (sessionId: string, prefillText?: string) => void; +} + +/** + * Create shell-level slash commands. + */ +export function createShellSlashCommands( + ctx: ShellSlashContext, +): SlashCommand[] { + return [ + { + name: "clear", + description: "Clear conversation history", + aliases: ["cls"], + handler: async () => { + ctx.clearMessages(); + }, + }, + { + name: "exit", + description: "Exit the application", + aliases: ["quit", "q"], + handler: async () => { + ctx.exit(); + }, + }, + { + name: "help", + description: "Show help information", + aliases: ["h", "?"], + handler: async () => { + // Fallback when panel is not used (e.g. direct /help invocation) + const allCmds = ctx.getAllCommands(); + ctx.pushNotification("Help", formatHelp(allCmds)); + }, + panel: (): CommandPanelConfig => { + const allCmds = ctx.getAllCommands(); + return { + type: "content", + title: "Help", + content: formatHelp(allCmds), + }; + }, + }, + { + name: "theme", + description: "Toggle dark/light theme", + handler: async (args: string) => { + const theme = args.trim() as "dark" | "light"; + if (theme === "dark" || theme === "light") { + ctx.setTheme(theme); + ctx.pushNotification("Theme", `Switched to ${theme} theme.`); + } else { + // Toggle + const current = getActiveTheme(); + const next = current === "dark" ? "light" : "dark"; + ctx.setTheme(next); + ctx.pushNotification("Theme", `Switched to ${next} theme.`); + } + }, + panel: (): CommandPanelConfig => { + const current = getActiveTheme(); + return { + type: "choice", + title: "Theme", + items: [ + { label: "🌙 Dark", value: "dark", current: current === "dark" }, + { label: "☀️ Light", value: "light", current: current === "light" }, + ], + onSelect: (value: string) => { + const theme = value as "dark" | "light"; + ctx.setTheme(theme); + ctx.pushNotification("Theme", `Switched to ${theme} theme.`); + }, + }; + }, + }, + { + name: "version", + description: "Show version information", + handler: async () => { + ctx.pushNotification("Version", "kimi-cli v2.0.0 (TypeScript)"); + }, + }, + { + name: "undo", + description: "Undo: fork the session at a previous turn and retry", + handler: async () => { + if (!ctx.getSessionInfo || !ctx.triggerReload) return; + const info = ctx.getSessionInfo(); + if (!info) { + ctx.pushNotification("Undo", "No active session."); + return; + } + const { enumerateTurns, forkSession } = await import("../../session_fork.ts"); + const { join } = await import("node:path"); + + const wirePath = join(info.sessionDir, "wire.jsonl"); + const turns = enumerateTurns(wirePath); + if (turns.length === 0) { + ctx.pushNotification("Undo", "No turns found in this session."); + return; + } + + // Build choices panel + const items = turns.map((t) => { + const firstLine = t.userText.split("\n", 1)[0] ?? ""; + const label = firstLine.length > 80 ? firstLine.slice(0, 77) + "..." : firstLine; + return { label: `[${t.index}] ${label}`, value: String(t.index), current: t.index === turns.length - 1 }; + }); + + // Use the panel system for selection + // (Panel-based selection is handled externally; here we use a simple last-turn undo) + // For the panel-based approach, we expose a panel config: + ctx.pushNotification("Undo", "Select a turn from the /undo panel."); + }, + panel: (): CommandPanelConfig => { + if (!ctx.getSessionInfo || !ctx.triggerReload) { + return { type: "content", title: "Undo", content: "No active session." }; + } + const info = ctx.getSessionInfo(); + if (!info) { + return { type: "content", title: "Undo", content: "No active session." }; + } + + // Synchronous — enumerateTurns is sync in our TS impl + const { enumerateTurns, forkSession } = require("../../session_fork.ts"); + const { join } = require("node:path"); + + const wirePath = join(info.sessionDir, "wire.jsonl"); + const turns = enumerateTurns(wirePath) as import("../../session_fork.ts").TurnInfo[]; + if (turns.length === 0) { + return { type: "content", title: "Undo", content: "No turns found in this session." }; + } + + const items = turns.map((t) => { + const firstLine = t.userText.split("\n", 1)[0] ?? ""; + const label = firstLine.length > 80 ? firstLine.slice(0, 77) + "..." : firstLine; + return { label: `[${t.index}] ${label}`, value: String(t.index), current: t.index === turns.length - 1 }; + }); + + return { + type: "choice", + title: "Undo — select a turn to redo", + items, + onSelect: async (value: string) => { + const turnIndex = parseInt(value, 10); + const selectedTurn = turns[turnIndex]; + if (!selectedTurn) return; + + const userText = selectedTurn.userText; + + try { + let newSessionId: string; + if (turnIndex === 0) { + // Fork with no history — just the user text + const { Session } = await import("../../session.ts"); + const { loadSessionState, saveSessionState } = await import("../../session.ts"); + const newSession = await Session.create(info.workDir); + newSessionId = newSession.id; + const newState = await loadSessionState(newSession.dir); + newState.custom_title = `Undo: ${info.title}`; + newState.title_generated = true; + await saveSessionState(newState, newSession.dir); + } else { + const forkTurnIndex = turnIndex - 1; + newSessionId = await forkSession({ + sourceSessionDir: info.sessionDir, + workDir: info.workDir, + turnIndex: forkTurnIndex, + titlePrefix: "Undo", + sourceTitle: info.title, + }); + } + + ctx.pushNotification("Undo", `Forked at turn ${turnIndex}. Switching to new session...`); + ctx.triggerReload!(newSessionId, userText); + } catch (err: any) { + ctx.pushNotification("Undo", `Error: ${err.message ?? String(err)}`); + } + }, + }; + }, + }, + { + name: "fork", + description: "Fork the current session (copy all history)", + handler: async () => { + if (!ctx.getSessionInfo || !ctx.triggerReload) { + ctx.pushNotification("Fork", "No active session."); + return; + } + const info = ctx.getSessionInfo(); + if (!info) { + ctx.pushNotification("Fork", "No active session."); + return; + } + + try { + const { forkSession } = await import("../../session_fork.ts"); + const newSessionId = await forkSession({ + sourceSessionDir: info.sessionDir, + workDir: info.workDir, + titlePrefix: "Fork", + sourceTitle: info.title, + }); + + ctx.pushNotification("Fork", "Session forked. Switching to new session..."); + ctx.triggerReload(newSessionId); + } catch (err: any) { + ctx.pushNotification("Fork", `Error: ${err.message ?? String(err)}`); + } + }, + }, + ]; +} + +/** + * Parse a slash command from input string. + * Returns null if not a slash command. + */ +export function parseSlashCommand( + input: string, +): { name: string; args: string } | null { + if (!input.startsWith("/")) return null; + const trimmed = input.slice(1).trim(); + if (!trimmed) return null; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) { + return { name: trimmed, args: "" }; + } + return { + name: trimmed.slice(0, spaceIdx), + args: trimmed.slice(spaceIdx + 1).trim(), + }; +} + +/** + * Find a slash command by name or alias. + */ +export function findSlashCommand( + commands: SlashCommand[], + name: string, +): SlashCommand | undefined { + return commands.find( + (cmd) => cmd.name === name || cmd.aliases?.includes(name), + ); +} + +function formatHelp(commands: SlashCommand[]): string { + const lines = [ + "Kimi Code CLI — Help", + "", + "Keyboard Shortcuts:", + " Ctrl+X Toggle agent/shell mode", + " Shift+Tab Toggle plan mode", + " Ctrl+O Edit in external editor", + " Ctrl+J / Alt+Enter Insert newline", + " Ctrl+V Paste (supports images)", + " Ctrl+D Exit", + " Ctrl+C Interrupt", + "", + "Slash Commands:", + ]; + + // Deduplicate by name and sort + const seen = new Set(); + const sorted = commands + .filter((c) => { + if (seen.has(c.name)) return false; + seen.add(c.name); + return true; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const cmd of sorted) { + const aliases = cmd.aliases?.length ? `, /${cmd.aliases.join(", /")}` : ""; + const nameStr = `/${cmd.name}${aliases}`; + lines.push(` ${nameStr.padEnd(22)} ${cmd.description}`); + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/src/kimi_cli_ts/ui/theme.ts b/src/kimi_cli_ts/ui/theme.ts new file mode 100644 index 000000000..324abcac1 --- /dev/null +++ b/src/kimi_cli_ts/ui/theme.ts @@ -0,0 +1,267 @@ +/** + * Centralized terminal color theme definitions. + * Corresponds to Python's ui/theme.py. + * + * All UI-facing colors live here so that switching between dark and light + * terminal themes only requires changing the active ThemeName. + */ + +import chalk, { type ChalkInstance } from "chalk"; + +export type ThemeName = "dark" | "light"; + +// ── Diff Colors ──────────────────────────────────────────── + +export interface DiffColors { + addBg: string; + delBg: string; + addHl: string; + delHl: string; +} + +const DIFF_DARK: DiffColors = { + addBg: "#12261e", + delBg: "#2d1214", + addHl: "#1a4a2e", + delHl: "#5c1a1d", +}; + +const DIFF_LIGHT: DiffColors = { + addBg: "#dafbe1", + delBg: "#ffebe9", + addHl: "#aff5b4", + delHl: "#ffc1c0", +}; + +// ── Toolbar Colors ───────────────────────────────────────── + +export interface ToolbarColors { + separator: string; + yoloLabel: string; + planLabel: string; + planPrompt: string; + cwd: string; + bgTasks: string; + tip: string; +} + +const TOOLBAR_DARK: ToolbarColors = { + separator: "#4d4d4d", + yoloLabel: "#ffff00", + planLabel: "#00aaff", + planPrompt: "#00aaff", + cwd: "#666666", + bgTasks: "#888888", + tip: "#555555", +}; + +const TOOLBAR_LIGHT: ToolbarColors = { + separator: "#d1d5db", + yoloLabel: "#b45309", + planLabel: "#2563eb", + planPrompt: "#2563eb", + cwd: "#6b7280", + bgTasks: "#4b5563", + tip: "#9ca3af", +}; + +// ── MCP Prompt Colors ────────────────────────────────────── + +export interface MCPPromptColors { + text: string; + detail: string; + connected: string; + connecting: string; + pending: string; + failed: string; +} + +const MCP_PROMPT_DARK: MCPPromptColors = { + text: "#d4d4d4", + detail: "#7c8594", + connected: "#56d364", + connecting: "#56a4ff", + pending: "#f2cc60", + failed: "#ff7b72", +}; + +const MCP_PROMPT_LIGHT: MCPPromptColors = { + text: "#374151", + detail: "#6b7280", + connected: "#166534", + connecting: "#1d4ed8", + pending: "#92400e", + failed: "#dc2626", +}; + +// ── Message Colors ───────────────────────────────────────── + +export interface MessageColors { + user: string; + assistant: string; + system: string; + tool: string; + error: string; + dim: string; + thinking: string; + highlight: string; +} + +const MESSAGE_DARK: MessageColors = { + user: "#56d364", // Rich "green" + assistant: "#e0e0e0", // bright text for readability + system: "#d670d6", // Rich "magenta" + tool: "#C8C5F4", // Rich "blue" — lavender as seen in Python + error: "#ff7b72", // Rich "dark_red" + dim: "#808080", // Rich "grey50" + thinking: "#7c8594", + highlight: "#56d364", // Rich "green" +}; + +const MESSAGE_LIGHT: MessageColors = { + user: "#166534", // dark green + assistant: "#1f2937", // dark text + system: "#7c3aed", // dark purple + tool: "#1d4ed8", // dark blue + error: "#dc2626", + dim: "#6b7280", + thinking: "#6b7280", + highlight: "#166534", +}; + +// ── Chalk helpers ────────────────────────────────────────── + +export interface ThemeStyles { + user: ChalkInstance; + assistant: ChalkInstance; + system: ChalkInstance; + tool: ChalkInstance; + error: ChalkInstance; + dim: ChalkInstance; + thinking: ChalkInstance; + highlight: ChalkInstance; + bold: ChalkInstance; + italic: ChalkInstance; +} + +function makeStyles(colors: MessageColors): ThemeStyles { + return { + user: chalk.hex(colors.user), + assistant: chalk.hex(colors.assistant), + system: chalk.hex(colors.system), + tool: chalk.hex(colors.tool), + error: chalk.hex(colors.error), + dim: chalk.hex(colors.dim), + thinking: chalk.italic.hex(colors.thinking), + highlight: chalk.hex(colors.highlight), + bold: chalk.bold, + italic: chalk.italic, + }; +} + +// ── Prompt Style ────────────────────────────────────────── + +export interface PromptStyleColors { + sparkle: string; + streamingSparkle: string; + inputText: string; + placeholder: string; + border: string; +} + +const PROMPT_DARK: PromptStyleColors = { + sparkle: "#f2cc60", + streamingSparkle: "#56a4ff", + inputText: "#e6e6e6", + placeholder: "#555555", + border: "#4d4d4d", +}; + +const PROMPT_LIGHT: PromptStyleColors = { + sparkle: "#b45309", + streamingSparkle: "#2563eb", + inputText: "#1f2937", + placeholder: "#9ca3af", + border: "#d1d5db", +}; + +// ── Task Browser Style ─────────────────────────────────── + +export interface TaskBrowserColors { + headerBg: string; + headerFg: string; + selectedBg: string; + selectedFg: string; + borderColor: string; + runningFg: string; + completedFg: string; + failedFg: string; + killedFg: string; + listBg: string; +} + +const TASK_BROWSER_DARK: TaskBrowserColors = { + headerBg: "#1f2937", + headerFg: "#67e8f9", + selectedBg: "#164e63", + selectedFg: "#ecfeff", + borderColor: "#155e75", + runningFg: "#86efac", + completedFg: "#56d364", + failedFg: "#fca5a5", + killedFg: "#fbbf24", + listBg: "#0f172a", +}; + +const TASK_BROWSER_LIGHT: TaskBrowserColors = { + headerBg: "#f3f4f6", + headerFg: "#0e7490", + selectedBg: "#e0f2fe", + selectedFg: "#164e63", + borderColor: "#67e8f9", + runningFg: "#166534", + completedFg: "#166534", + failedFg: "#dc2626", + killedFg: "#b45309", + listBg: "#ffffff", +}; + +// ── Public API ───────────────────────────────────────────── + +let activeTheme: ThemeName = "dark"; + +export function setActiveTheme(theme: ThemeName): void { + activeTheme = theme; +} + +export function getActiveTheme(): ThemeName { + return activeTheme; +} + +export function getDiffColors(): DiffColors { + return activeTheme === "light" ? DIFF_LIGHT : DIFF_DARK; +} + +export function getToolbarColors(): ToolbarColors { + return activeTheme === "light" ? TOOLBAR_LIGHT : TOOLBAR_DARK; +} + +export function getMcpPromptColors(): MCPPromptColors { + return activeTheme === "light" ? MCP_PROMPT_LIGHT : MCP_PROMPT_DARK; +} + +export function getMessageColors(): MessageColors { + return activeTheme === "light" ? MESSAGE_LIGHT : MESSAGE_DARK; +} + +export function getStyles(): ThemeStyles { + return makeStyles(getMessageColors()); +} + +export function getPromptColors(): PromptStyleColors { + return activeTheme === "light" ? PROMPT_LIGHT : PROMPT_DARK; +} + +export function getTaskBrowserColors(): TaskBrowserColors { + return activeTheme === "light" ? TASK_BROWSER_LIGHT : TASK_BROWSER_DARK; +} diff --git a/src/kimi_cli_ts/utils/async.ts b/src/kimi_cli_ts/utils/async.ts new file mode 100644 index 000000000..2c5af9fdc --- /dev/null +++ b/src/kimi_cli_ts/utils/async.ts @@ -0,0 +1,78 @@ +/** + * Async utilities — corresponds to Python utils/async patterns + */ + +/** Sleep for given milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Run a function with a timeout. Rejects with TimeoutError if exceeded. */ +export async function withTimeout(fn: () => Promise, timeoutMs: number): Promise { + return Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError(`Timed out after ${timeoutMs}ms`)), timeoutMs), + ), + ]); +} + +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = "TimeoutError"; + } +} + +/** + * Deferred — a promise that can be resolved/rejected externally. + * Similar to Python's asyncio.Future. + */ +export class Deferred { + readonly promise: Promise; + resolve!: (value: T) => void; + reject!: (reason: unknown) => void; + private _settled = false; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (v: T) => { + if (!this._settled) { + this._settled = true; + resolve(v); + } + }; + this.reject = (r: unknown) => { + if (!this._settled) { + this._settled = true; + reject(r); + } + }; + }); + } + + get settled(): boolean { + return this._settled; + } +} + +/** Run tasks with a concurrency limit. */ +export async function mapConcurrent( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let index = 0; + + async function worker() { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]!); + } + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); + await Promise.all(workers); + return results; +} diff --git a/src/kimi_cli_ts/utils/changelog.ts b/src/kimi_cli_ts/utils/changelog.ts new file mode 100644 index 000000000..a79be52c8 --- /dev/null +++ b/src/kimi_cli_ts/utils/changelog.ts @@ -0,0 +1,19 @@ +/** + * Changelog data — corresponds to Python utils/changelog.py + */ + +export interface ChangelogEntry { + description: string; + entries: string[]; +} + +export const CHANGELOG: Record = { + "2.0.0": { + description: "TypeScript rewrite", + entries: [ + "Complete rewrite in TypeScript with Bun runtime", + "New Ink-based terminal UI", + "Improved performance and startup time", + ], + }, +}; diff --git a/src/kimi_cli_ts/utils/clipboard.ts b/src/kimi_cli_ts/utils/clipboard.ts new file mode 100644 index 000000000..d9bc13f3c --- /dev/null +++ b/src/kimi_cli_ts/utils/clipboard.ts @@ -0,0 +1,94 @@ +/** + * Clipboard utilities — corresponds to Python utils/clipboard.py + * Clipboard access for media (images, files) via system commands. + */ + +import { logger } from "./logging.ts"; + +export interface ClipboardResult { + readonly imagePaths: string[]; + readonly filePaths: string[]; + readonly text?: string; +} + +/** + * Check if clipboard text access is available. + */ +export async function isClipboardAvailable(): Promise { + try { + if (process.platform === "darwin") { + const proc = Bun.spawn(["pbpaste"], { stdout: "pipe", stderr: "pipe" }); + await proc.exited; + return true; + } + if (process.platform === "linux") { + // Try xclip first, then xsel + for (const cmd of ["xclip", "xsel"]) { + try { + const proc = Bun.spawn(["which", cmd], { stdout: "pipe", stderr: "pipe" }); + const code = await proc.exited; + if (code === 0) return true; + } catch { + continue; + } + } + } + return false; + } catch { + return false; + } +} + +/** + * Read text from clipboard. + */ +export async function readClipboardText(): Promise { + try { + let proc: ReturnType; + if (process.platform === "darwin") { + proc = Bun.spawn(["pbpaste"], { stdout: "pipe", stderr: "pipe" }); + } else if (process.platform === "linux") { + proc = Bun.spawn(["xclip", "-selection", "clipboard", "-o"], { + stdout: "pipe", + stderr: "pipe", + }); + } else { + return undefined; + } + const code = await proc.exited; + if (code !== 0) return undefined; + return await new Response(proc.stdout as ReadableStream).text(); + } catch { + logger.debug("Failed to read clipboard text"); + return undefined; + } +} + +/** + * Write text to clipboard. + */ +export async function writeClipboardText(text: string): Promise { + try { + let proc: ReturnType; + if (process.platform === "darwin") { + proc = Bun.spawn(["pbcopy"], { + stdin: new Blob([text]), + stdout: "pipe", + stderr: "pipe", + }); + } else if (process.platform === "linux") { + proc = Bun.spawn(["xclip", "-selection", "clipboard"], { + stdin: new Blob([text]), + stdout: "pipe", + stderr: "pipe", + }); + } else { + return false; + } + const code = await proc.exited; + return code === 0; + } catch { + logger.debug("Failed to write clipboard text"); + return false; + } +} diff --git a/src/kimi_cli_ts/utils/diff.ts b/src/kimi_cli_ts/utils/diff.ts new file mode 100644 index 000000000..748d71466 --- /dev/null +++ b/src/kimi_cli_ts/utils/diff.ts @@ -0,0 +1,298 @@ +/** + * Diff utilities — corresponds to Python utils/diff.py + * Unified diff formatting and diff block generation. + */ + +const N_CONTEXT_LINES = 3; +const HUGE_FILE_THRESHOLD = 10000; + +/** + * Format a unified diff between old_text and new_text. + */ +export function formatUnifiedDiff( + oldText: string, + newText: string, + path = "", + opts?: { includeFileHeader?: boolean }, +): string { + const includeFileHeader = opts?.includeFileHeader ?? true; + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + const fromFile = path ? `a/${path}` : "a/file"; + const toFile = path ? `b/${path}` : "b/file"; + + // Simple unified diff implementation + const hunks = computeHunks(oldLines, newLines, N_CONTEXT_LINES); + if (hunks.length === 0) return ""; + + const result: string[] = []; + if (includeFileHeader) { + result.push(`--- ${fromFile}`); + result.push(`+++ ${toFile}`); + } + + for (const hunk of hunks) { + result.push(hunk); + } + + return result.join("\n") + "\n"; +} + +function computeHunks(oldLines: string[], newLines: string[], context: number): string[] { + // LCS-based diff + const ops = diffLines(oldLines, newLines); + if (ops.length === 0) return []; + + const hunks: string[] = []; + let i = 0; + + while (i < ops.length) { + // Find next change + while (i < ops.length && ops[i] === "equal") i++; + if (i >= ops.length) break; + + // Determine hunk boundaries + const changeStart = i; + let changeEnd = i; + while (changeEnd < ops.length) { + if (ops[changeEnd] === "equal") { + // Check if there's another change within context + let nextChange = changeEnd; + while (nextChange < ops.length && ops[nextChange] === "equal") nextChange++; + if (nextChange >= ops.length || nextChange - changeEnd > context * 2) break; + changeEnd = nextChange; + } + changeEnd++; + } + + // Build hunk with context + const start = Math.max(0, changeStart - context); + const end = Math.min(ops.length, changeEnd + context); + + let oldStart = 0; + let newStart = 0; + for (let j = 0; j < start; j++) { + if (ops[j] === "equal" || ops[j] === "delete") oldStart++; + if (ops[j] === "equal" || ops[j] === "insert") newStart++; + } + + let oldCount = 0; + let newCount = 0; + const lines: string[] = []; + + let oldIdx = oldStart; + let newIdx = newStart; + for (let j = start; j < end; j++) { + const op = ops[j]!; + if (op === "equal") { + lines.push(` ${oldLines[oldIdx] ?? ""}`); + oldIdx++; + newIdx++; + oldCount++; + newCount++; + } else if (op === "delete") { + lines.push(`-${oldLines[oldIdx] ?? ""}`); + oldIdx++; + oldCount++; + } else if (op === "insert") { + lines.push(`+${newLines[newIdx] ?? ""}`); + newIdx++; + newCount++; + } + } + + hunks.push(`@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@`); + hunks.push(...lines); + + i = changeEnd; + } + + return hunks; +} + +function diffLines(oldLines: string[], newLines: string[]): ("equal" | "delete" | "insert")[] { + const m = oldLines.length; + const n = newLines.length; + + if (m === 0 && n === 0) return []; + if (m === 0) return new Array(n).fill("insert"); + if (n === 0) return new Array(m).fill("delete"); + + // Myers diff algorithm (simplified) + const max = m + n; + const v = new Array(2 * max + 1).fill(0); + const trace: number[][] = []; + + outer: for (let d = 0; d <= max; d++) { + trace.push([...v]); + for (let k = -d; k <= d; k += 2) { + let x: number; + if (k === -d || (k !== d && v[k - 1 + max]! < v[k + 1 + max]!)) { + x = v[k + 1 + max]!; + } else { + x = v[k - 1 + max]! + 1; + } + let y = x - k; + while (x < m && y < n && oldLines[x] === newLines[y]) { + x++; + y++; + } + v[k + max] = x; + if (x >= m && y >= n) break outer; + } + } + + // Backtrack to build edit script + const ops: ("equal" | "delete" | "insert")[] = []; + let x = m; + let y = n; + + for (let d = trace.length - 1; d > 0; d--) { + const prev = trace[d - 1]!; + const k = x - y; + let prevK: number; + if (k === -d || (k !== d && prev[k - 1 + max]! < prev[k + 1 + max]!)) { + prevK = k + 1; + } else { + prevK = k - 1; + } + const prevX = prev[prevK + max]!; + const prevY = prevX - prevK; + + // Diagonal moves (equal) + while (x > prevX + (prevK < k ? 1 : 0) && y > prevY + (prevK < k ? 0 : 1)) { + ops.push("equal"); + x--; + y--; + } + + if (prevK < k) { + ops.push("delete"); + x--; + } else { + ops.push("insert"); + y--; + } + } + + // Remaining diagonal + while (x > 0 && y > 0 && oldLines[x - 1] === newLines[y - 1]) { + ops.push("equal"); + x--; + y--; + } + + ops.reverse(); + return ops; +} + +export interface DiffBlock { + path: string; + oldText: string; + newText: string; + oldStart: number; + newStart: number; + isSummary?: boolean; +} + +/** + * Build diff display blocks grouped with small context windows. + */ +export function buildDiffBlocks( + path: string, + oldText: string, + newText: string, +): DiffBlock[] { + if (oldText === newText) return []; + + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const maxLines = Math.max(oldLines.length, newLines.length); + + // Huge files: skip diff entirely + if (maxLines > HUGE_FILE_THRESHOLD) { + const oldDesc = `(${oldLines.length} lines)`; + const newDesc = + oldLines.length === newLines.length + ? `(${newLines.length} lines, modified)` + : `(${newLines.length} lines)`; + return [ + { + path, + oldText: oldDesc, + newText: newDesc, + oldStart: 1, + newStart: 1, + isSummary: true, + }, + ]; + } + + // Use simple sequence matching for blocks + const blocks: DiffBlock[] = []; + const ops = diffLines(oldLines, newLines); + + let i = 0; + while (i < ops.length) { + // Skip equal ops + while (i < ops.length && ops[i] === "equal") i++; + if (i >= ops.length) break; + + // Find change range + const changeStart = i; + let changeEnd = i; + while (changeEnd < ops.length) { + if (ops[changeEnd] === "equal") { + let nextChange = changeEnd; + while (nextChange < ops.length && ops[nextChange] === "equal") nextChange++; + if (nextChange >= ops.length || nextChange - changeEnd > N_CONTEXT_LINES * 2) break; + changeEnd = nextChange; + } + changeEnd++; + } + + const start = Math.max(0, changeStart - N_CONTEXT_LINES); + const end = Math.min(ops.length, changeEnd + N_CONTEXT_LINES); + + let oldIdx = 0; + let newIdx = 0; + for (let j = 0; j < start; j++) { + if (ops[j] === "equal" || ops[j] === "delete") oldIdx++; + if (ops[j] === "equal" || ops[j] === "insert") newIdx++; + } + + const oldStart = oldIdx; + const newStart = newIdx; + const blockOldLines: string[] = []; + const blockNewLines: string[] = []; + + for (let j = start; j < end; j++) { + const op = ops[j]!; + if (op === "equal") { + blockOldLines.push(oldLines[oldIdx]!); + blockNewLines.push(newLines[newIdx]!); + oldIdx++; + newIdx++; + } else if (op === "delete") { + blockOldLines.push(oldLines[oldIdx]!); + oldIdx++; + } else { + blockNewLines.push(newLines[newIdx]!); + newIdx++; + } + } + + blocks.push({ + path, + oldText: blockOldLines.join("\n"), + newText: blockNewLines.join("\n"), + oldStart: oldStart + 1, + newStart: newStart + 1, + }); + + i = changeEnd; + } + + return blocks; +} diff --git a/src/kimi_cli_ts/utils/environment.ts b/src/kimi_cli_ts/utils/environment.ts new file mode 100644 index 000000000..124a40d55 --- /dev/null +++ b/src/kimi_cli_ts/utils/environment.ts @@ -0,0 +1,95 @@ +/** + * Environment detection — corresponds to Python utils/environment.py + * Detects OS, architecture, and default shell. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export type OsKind = "macOS" | "Windows" | "Linux" | string; +export type ShellName = "bash" | "sh" | "Windows PowerShell"; + +export interface Environment { + readonly osKind: OsKind; + readonly osArch: string; + readonly osVersion: string; + readonly shellName: ShellName; + readonly shellPath: string; +} + +/** + * Detect the current environment: OS, architecture, and default shell. + */ +export async function detectEnvironment(): Promise { + let osKind: OsKind; + switch (process.platform) { + case "darwin": + osKind = "macOS"; + break; + case "win32": + osKind = "Windows"; + break; + case "linux": + osKind = "Linux"; + break; + default: + osKind = process.platform; + } + + const osArch = process.arch; + + // Get OS version + let osVersion = ""; + try { + const { release } = await import("node:os"); + osVersion = release(); + } catch { + // Ignore + } + + let shellName: ShellName; + let shellPath: string; + + if (osKind === "Windows") { + shellName = "Windows PowerShell"; + const systemRoot = process.env.SYSTEMROOT ?? "C:\\Windows"; + const possiblePaths = [ + join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + ]; + const fallbackPath = "powershell.exe"; + let foundWindows = false; + for (const p of possiblePaths) { + if (existsSync(p)) { + shellPath = p; + foundWindows = true; + break; + } + } + if (!foundWindows) { + shellPath = fallbackPath; + } + } else { + const bashPaths = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]; + let found = false; + for (const p of bashPaths) { + if (existsSync(p)) { + shellName = "bash"; + shellPath = p; + found = true; + break; + } + } + if (!found) { + shellName = "sh"; + shellPath = "/bin/sh"; + } + } + + return { + osKind, + osArch, + osVersion, + shellName: shellName!, + shellPath: shellPath!, + }; +} diff --git a/src/kimi_cli_ts/utils/export.ts b/src/kimi_cli_ts/utils/export.ts new file mode 100644 index 000000000..46cff3343 --- /dev/null +++ b/src/kimi_cli_ts/utils/export.ts @@ -0,0 +1,261 @@ +/** + * Export utilities — corresponds to Python utils/export.py + * Session export to markdown format. + */ + +import { join, resolve } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs"; +import type { ContentPart, Message, ToolCallInfo } from "./message.ts"; +import { messageStringify } from "./message.ts"; + +// ── Export helpers ── + +const HINT_KEYS = ["path", "file_path", "command", "query", "url", "name", "pattern"]; + +function extractToolCallHint(argsJson: string): string { + try { + const parsed = JSON.parse(argsJson); + if (typeof parsed !== "object" || parsed === null) return ""; + const args = parsed as Record; + + // Prefer well-known keys + for (const key of HINT_KEYS) { + const val = args[key]; + if (typeof val === "string" && val.trim()) { + return val.length > 60 ? val.slice(0, 57) + "…" : val; + } + } + + // Fallback: first short string value + for (const val of Object.values(args)) { + if (typeof val === "string" && val.length > 0 && val.length <= 80) { + return val.length > 60 ? val.slice(0, 57) + "…" : val; + } + } + } catch { + // ignore + } + return ""; +} + +function formatContentPartMd(part: ContentPart): string { + if (part.type === "text" && part.text) return part.text; + if (part.type === "think" && part.think) { + if (!part.think.trim()) return ""; + return `
Thinking\n\n${part.think}\n\n
`; + } + if (part.type === "image_url") return "[image]"; + if (part.type === "audio_url") return "[audio]"; + if (part.type === "video_url") return "[video]"; + return `[${part.type}]`; +} + +function formatToolCallMd(tc: ToolCallInfo): string { + const argsRaw = tc.function.arguments || "{}"; + const hint = extractToolCallHint(argsRaw); + let title = `#### Tool Call: ${tc.function.name}`; + if (hint) title += ` (\`${hint}\`)`; + + let argsFormatted: string; + try { + const parsed = JSON.parse(argsRaw); + argsFormatted = JSON.stringify(parsed, null, 2); + } catch { + argsFormatted = argsRaw; + } + + return `${title}\n\n\`\`\`json\n${argsFormatted}\n\`\`\``; +} + +function formatToolResultMd(msg: Message, toolName: string, hint: string): string { + const callId = msg.tool_call_id || "unknown"; + const resultParts: string[] = []; + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) resultParts.push(text); + } + const resultText = resultParts.join("\n"); + + let summary = `Tool Result: ${toolName}`; + if (hint) summary += ` (\`${hint}\`)`; + + return ( + `
${summary}\n\n` + + `\n` + + `${resultText}\n\n` + + `
` + ); +} + +function isInternalUserMessage(msg: Message): boolean { + if (msg.role !== "user" || msg.content.length !== 1) return false; + const part = msg.content[0]!; + return part.type === "text" && (part.text ?? "").trim().startsWith(""); +} + +function groupIntoTurns(history: Message[]): Message[][] { + const turns: Message[][] = []; + let current: Message[] = []; + + for (const msg of history) { + if (isInternalUserMessage(msg)) continue; + if (msg.role === "user" && current.length > 0) { + turns.push(current); + current = []; + } + current.push(msg); + } + if (current.length > 0) turns.push(current); + return turns; +} + +function formatTurnMd(messages: Message[], turnNumber: number): string { + const lines = [`## Turn ${turnNumber}`, ""]; + const toolCallInfo: Record = {}; + let assistantHeaderWritten = false; + + for (const msg of messages) { + if (isInternalUserMessage(msg)) continue; + + if (msg.role === "user") { + lines.push("### User", ""); + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ""); + } + } + } else if (msg.role === "assistant") { + if (!assistantHeaderWritten) { + lines.push("### Assistant", ""); + assistantHeaderWritten = true; + } + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ""); + } + } + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + const hint = extractToolCallHint(tc.function.arguments || "{}"); + toolCallInfo[tc.id] = [tc.function.name, hint]; + lines.push(formatToolCallMd(tc), ""); + } + } + } else if (msg.role === "tool") { + const tcId = msg.tool_call_id || ""; + const [name, hint] = toolCallInfo[tcId] ?? ["unknown", ""]; + lines.push(formatToolResultMd(msg, name, hint), ""); + } else if (msg.role === "system" || msg.role === "developer") { + lines.push(`### ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`, ""); + for (const part of msg.content) { + const text = formatContentPartMd(part); + if (text.trim()) { + lines.push(text, ""); + } + } + } + } + return lines.join("\n"); +} + +function buildOverview(history: Message[], turns: Message[][], tokenCount: number): string { + let topic = ""; + for (const msg of history) { + if (msg.role === "user" && !isInternalUserMessage(msg)) { + const full = messageStringify(msg); + topic = full.length > 80 ? full.slice(0, 77) + "…" : full; + break; + } + } + + const nToolCalls = history.reduce( + (sum, msg) => sum + (msg.tool_calls?.length ?? 0), + 0, + ); + + return [ + "## Overview", + "", + topic ? `- **Topic**: ${topic}` : "- **Topic**: (empty)", + `- **Conversation**: ${turns.length} turns | ${nToolCalls} tool calls | ${tokenCount.toLocaleString()} tokens`, + "", + "---", + ].join("\n"); +} + +/** + * Build the full export markdown string. + */ +export function buildExportMarkdown(opts: { + sessionId: string; + workDir: string; + history: Message[]; + tokenCount: number; + now: Date; +}): string { + const { sessionId, workDir, history, tokenCount, now } = opts; + const lines = [ + "---", + `session_id: ${sessionId}`, + `exported_at: ${now.toISOString()}`, + `work_dir: ${workDir}`, + `message_count: ${history.length}`, + `token_count: ${tokenCount}`, + "---", + "", + "# Kimi Session Export", + "", + ]; + + const turns = groupIntoTurns(history); + lines.push(buildOverview(history, turns, tokenCount)); + lines.push(""); + + for (let i = 0; i < turns.length; i++) { + lines.push(formatTurnMd(turns[i]!, i + 1)); + } + + return lines.join("\n"); +} + +// ── Import helpers ── + +const IMPORTABLE_EXTENSIONS = new Set([ + ".md", ".markdown", ".txt", ".text", ".rst", + ".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", + ".csv", ".tsv", ".xml", ".env", ".properties", + ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".go", ".rs", + ".c", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", + ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", + ".r", ".R", ".lua", ".pl", ".pm", ".ex", ".exs", ".erl", ".hs", ".ml", + ".sql", ".graphql", ".proto", + ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", + ".log", + ".tex", ".bib", ".org", ".adoc", ".wiki", +]); + +/** + * Check if a file path has an importable extension. + */ +export function isImportableFile(pathStr: string): boolean { + const lastDot = pathStr.lastIndexOf("."); + if (lastDot === -1) return true; // No extension = ok + const suffix = pathStr.slice(lastDot).toLowerCase(); + return IMPORTABLE_EXTENSIONS.has(suffix); +} + +export const MAX_IMPORT_SIZE = 10 * 1024 * 1024; // 10 MB + +const SENSITIVE_FILE_PATTERNS = [ + ".env", "credentials", "secrets", ".pem", ".key", ".p12", ".pfx", ".keystore", +]; + +/** + * Check if a filename looks like it may contain secrets. + */ +export function isSensitiveFile(filename: string): boolean { + const name = filename.toLowerCase(); + return SENSITIVE_FILE_PATTERNS.some((pat) => name.includes(pat)); +} diff --git a/src/kimi_cli_ts/utils/logging.ts b/src/kimi_cli_ts/utils/logging.ts new file mode 100644 index 000000000..7852ff0ef --- /dev/null +++ b/src/kimi_cli_ts/utils/logging.ts @@ -0,0 +1,51 @@ +/** + * Logging module — corresponds to Python utils/logging.py + * Simple structured logger using console with level filtering. + */ + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +class Logger { + private level: LogLevel = "info"; + + setLevel(level: LogLevel): void { + this.level = level; + } + + private shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[this.level]; + } + + debug(message: string, ...args: unknown[]): void { + if (this.shouldLog("debug")) process.stderr.write(`[DEBUG] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + } + + info(message: string, ...args: unknown[]): void { + if (this.shouldLog("info")) process.stderr.write(`[INFO] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + } + + warn(message: string, ...args: unknown[]): void { + if (this.shouldLog("warn")) process.stderr.write(`[WARN] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + } + + error(message: string, ...args: unknown[]): void { + if (this.shouldLog("error")) process.stderr.write(`[ERROR] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + } +} + +export const logger = new Logger(); + +// Set default level from environment +if (process.env.KIMI_LOG_LEVEL) { + const envLevel = process.env.KIMI_LOG_LEVEL.toLowerCase() as LogLevel; + if (envLevel in LOG_LEVELS) { + logger.setLevel(envLevel); + } +} diff --git a/src/kimi_cli_ts/utils/message.ts b/src/kimi_cli_ts/utils/message.ts new file mode 100644 index 000000000..31bd66b76 --- /dev/null +++ b/src/kimi_cli_ts/utils/message.ts @@ -0,0 +1,47 @@ +/** + * Message utilities — corresponds to Python utils/message.py + * String representation of messages for display and export. + */ + +export interface ContentPart { + type: string; + text?: string; + think?: string; + [key: string]: unknown; +} + +export interface Message { + role: string; + content: ContentPart[]; + tool_calls?: ToolCallInfo[]; + tool_call_id?: string; +} + +export interface ToolCallInfo { + id: string; + function: { + name: string; + arguments: string; + }; +} + +/** + * Get a string representation of a message. + */ +export function messageStringify(message: Message): string { + const parts: string[] = []; + for (const part of message.content) { + if (part.type === "text" && part.text) { + parts.push(part.text); + } else if (part.type === "image_url") { + parts.push("[image]"); + } else if (part.type === "audio_url") { + parts.push("[audio]"); + } else if (part.type === "video_url") { + parts.push("[video]"); + } else { + parts.push(`[${part.type}]`); + } + } + return parts.join(""); +} diff --git a/src/kimi_cli_ts/utils/path.ts b/src/kimi_cli_ts/utils/path.ts new file mode 100644 index 000000000..43f3d8949 --- /dev/null +++ b/src/kimi_cli_ts/utils/path.ts @@ -0,0 +1,71 @@ +/** + * Path utilities — corresponds to Python utils/path.py + */ + +import { homedir } from "node:os"; +import { resolve, relative, join } from "node:path"; + +/** Expand ~ to home directory. */ +export function expandHome(p: string): string { + if (p.startsWith("~/") || p === "~") { + return join(homedir(), p.slice(1)); + } + return p; +} + +/** Resolve a path relative to a base directory, expanding ~. */ +export function resolvePath(base: string, p: string): string { + return resolve(base, expandHome(p)); +} + +/** Get a relative path from base, or the absolute path if it's shorter. */ +export function shortPath(base: string, p: string): string { + const abs = resolve(p); + const rel = relative(base, abs); + return rel.length < abs.length ? rel : abs; +} + +/** Check if a path is inside a directory. */ +export function isInsideDir(dir: string, p: string): boolean { + const absDir = resolve(dir); + const absP = resolve(p); + return absP.startsWith(absDir + "/") || absP === absDir; +} + +/** Ensure a directory exists. */ +export async function ensureDir(dir: string): Promise { + await Bun.$`mkdir -p ${dir}`.quiet(); +} + +/** + * Validate a file path against workspace boundaries. + * Returns null if valid, or an error message if the path is outside workspace. + * Relative paths are always allowed (resolved against workDir). + * Absolute paths must be within workDir or additionalDirs. + */ +export function validateWorkspacePath( + filePath: string, + workDir: string, + additionalDirs: string[] = [], +): string | null { + // Relative paths are ok — they resolve against workDir + if (!filePath.startsWith("/") && !filePath.startsWith("~")) { + return null; + } + + const resolved = resolve(expandHome(filePath)); + + // Check workDir + if (isInsideDir(workDir, resolved)) return null; + + // Check additional dirs + for (const dir of additionalDirs) { + if (isInsideDir(resolve(dir), resolved)) return null; + } + + // Allow /tmp paths (common for temp files) + if (resolved.startsWith("/tmp/") || resolved.startsWith("/var/tmp/")) return null; + + // Outside workspace — warn but allow (with absolute path requirement already met) + return null; // For now, allow all absolute paths like Python does for ReadFile +} diff --git a/src/kimi_cli_ts/utils/queue.ts b/src/kimi_cli_ts/utils/queue.ts new file mode 100644 index 000000000..c53410c36 --- /dev/null +++ b/src/kimi_cli_ts/utils/queue.ts @@ -0,0 +1,101 @@ +/** + * Async queue with shutdown support — corresponds to Python's utils/aioqueue.py + * and utils/broadcast.py + */ + +// ── QueueShutDown ────────────────────────────────────────── + +export class QueueShutDown extends Error { + constructor() { + super("Queue has been shut down"); + this.name = "QueueShutDown"; + } +} + +// ── AsyncQueue ───────────────────────────────────────────── + +/** + * Unbounded async queue with shutdown support. + * Modeled after Python's asyncio.Queue. + */ +export class AsyncQueue { + private _buffer: T[] = []; + private _waiters: Array<{ + resolve: (value: T) => void; + reject: (err: Error) => void; + }> = []; + private _shutdown = false; + + get closed(): boolean { + return this._shutdown; + } + + put(item: T): void { + if (this._shutdown) throw new QueueShutDown(); + if (this._waiters.length > 0) { + const waiter = this._waiters.shift()!; + waiter.resolve(item); + } else { + this._buffer.push(item); + } + } + + async get(): Promise { + if (this._buffer.length > 0) { + return this._buffer.shift()!; + } + if (this._shutdown) throw new QueueShutDown(); + return new Promise((resolve, reject) => { + this._waiters.push({ resolve, reject }); + }); + } + + shutdown(immediate = false): void { + if (this._shutdown) return; + this._shutdown = true; + if (immediate) { + this._buffer.length = 0; + } + // Wake all waiters with QueueShutDown + for (const waiter of this._waiters) { + waiter.reject(new QueueShutDown()); + } + this._waiters.length = 0; + } + + get empty(): boolean { + return this._buffer.length === 0; + } +} + +// ── BroadcastQueue ───────────────────────────────────────── + +/** + * A broadcast queue that allows multiple subscribers to receive published items. + */ +export class BroadcastQueue { + private _queues = new Set>(); + + subscribe(): AsyncQueue { + const queue = new AsyncQueue(); + this._queues.add(queue); + return queue; + } + + unsubscribe(queue: AsyncQueue): void { + this._queues.delete(queue); + } + + publishNowait(item: T): void { + for (const queue of this._queues) { + queue.put(item); + } + } + + shutdown(immediate = false): void { + for (const queue of this._queues) { + queue.shutdown(immediate); + } + this._queues.clear(); + } +} diff --git a/src/kimi_cli_ts/utils/sensitive.ts b/src/kimi_cli_ts/utils/sensitive.ts new file mode 100644 index 000000000..ffa24e876 --- /dev/null +++ b/src/kimi_cli_ts/utils/sensitive.ts @@ -0,0 +1,89 @@ +/** + * Sensitive file detection — blocks access to files containing secrets. + * Corresponds to Python utils/sensitive.py + */ + +// High-confidence sensitive file patterns. +// Only patterns with very low false-positive risk are included. +const SENSITIVE_PATTERNS: string[] = [ + // Environment variable / secrets + ".env", + ".env.*", + // SSH private keys + "id_rsa", + "id_ed25519", + "id_ecdsa", + // Cloud credentials (path-based, also bare name for stripped-path scenarios) + ".aws/credentials", + ".gcp/credentials", + "credentials", +]; + +// Template/example files that match .env.* but are not sensitive. +const SENSITIVE_EXEMPTIONS = new Set([ + ".env.example", + ".env.sample", + ".env.template", +]); + +/** + * Simple fnmatch-style matching (supports * and ? wildcards). + */ +function fnmatch(name: string, pattern: string): boolean { + // Convert fnmatch pattern to regex + let regex = "^"; + for (const ch of pattern) { + if (ch === "*") regex += ".*"; + else if (ch === "?") regex += "."; + else if (".+^${}()|[]\\".includes(ch)) regex += "\\" + ch; + else regex += ch; + } + regex += "$"; + return new RegExp(regex).test(name); +} + +/** + * Check if a file path matches any sensitive file pattern. + */ +export function isSensitiveFile(path: string): boolean { + // Extract basename + const parts = path.replace(/\\/g, "/").split("/"); + const name = parts[parts.length - 1] || ""; + + if (SENSITIVE_EXEMPTIONS.has(name)) { + return false; + } + + for (const pattern of SENSITIVE_PATTERNS) { + if (pattern.includes("/")) { + if (path.endsWith(pattern) || path.includes("/" + pattern)) { + return true; + } + } else { + if (fnmatch(name, pattern)) { + return true; + } + } + } + return false; +} + +/** + * Generate a warning message for sensitive files that were skipped. + */ +export function sensitiveFileWarning(paths: string[]): string { + const nameSet = new Set(); + for (const p of paths) { + const parts = p.replace(/\\/g, "/").split("/"); + nameSet.add(parts[parts.length - 1] || p); + } + const names = [...nameSet].sort(); + let fileList = names.slice(0, 5).join(", "); + if (names.length > 5) { + fileList += `, ... (${names.length} files total)`; + } + return ( + `Skipped ${paths.length} sensitive file(s) (${fileList}) ` + + `to protect secrets. These files may contain credentials or private keys.` + ); +} diff --git a/src/kimi_cli_ts/utils/signals.ts b/src/kimi_cli_ts/utils/signals.ts new file mode 100644 index 000000000..7459ceeca --- /dev/null +++ b/src/kimi_cli_ts/utils/signals.ts @@ -0,0 +1,44 @@ +/** + * Signal handling utilities — corresponds to Python utils/signals.py + * Cross-platform SIGINT handler installation. + */ + +/** + * Install a SIGINT handler. Returns a function to remove it. + * + * Works on Unix and Windows (Bun's process.on is cross-platform). + */ +export function installSigintHandler(handler: () => void): () => void { + const listener = () => handler(); + process.on("SIGINT", listener); + + return () => { + process.off("SIGINT", listener); + }; +} + +/** + * Install a SIGTERM handler. Returns a function to remove it. + */ +export function installSigtermHandler(handler: () => void): () => void { + const listener = () => handler(); + process.on("SIGTERM", listener); + + return () => { + process.off("SIGTERM", listener); + }; +} + +/** + * Install handlers for graceful shutdown on both SIGINT and SIGTERM. + * Returns a function to remove all handlers. + */ +export function installShutdownHandlers(handler: () => void): () => void { + const removeSigint = installSigintHandler(handler); + const removeSigterm = installSigtermHandler(handler); + + return () => { + removeSigint(); + removeSigterm(); + }; +} diff --git a/src/kimi_cli_ts/utils/string.ts b/src/kimi_cli_ts/utils/string.ts new file mode 100644 index 000000000..d1fef4b55 --- /dev/null +++ b/src/kimi_cli_ts/utils/string.ts @@ -0,0 +1,48 @@ +/** + * String utilities — corresponds to Python utils/string.py + */ + +const NEWLINE_RE = /[\r\n]+/g; + +/** + * Shorten text to at most `width` characters. + * + * Normalises whitespace, then truncates — preferring a word boundary + * when one exists near the cut point, but falling back to a hard cut + * so that CJK text without spaces won't collapse to just the placeholder. + */ +export function shorten(text: string, options: { width: number; placeholder?: string }): string { + const { width, placeholder = "…" } = options; + const normalized = text.split(/\s+/).filter(Boolean).join(" "); + if (normalized.length <= width) return normalized; + const cut = width - placeholder.length; + if (cut <= 0) return normalized.slice(0, width); + const space = normalized.lastIndexOf(" ", cut); + const actualCut = space > 0 ? space : cut; + return normalized.slice(0, actualCut).trimEnd() + placeholder; +} + +/** + * Shorten text by inserting ellipsis in the middle. + */ +export function shortenMiddle(text: string, width: number, removeNewline = true): string { + if (text.length <= width) return text; + let t = text; + if (removeNewline) { + t = t.replace(NEWLINE_RE, " "); + } + const half = Math.floor(width / 2); + return t.slice(0, half) + "..." + t.slice(-half); +} + +/** + * Generate a random lowercase string of fixed length. + */ +export function randomString(length = 8): string { + const letters = "abcdefghijklmnopqrstuvwxyz"; + let result = ""; + for (let i = 0; i < length; i++) { + result += letters[Math.floor(Math.random() * letters.length)]; + } + return result; +} diff --git a/src/kimi_cli_ts/utils/yaml.ts b/src/kimi_cli_ts/utils/yaml.ts new file mode 100644 index 000000000..05adcc97b --- /dev/null +++ b/src/kimi_cli_ts/utils/yaml.ts @@ -0,0 +1,179 @@ +/** + * Minimal YAML parser utility. + * For agent spec YAML files which use simple structures. + * + * For production use, consider adding `yaml` package. + * This is a bootstrap implementation. + */ +export function parse(text: string): unknown { + const lines = text.split("\n"); + return parseObject(lines, 0).value; +} + +interface ParseResult { + value: unknown; + consumed: number; +} + +function parseObject(lines: string[], startIndent: number): ParseResult { + const obj: Record = {}; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const stripped = line.trimStart(); + + if (!stripped || stripped.startsWith("#")) { + i++; + continue; + } + + const indent = line.length - stripped.length; + if (indent < startIndent) break; + + if (stripped.startsWith("- ")) break; + + const colonIdx = stripped.indexOf(":"); + if (colonIdx === -1) { + i++; + continue; + } + + const key = stripped.slice(0, colonIdx).trim(); + const valueStr = stripped.slice(colonIdx + 1).trim(); + + if (valueStr === "" || valueStr === "|" || valueStr === ">") { + i++; + const nextIndent = getNextIndent(lines, i); + if (nextIndent > indent) { + const nextStripped = (lines[i] ?? "").trimStart(); + if (nextStripped.startsWith("- ")) { + const arr = parseArray(lines.slice(i), nextIndent); + obj[key] = arr.value; + i += arr.consumed; + } else if (valueStr === "|" || valueStr === ">") { + const block = parseBlockScalar(lines.slice(i), nextIndent, valueStr === "|"); + obj[key] = block.value; + i += block.consumed; + } else { + const nested = parseObject(lines.slice(i), nextIndent); + obj[key] = nested.value; + i += nested.consumed; + } + } else { + obj[key] = null; + } + } else { + obj[key] = parseScalar(valueStr); + i++; + } + } + + return { value: obj, consumed: i }; +} + +function parseArray(lines: string[], startIndent: number): ParseResult { + const arr: unknown[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const stripped = line.trimStart(); + if (!stripped || stripped.startsWith("#")) { + i++; + continue; + } + + const indent = line.length - stripped.length; + if (indent < startIndent) break; + + if (stripped.startsWith("- ")) { + const itemStr = stripped.slice(2).trim(); + if (itemStr.includes(":")) { + const colonIdx = itemStr.indexOf(":"); + const key = itemStr.slice(0, colonIdx).trim(); + const val = itemStr.slice(colonIdx + 1).trim(); + + i++; + const nextIndent = getNextIndent(lines, i); + if (nextIndent > indent + 2) { + const nested = parseObject(lines.slice(i), nextIndent); + const item: Record = { [key]: val ? parseScalar(val) : nested.value }; + if (typeof nested.value === "object" && nested.value !== null && !val) { + Object.assign(item, { [key]: nested.value }); + } + arr.push(item); + i += nested.consumed; + } else { + arr.push({ [key]: parseScalar(val) }); + } + } else { + arr.push(parseScalar(itemStr)); + i++; + } + } else { + break; + } + } + + return { value: arr, consumed: i }; +} + +function parseBlockScalar(lines: string[], startIndent: number, literal: boolean): ParseResult { + const parts: string[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const stripped = line.trimStart(); + const indent = line.length - stripped.length; + + if (!stripped) { + parts.push(""); + i++; + continue; + } + + if (indent < startIndent) break; + parts.push(line.slice(startIndent)); + i++; + } + + const sep = literal ? "\n" : " "; + return { value: parts.join(sep).trimEnd(), consumed: i }; +} + +function getNextIndent(lines: string[], from: number): number { + for (let i = from; i < lines.length; i++) { + const line = lines[i]!; + const stripped = line.trimStart(); + if (stripped && !stripped.startsWith("#")) { + return line.length - stripped.length; + } + } + return 0; +} + +function parseScalar(value: string): unknown { + if (!value) return null; + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + + if (value === "true" || value === "True" || value === "yes") return true; + if (value === "false" || value === "False" || value === "no") return false; + if (value === "null" || value === "~" || value === "Null") return null; + + if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10); + if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value); + + if (value.startsWith("[") && value.endsWith("]")) { + return value + .slice(1, -1) + .split(",") + .map((s) => parseScalar(s.trim())); + } + + return value; +} diff --git a/src/kimi_cli_ts/wire/file.ts b/src/kimi_cli_ts/wire/file.ts new file mode 100644 index 000000000..8ce53b465 --- /dev/null +++ b/src/kimi_cli_ts/wire/file.ts @@ -0,0 +1,299 @@ +/** + * Wire file — JSONL-based message log. + * Corresponds to Python's wire/file.py + */ + +import { z } from "zod/v4"; +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + WIRE_PROTOCOL_LEGACY_VERSION, + WIRE_PROTOCOL_VERSION, +} from "./protocol.ts"; +import { + type WireMessage, + type WireMessageEnvelope, + WireMessageEnvelopeSchema, + fromEnvelope, +} from "./types.ts"; +import { serializeWireMessage } from "./serde.ts"; + +// ── Record Types ─────────────────────────────────────────── + +export const WireFileMetadata = z.object({ + type: z.literal("metadata"), + protocol_version: z.string(), +}); +export type WireFileMetadata = z.infer; + +export const WireMessageRecord = z.object({ + timestamp: z.number(), + message: WireMessageEnvelopeSchema, +}); +export type WireMessageRecord = z.infer; + +// ── Parsing helpers ──────────────────────────────────────── + +/** + * Parse a wire file metadata line; returns null if not metadata. + */ +export function parseWireFileMetadata(line: string): WireFileMetadata | null { + try { + const data = JSON.parse(line); + const result = WireFileMetadata.safeParse(data); + return result.success ? result.data : null; + } catch { + return null; + } +} + +/** + * Parse a wire file line into metadata or a message record. + */ +export function parseWireFileLine( + line: string +): WireFileMetadata | WireMessageRecord { + const metadata = parseWireFileMetadata(line); + if (metadata !== null) return metadata; + return WireMessageRecord.parse(JSON.parse(line)); +} + +/** + * Convert a WireMessageRecord to a WireMessage. + */ +export function recordToWireMessage(record: WireMessageRecord): unknown { + return fromEnvelope(record.message).message; +} + +// ── Ensure directory exists ──────────────────────────────── + +async function ensureDir(dirPath: string): Promise { + await mkdir(dirPath, { recursive: true }); +} + +// ── Protocol version detection ───────────────────────────── + +function loadProtocolVersion(path: string): string | null { + try { + const file = Bun.file(path); + // Sync existence check via size — Bun.file for non-existent files has size 0 + // We need to try reading synchronously for constructor use + const text = readFileSync(path); + if (!text) return null; + const lines = text.split("\n"); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + const metadata = parseWireFileMetadata(line); + if (metadata === null) return null; + return metadata.protocol_version; + } + } catch { + // File doesn't exist or can't be read + } + return null; +} + +/** + * Synchronous file read helper (Bun-specific). + */ +function readFileSync(path: string): string | null { + try { + const file = Bun.file(path); + // Bun doesn't have a true sync read, but we can use node:fs + const fs = require("node:fs"); + return fs.readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +function fileExists(path: string): boolean { + try { + const fs = require("node:fs"); + return fs.existsSync(path); + } catch { + return false; + } +} + +// ── WireFile class ───────────────────────────────────────── + +export class WireFile { + readonly path: string; + protocolVersion: string; + + constructor(path: string) { + this.path = path; + + if (fileExists(path)) { + const version = loadProtocolVersion(path); + this.protocolVersion = + version !== null ? version : WIRE_PROTOCOL_LEGACY_VERSION; + } else { + this.protocolVersion = WIRE_PROTOCOL_VERSION; + } + } + + get version(): string { + return this.protocolVersion; + } + + /** + * Check if the wire file is empty (no message records). + */ + async isEmpty(): Promise { + const file = Bun.file(this.path); + if ((await file.exists()) === false) return true; + + // Use streaming line reader instead of loading entire file + const stream = file.stream(); + const decoder = new TextDecoder(); + let remainder = ""; + + for await (const chunk of stream) { + remainder += decoder.decode(chunk, { stream: true }); + const lines = remainder.split("\n"); + remainder = lines.pop()!; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed) continue; + if (parseWireFileMetadata(trimmed) !== null) continue; + return false; + } + } + // Check remainder + const trimmed = remainder.trim(); + if (trimmed && parseWireFileMetadata(trimmed) === null) { + return false; + } + return true; + } + + /** + * Iterate over all message records in the file using streaming line reads. + */ + async *iterRecords(): AsyncGenerator { + const file = Bun.file(this.path); + if ((await file.exists()) === false) return; + + try { + const stream = file.stream(); + const decoder = new TextDecoder(); + let remainder = ""; + + for await (const chunk of stream) { + remainder += decoder.decode(chunk, { stream: true }); + const lines = remainder.split("\n"); + remainder = lines.pop()!; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed) continue; + try { + const parsed = parseWireFileLine(trimmed); + if ("type" in parsed && parsed.type === "metadata") { + this.protocolVersion = ( + parsed as WireFileMetadata + ).protocol_version; + continue; + } + yield parsed as WireMessageRecord; + } catch (err) { + console.error( + `Failed to parse line in wire file ${this.path}:`, + err + ); + continue; + } + } + } + + // Handle any remaining data + const trimmed = remainder.trim(); + if (trimmed) { + try { + const parsed = parseWireFileLine(trimmed); + if (!("type" in parsed && parsed.type === "metadata")) { + yield parsed as WireMessageRecord; + } + } catch (err) { + console.error( + `Failed to parse line in wire file ${this.path}:`, + err + ); + } + } + } catch (err) { + console.error(`Failed to read wire file ${this.path}:`, err); + } + } + + /** + * Append a wire message to the file. + * Accepts either (typeName, payload) or a WireMessage object. + */ + async appendMessage( + typeNameOrMsg: string | WireMessage | Record, + payloadOrTimestamp?: Record | number, + timestamp?: number + ): Promise { + let envelope: Record; + let ts: number; + + if (typeof typeNameOrMsg === "string") { + // (typeName, payload, timestamp?) + envelope = serializeWireMessage( + typeNameOrMsg, + payloadOrTimestamp as Record + ); + ts = (timestamp ?? Date.now() / 1000); + } else { + // (msg, timestamp?) + envelope = serializeWireMessage(typeNameOrMsg); + ts = + typeof payloadOrTimestamp === "number" + ? payloadOrTimestamp + : Date.now() / 1000; + } + + const record: WireMessageRecord = { + timestamp: ts, + message: envelope as WireMessageEnvelope, + }; + await this.appendRecord(record); + } + + /** + * Append a raw record to the wire file. + */ + async appendRecord(record: WireMessageRecord): Promise { + // Ensure parent directory exists + await ensureDir(dirname(this.path)); + + const file = Bun.file(this.path); + const exists = await file.exists(); + const needsHeader = !exists || file.size === 0; + + let content = ""; + if (needsHeader) { + const metadata: WireFileMetadata = { + type: "metadata", + protocol_version: this.protocolVersion, + }; + content += JSON.stringify(metadata) + "\n"; + } + content += JSON.stringify(record) + "\n"; + + // Append to file + const writer = Bun.file(this.path).writer(); + writer.write(content); + await writer.flush(); + writer.end(); + } + + toString(): string { + return this.path; + } +} diff --git a/src/kimi_cli_ts/wire/index.ts b/src/kimi_cli_ts/wire/index.ts new file mode 100644 index 000000000..690790c29 --- /dev/null +++ b/src/kimi_cli_ts/wire/index.ts @@ -0,0 +1,12 @@ +/** + * Wire barrel export — corresponds to Python's wire/__init__.py + */ + +export * from "./types.ts"; +export * from "./protocol.ts"; +export * from "./serde.ts"; +export * from "./file.ts"; +export * from "./wire_core.ts"; +export * from "./server.ts"; +export * from "./jsonrpc.ts"; +export * from "./root_hub.ts"; diff --git a/src/kimi_cli_ts/wire/jsonrpc.ts b/src/kimi_cli_ts/wire/jsonrpc.ts new file mode 100644 index 000000000..d69bd7623 --- /dev/null +++ b/src/kimi_cli_ts/wire/jsonrpc.ts @@ -0,0 +1,295 @@ +/** + * JSON-RPC types for the Wire protocol — corresponds to Python's wire/jsonrpc.py + * Uses Zod v4 for schema validation. + */ + +import { z } from "zod/v4"; +import { ContentPart, type JsonValue } from "../types.ts"; +import type { Event, Request, WireMessage } from "./types.ts"; +import { serializeWireMessage } from "./serde.ts"; + +// ── Base / Error ─────────────────────────────────────────── + +export const JSONRPCErrorObject = z.object({ + code: z.number(), + message: z.string(), + data: z.unknown().nullable().optional(), +}); +export type JSONRPCErrorObject = z.infer; + +/** + * Generic JSON-RPC message used for first-pass validation. + */ +export const JSONRPCMessage = z + .object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.string().nullable().optional(), + id: z.string().nullable().optional(), + params: z.unknown().nullable().optional(), + result: z.unknown().nullable().optional(), + error: JSONRPCErrorObject.nullable().optional(), + }) + .passthrough(); +export type JSONRPCMessage = z.infer; + +export function methodIsInbound(msg: JSONRPCMessage): boolean { + return msg.method != null && JSONRPC_IN_METHODS.has(msg.method); +} + +export function isRequest(msg: JSONRPCMessage): boolean { + return msg.method != null && msg.id != null; +} + +export function isNotification(msg: JSONRPCMessage): boolean { + return msg.method != null && msg.id == null; +} + +export function isResponse(msg: JSONRPCMessage): boolean { + return msg.method == null && msg.id != null; +} + +// ── Success / Error Responses ────────────────────────────── + +export const JSONRPCSuccessResponse = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + id: z.string(), + result: z.unknown(), +}); +export type JSONRPCSuccessResponse = z.infer; + +export const JSONRPCErrorResponse = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + id: z.string(), + error: JSONRPCErrorObject, +}); +export type JSONRPCErrorResponse = z.infer; + +export const JSONRPCErrorResponseNullableID = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + id: z.string().nullable(), + error: JSONRPCErrorObject, +}); +export type JSONRPCErrorResponseNullableID = z.infer; + +// ── Support Types ────────────────────────────────────────── + +export const ClientInfo = z.object({ + name: z.string(), + version: z.string().nullable().optional(), +}); +export type ClientInfo = z.infer; + +export const ExternalTool = z.object({ + name: z.string(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), +}); +export type ExternalTool = z.infer; + +export const ClientCapabilities = z.object({ + supports_question: z.boolean().default(false), + supports_plan_mode: z.boolean().default(false), +}); +export type ClientCapabilities = z.infer; + +export const WireHookSubscription = z.object({ + id: z.string(), + event: z.string(), + matcher: z.string().default(""), + timeout: z.number().default(30), +}); +export type WireHookSubscription = z.infer; + +// ── Inbound Messages ─────────────────────────────────────── + +export const JSONRPCInitializeParams = z.object({ + protocol_version: z.string(), + client: ClientInfo.nullable().optional(), + external_tools: z.array(ExternalTool).nullable().optional(), + hooks: z.array(WireHookSubscription).nullable().optional(), + capabilities: ClientCapabilities.nullable().optional(), +}); +export type JSONRPCInitializeParams = z.infer; + +export const JSONRPCInitializeMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("initialize").default("initialize"), + id: z.string(), + params: JSONRPCInitializeParams, +}); +export type JSONRPCInitializeMessage = z.infer; + +export const JSONRPCPromptParams = z.object({ + user_input: z.union([z.string(), z.array(ContentPart)]), +}); +export type JSONRPCPromptParams = z.infer; + +export const JSONRPCPromptMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("prompt").default("prompt"), + id: z.string(), + params: JSONRPCPromptParams, +}); +export type JSONRPCPromptMessage = z.infer; + +export const JSONRPCReplayMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("replay").default("replay"), + id: z.string(), + params: z.unknown().nullable().optional(), +}); +export type JSONRPCReplayMessage = z.infer; + +export const JSONRPCSteerParams = z.object({ + user_input: z.union([z.string(), z.array(ContentPart)]), +}); +export type JSONRPCSteerParams = z.infer; + +export const JSONRPCSteerMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("steer").default("steer"), + id: z.string(), + params: JSONRPCSteerParams, +}); +export type JSONRPCSteerMessage = z.infer; + +export const JSONRPCSetPlanModeParams = z.object({ + enabled: z.boolean(), +}).passthrough(); +export type JSONRPCSetPlanModeParams = z.infer; + +export const JSONRPCSetPlanModeMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("set_plan_mode").default("set_plan_mode"), + id: z.string(), + params: JSONRPCSetPlanModeParams, +}); +export type JSONRPCSetPlanModeMessage = z.infer; + +export const JSONRPCCancelMessage = z.object({ + jsonrpc: z.literal("2.0").default("2.0"), + method: z.literal("cancel").default("cancel"), + id: z.string(), + params: z.unknown().nullable().optional(), +}); +export type JSONRPCCancelMessage = z.infer; + +// ── Outbound Messages ────────────────────────────────────── + +export interface JSONRPCEventMessage { + jsonrpc: "2.0"; + method: "event"; + params: Record; +} + +export function createEventMessage(event: WireMessage): JSONRPCEventMessage { + return { + jsonrpc: "2.0", + method: "event", + params: serializeWireMessage(event), + }; +} + +export interface JSONRPCRequestMessage { + jsonrpc: "2.0"; + method: "request"; + id: string; + params: Record; +} + +export function createRequestMessage(id: string, request: Request): JSONRPCRequestMessage { + return { + jsonrpc: "2.0", + method: "request", + id, + params: serializeWireMessage(request), + }; +} + +// ── Union Types ──────────────────────────────────────────── + +export type JSONRPCInMessage = + | JSONRPCSuccessResponse + | JSONRPCErrorResponse + | JSONRPCInitializeMessage + | JSONRPCPromptMessage + | JSONRPCSteerMessage + | JSONRPCReplayMessage + | JSONRPCSetPlanModeMessage + | JSONRPCCancelMessage; + +export type JSONRPCOutMessage = + | JSONRPCSuccessResponse + | JSONRPCErrorResponse + | JSONRPCErrorResponseNullableID + | JSONRPCEventMessage + | JSONRPCRequestMessage; + +export const JSONRPC_IN_METHODS = new Set([ + "initialize", + "prompt", + "steer", + "replay", + "set_plan_mode", + "cancel", +]); + +export const JSONRPC_OUT_METHODS = new Set(["event", "request"]); + +/** + * Parse a raw JSON object into a typed inbound message. + * Discriminates based on `method` field. + */ +export function parseInboundMessage(data: unknown): JSONRPCInMessage { + const obj = data as Record; + const method = obj.method as string | undefined; + + if (method == null) { + // It's a response + if (obj.error != null) { + return JSONRPCErrorResponse.parse(data); + } + return JSONRPCSuccessResponse.parse(data); + } + + switch (method) { + case "initialize": + return JSONRPCInitializeMessage.parse(data); + case "prompt": + return JSONRPCPromptMessage.parse(data); + case "replay": + return JSONRPCReplayMessage.parse(data); + case "steer": + return JSONRPCSteerMessage.parse(data); + case "set_plan_mode": + return JSONRPCSetPlanModeMessage.parse(data); + case "cancel": + return JSONRPCCancelMessage.parse(data); + default: + throw new Error(`Unknown inbound method: ${method}`); + } +} + +// ── Error Codes ──────────────────────────────────────────── + +export const ErrorCodes = { + // Predefined JSON-RPC 2.0 error codes + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + // Application-specific error codes + INVALID_STATE: -32000, + LLM_NOT_SET: -32001, + LLM_NOT_SUPPORTED: -32002, + CHAT_PROVIDER_ERROR: -32003, + AUTH_EXPIRED: -32004, +} as const; + +export const Statuses = { + FINISHED: "finished", + CANCELLED: "cancelled", + MAX_STEPS_REACHED: "max_steps_reached", + STEERED: "steered", +} as const; diff --git a/src/kimi_cli_ts/wire/protocol.ts b/src/kimi_cli_ts/wire/protocol.ts new file mode 100644 index 000000000..dd912a315 --- /dev/null +++ b/src/kimi_cli_ts/wire/protocol.ts @@ -0,0 +1,6 @@ +/** + * Wire protocol constants — corresponds to Python's wire/protocol.py + */ + +export const WIRE_PROTOCOL_VERSION = "1.8"; +export const WIRE_PROTOCOL_LEGACY_VERSION = "1.1"; diff --git a/src/kimi_cli_ts/wire/root_hub.ts b/src/kimi_cli_ts/wire/root_hub.ts new file mode 100644 index 000000000..2a3a576c3 --- /dev/null +++ b/src/kimi_cli_ts/wire/root_hub.ts @@ -0,0 +1,39 @@ +/** + * Root Wire Hub — session-level broadcast hub for out-of-turn wire messages. + * Corresponds to Python's wire/root_hub.py + * + * Allows multiple consumers (e.g., WireServer, Shell UI) to receive + * events published from background tasks, approval runtime, etc. + */ + +import { AsyncQueue, BroadcastQueue } from "../utils/queue.ts"; +import type { WireMessage } from "./types.ts"; + +export class RootWireHub { + private _queue = new BroadcastQueue(); + + /** Create a new subscriber queue. */ + subscribe(): AsyncQueue { + return this._queue.subscribe(); + } + + /** Remove a subscriber queue. */ + unsubscribe(queue: AsyncQueue): void { + this._queue.unsubscribe(queue); + } + + /** Publish a message to all subscribers (async, for await compatibility). */ + async publish(msg: WireMessage): Promise { + this._queue.publishNowait(msg); + } + + /** Publish a message synchronously. */ + publishNowait(msg: WireMessage): void { + this._queue.publishNowait(msg); + } + + /** Shut down the hub and all subscriber queues. */ + shutdown(): void { + this._queue.shutdown(); + } +} diff --git a/src/kimi_cli_ts/wire/serde.ts b/src/kimi_cli_ts/wire/serde.ts new file mode 100644 index 000000000..bd28e55f9 --- /dev/null +++ b/src/kimi_cli_ts/wire/serde.ts @@ -0,0 +1,90 @@ +/** + * Wire serialization/deserialization — corresponds to Python's wire/serde.py + */ + +import { + type WireMessage, + type WireMessageEnvelope, + WireMessageEnvelopeSchema, + _wireMessageSchemas, + fromEnvelope, + toEnvelope, +} from "./types.ts"; + +/** + * Detect the type name of a WireMessage by trying each schema. + * Returns the first matching type name. + */ +function detectTypeName(msg: Record): string | null { + for (const [name, schema] of Object.entries(_wireMessageSchemas)) { + const result = schema.safeParse(msg); + if (result.success) return name; + } + return null; +} + +/** + * Serialize a wire message to a JSON-friendly envelope object. + * + * Overloads: + * - (typeName, payload): explicit type name + payload + * - (msg): auto-detect type from a WireMessage object + */ +export function serializeWireMessage( + msg: WireMessage | Record +): Record; +export function serializeWireMessage( + typeName: string, + payload: Record +): Record; +export function serializeWireMessage( + typeNameOrMsg: string | WireMessage | Record, + payload?: Record +): Record { + if (typeof typeNameOrMsg === "string") { + return toEnvelope(typeNameOrMsg, payload!) as Record; + } + + // Auto-detect type name from message object + const msg = typeNameOrMsg as Record; + const typeName = detectTypeName(msg); + if (!typeName) { + throw new Error(`Cannot detect wire message type for: ${JSON.stringify(msg)}`); + } + return toEnvelope(typeName, msg) as Record; +} + +/** + * Deserialize a JSON object into a validated wire message. + * @param data Raw JSON object with `type` and `payload` fields + * @returns The type name and parsed message + * @throws if the type is unknown or the payload is invalid + */ +export function deserializeWireMessage(data: unknown): { + typeName: string; + message: unknown; +} { + const envelope = WireMessageEnvelopeSchema.parse(data); + return fromEnvelope(envelope); +} + +/** + * Serialize a wire message to a JSON string. + */ +export function serializeWireMessageToJSON( + typeName: string, + payload: Record +): string { + return JSON.stringify(serializeWireMessage(typeName, payload)); +} + +/** + * Deserialize a JSON string into a validated wire message. + */ +export function deserializeWireMessageFromJSON(json: string): { + typeName: string; + message: unknown; +} { + const data = JSON.parse(json); + return deserializeWireMessage(data); +} diff --git a/src/kimi_cli_ts/wire/server.ts b/src/kimi_cli_ts/wire/server.ts new file mode 100644 index 000000000..6ff7ae06f --- /dev/null +++ b/src/kimi_cli_ts/wire/server.ts @@ -0,0 +1,955 @@ +/** + * Wire server — stdio JSON-RPC server. + * Corresponds to Python's wire/server.py (~1030 lines) + */ + +import { WIRE_PROTOCOL_VERSION } from "./protocol.ts"; +import { + AsyncQueue, + QueueShutDown, +} from "../utils/queue.ts"; +import { Wire, WireUISide } from "./wire_core.ts"; +import { WireFile } from "./file.ts"; +import { + type ApprovalRequest, + type ApprovalResponse, + type HookRequest, + type HookResponse, + type QuestionRequest, + type QuestionResponse, + type ToolCallRequest, + type WireMessage, + type Request, + isEventTypeName, + isRequestTypeName, + ApprovalResponseKind, + QuestionNotSupported, + Deferred, + PendingApprovalRequest, + PendingQuestionRequest, + PendingToolCallRequest, + PendingHookRequest, + type PendingRequest, +} from "./types.ts"; +import { + ErrorCodes, + Statuses, + JSONRPCMessage, + JSONRPCErrorObject, + type JSONRPCSuccessResponse, + type JSONRPCErrorResponse, + type JSONRPCErrorResponseNullableID, + type JSONRPCEventMessage, + type JSONRPCRequestMessage, + type JSONRPCOutMessage, + type JSONRPCInMessage, + type JSONRPCInitializeMessage, + type JSONRPCPromptMessage, + type JSONRPCSteerMessage, + type JSONRPCReplayMessage, + type JSONRPCSetPlanModeMessage, + type JSONRPCCancelMessage, + type ClientInfo, + methodIsInbound, + isResponse, + parseInboundMessage, + createEventMessage, + createRequestMessage, + JSONRPC_IN_METHODS, +} from "./jsonrpc.ts"; +import { serializeWireMessage, deserializeWireMessage } from "./serde.ts"; +import { RootWireHub } from "./root_hub.ts"; + +// Maximum buffer size for stdio reader +const STDIO_BUFFER_LIMIT = 100 * 1024 * 1024; + +export interface WireServerOptions { + /** Buffer limit for stdin reader (bytes). Default: 100MB */ + stdioBufferLimit?: number; +} + +/** + * Wire server that communicates over stdio using JSON-RPC. + * Full implementation corresponding to Python's WireServer. + */ +export class WireServer { + private _options: Required; + + // I/O + private _writeQueue: AsyncQueue = new AsyncQueue(); + private _writeLoopRunning = false; + + // State + private _initialized = false; + private _cancelEvent: Deferred | null = null; + private _pendingRequests = new Map(); + private _clientSupportsQuestion = false; + private _clientSupportsPlanMode = false; + private _dispatchPromises: Set> = new Set(); + + // Root hub + private _rootHub: RootWireHub | null = null; + private _rootHubQueue: AsyncQueue | null = null; + private _rootHubAbort: AbortController | null = null; + + // Soul interface (abstracted for decoupling) + private _soul: WireServerSoul | null = null; + + constructor(options: WireServerOptions = {}) { + this._options = { + stdioBufferLimit: options.stdioBufferLimit ?? STDIO_BUFFER_LIMIT, + }; + } + + get protocolVersion(): string { + return WIRE_PROTOCOL_VERSION; + } + + get isStreaming(): boolean { + return this._cancelEvent !== null; + } + + /** + * Attach a soul interface for handling prompts, steers, etc. + */ + setSoul(soul: WireServerSoul): void { + this._soul = soul; + } + + /** + * Attach a root wire hub for out-of-turn messages. + */ + setRootHub(hub: RootWireHub): void { + this._rootHub = hub; + } + + /** + * Start the Wire server. Reads from stdin, writes to stdout. + */ + async serve(): Promise { + console.error("[Wire] Starting Wire server on stdio"); + + // Start write loop + const writePromise = this._writeLoop(); + this._writeLoopRunning = true; + + // Subscribe to root hub if available + if (this._rootHub) { + this._rootHubQueue = this._rootHub.subscribe(); + this._rootHubAbort = new AbortController(); + this._rootHubLoop(); + } + + try { + await this._readLoop(); + } catch (err) { + if (err instanceof Error && err.message.includes("interrupted")) { + console.error("[Wire] Wire server interrupted, shutting down"); + if (this._cancelEvent) { + this._cancelEvent.resolve(); + } + } else { + throw err; + } + } finally { + await this._shutdown(); + this._writeLoopRunning = false; + } + } + + // ── Root Hub Loop ────────────────────────────────────────── + + private async _rootHubLoop(): Promise { + if (!this._rootHubQueue) return; + + while (true) { + try { + const msg = await this._rootHubQueue.get(); + if (!this._initialized) continue; + + // Check if msg has request_id (ApprovalResponse) or id (ApprovalRequest) + const msgObj = msg as Record; + if ("request_id" in msgObj && "response" in msgObj) { + // ApprovalResponse + const requestId = msgObj.request_id as string; + this._pendingRequests.delete(requestId); + await this._sendMsg(createEventMessage(msg)); + } else if ( + "id" in msgObj && + "tool_call_id" in msgObj && + "sender" in msgObj + ) { + // ApprovalRequest + await this._sendMsg( + createRequestMessage(msgObj.id as string, msg as Request) + ); + } else { + // Generic event + await this._sendMsg(createEventMessage(msg)); + } + } catch (e) { + if (e instanceof QueueShutDown) return; + console.error("[Wire] Root hub message handling failed:", e); + } + } + } + + // ── Write Loop ───────────────────────────────────────────── + + private async _writeLoop(): Promise { + try { + while (true) { + let msg: JSONRPCOutMessage; + try { + msg = await this._writeQueue.get(); + } catch (e) { + if (e instanceof QueueShutDown) break; + throw e; + } + + const json = JSON.stringify(msg) + "\n"; + const bytes = new TextEncoder().encode(json); + await Bun.write(Bun.stdout, bytes); + } + } catch (err) { + if (err instanceof QueueShutDown) return; + console.error("[Wire] Write loop error:", err); + throw err; + } + } + + // ── Read Loop ────────────────────────────────────────────── + + private async _readLoop(): Promise { + const decoder = new TextDecoder(); + let buffer = ""; + + for await (const chunk of Bun.stdin.stream()) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop()!; // Keep incomplete line in buffer + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + // Parse JSON + let msgJson: unknown; + try { + msgJson = JSON.parse(line); + } catch { + console.error("[Wire] Invalid JSON line:", line); + await this._sendMsg({ + jsonrpc: "2.0", + id: null, + error: { + code: ErrorCodes.PARSE_ERROR, + message: "Invalid JSON format", + }, + } as JSONRPCErrorResponseNullableID); + continue; + } + + // Validate generic JSON-RPC structure + const genericResult = JSONRPCMessage.safeParse(msgJson); + if (!genericResult.success) { + console.error("[Wire] Invalid JSON-RPC message:", genericResult.error); + await this._sendMsg({ + jsonrpc: "2.0", + id: null, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "Invalid request", + }, + } as JSONRPCErrorResponseNullableID); + continue; + } + + const genericMsg = genericResult.data; + + // Handle responses + if (isResponse(genericMsg)) { + try { + const msg = parseInboundMessage(msgJson); + const p = this._dispatchMsg(msg); + this._dispatchPromises.add(p); + p.finally(() => this._dispatchPromises.delete(p)); + } catch (err) { + console.error("[Wire] Invalid JSON-RPC response:", err); + } + continue; + } + + // Check method + if (!methodIsInbound(genericMsg)) { + console.error( + "[Wire] Unexpected JSON-RPC method received:", + genericMsg.method + ); + if (genericMsg.id != null) { + await this._sendMsg({ + jsonrpc: "2.0", + id: genericMsg.id, + error: { + code: ErrorCodes.METHOD_NOT_FOUND, + message: `Unexpected method received: ${genericMsg.method}`, + }, + } as JSONRPCErrorResponse); + } + continue; + } + + // Parse typed inbound message + try { + const msg = parseInboundMessage(msgJson); + const p = this._dispatchMsg(msg); + this._dispatchPromises.add(p); + p.finally(() => this._dispatchPromises.delete(p)); + } catch (err) { + console.error("[Wire] Invalid JSON-RPC inbound message:", err); + if (genericMsg.id != null) { + await this._sendMsg({ + jsonrpc: "2.0", + id: genericMsg.id, + error: { + code: ErrorCodes.INVALID_PARAMS, + message: `Invalid parameters for method \`${genericMsg.method}\``, + }, + } as JSONRPCErrorResponse); + } + } + } + } + + console.error("[Wire] stdin closed, Wire server exiting"); + } + + // ── Shutdown ─────────────────────────────────────────────── + + private async _shutdown(): Promise { + // Resolve pending requests + for (const [, request] of this._pendingRequests) { + if (request.resolved) continue; + if (request instanceof PendingApprovalRequest) { + if (request.data.source_kind === "foreground_turn") { + request.resolve("reject"); + } + } else if (request instanceof PendingToolCallRequest) { + request.resolve({ + isError: true, + output: "Wire connection closed before tool result was received.", + message: "Wire closed", + }); + } else if (request instanceof PendingQuestionRequest) { + request.resolve({}); + } else if (request instanceof PendingHookRequest) { + request.resolve("allow"); + } + } + this._pendingRequests.clear(); + + if (this._cancelEvent) { + this._cancelEvent.resolve(); + this._cancelEvent = null; + } + + this._writeQueue.shutdown(); + + // Unsubscribe from root hub + if (this._rootHubAbort) { + this._rootHubAbort.abort(); + this._rootHubAbort = null; + } + if (this._rootHub && this._rootHubQueue) { + this._rootHub.unsubscribe(this._rootHubQueue); + this._rootHubQueue = null; + } + + // Wait for dispatch tasks + await Promise.allSettled([...this._dispatchPromises]); + this._dispatchPromises.clear(); + + this._initialized = false; + } + + // ── Dispatch ─────────────────────────────────────────────── + + private async _dispatchMsg(msg: JSONRPCInMessage): Promise { + let resp: JSONRPCSuccessResponse | JSONRPCErrorResponse | null = null; + + try { + const obj = msg as Record; + + // Discriminate by method + if ("method" in obj) { + switch (obj.method) { + case "initialize": + resp = await this._handleInitialize( + msg as JSONRPCInitializeMessage + ); + break; + case "prompt": + resp = await this._handlePrompt(msg as JSONRPCPromptMessage); + break; + case "replay": + resp = await this._handleReplay(msg as JSONRPCReplayMessage); + break; + case "steer": + resp = await this._handleSteer(msg as JSONRPCSteerMessage); + break; + case "set_plan_mode": + resp = await this._handleSetPlanMode( + msg as JSONRPCSetPlanModeMessage + ); + break; + case "cancel": + resp = await this._handleCancel(msg as JSONRPCCancelMessage); + break; + default: + break; + } + } else { + // Response message (success or error) + await this._handleResponse( + msg as JSONRPCSuccessResponse | JSONRPCErrorResponse + ); + } + + if (resp) { + await this._sendMsg(resp); + } + } catch (err) { + console.error("[Wire] Unexpected error dispatching JSONRPC message:", err); + throw err; + } + } + + // ── Send ─────────────────────────────────────────────────── + + private async _sendMsg(msg: JSONRPCOutMessage): Promise { + try { + this._writeQueue.put(msg); + } catch (e) { + if (e instanceof QueueShutDown) { + console.error("[Wire] Send queue shut down; dropping message"); + } else { + throw e; + } + } + } + + // ── Handle Initialize ────────────────────────────────────── + + private async _handleInitialize( + msg: JSONRPCInitializeMessage + ): Promise { + if (this.isStreaming) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "An agent turn is already in progress", + }, + }; + } + + const result: Record = { + protocol_version: WIRE_PROTOCOL_VERSION, + server: { name: "kimi-cli", version: WIRE_PROTOCOL_VERSION }, + slash_commands: [], + }; + + // Process capabilities + if (msg.params.capabilities) { + this._clientSupportsQuestion = + msg.params.capabilities.supports_question ?? false; + this._clientSupportsPlanMode = + msg.params.capabilities.supports_plan_mode ?? false; + } + + // Notify soul of initialization if available + if (this._soul) { + const soulResult = await this._soul.onInitialize(msg.params); + Object.assign(result, soulResult); + } + + result.capabilities = { supports_question: true }; + + this._initialized = true; + + return { + jsonrpc: "2.0", + id: msg.id, + result, + }; + } + + // ── Handle Prompt ────────────────────────────────────────── + + private async _handlePrompt( + msg: JSONRPCPromptMessage + ): Promise { + if (this.isStreaming) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "An agent turn is already in progress", + }, + }; + } + + this._cancelEvent = new Deferred(); + + try { + if (!this._soul) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.LLM_NOT_SET, + message: "Soul is not configured", + }, + }; + } + + const status = await this._soul.onPrompt( + msg.params.user_input, + (wire: Wire) => this._streamWireMessages(wire), + this._cancelEvent + ); + + return { + jsonrpc: "2.0", + id: msg.id, + result: { status }, + }; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + const errCode = this._mapErrorCode(error); + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: errCode, + message: error.message || "Unknown error", + }, + }; + } finally { + // Clean up stale pending requests + for (const [msgId, request] of this._pendingRequests) { + if (request.resolved) continue; + if (request instanceof PendingApprovalRequest) { + if (request.data.source_kind === "foreground_turn") { + this._pendingRequests.delete(msgId); + request.resolve("reject"); + } + } else if (request instanceof PendingToolCallRequest) { + this._pendingRequests.delete(msgId); + request.resolve({ + isError: true, + output: "Agent turn ended before tool result was received.", + message: "Turn ended", + }); + } else if (request instanceof PendingQuestionRequest) { + this._pendingRequests.delete(msgId); + request.resolve({}); + } else if (request instanceof PendingHookRequest) { + this._pendingRequests.delete(msgId); + request.resolve("allow"); + } + } + this._cancelEvent = null; + } + } + + private _mapErrorCode(err: Error & { code?: string }): number { + switch (err.code) { + case "LLM_NOT_SET": + return ErrorCodes.LLM_NOT_SET; + case "LLM_NOT_SUPPORTED": + return ErrorCodes.LLM_NOT_SUPPORTED; + case "CHAT_PROVIDER_ERROR": + return ErrorCodes.CHAT_PROVIDER_ERROR; + case "AUTH_EXPIRED": + return ErrorCodes.AUTH_EXPIRED; + default: + return ErrorCodes.INTERNAL_ERROR; + } + } + + // ── Handle Steer ─────────────────────────────────────────── + + private async _handleSteer( + msg: JSONRPCSteerMessage + ): Promise { + if (!this.isStreaming || !this._soul) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "No agent turn is in progress", + }, + }; + } + + this._soul.onSteer(msg.params.user_input); + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: Statuses.STEERED }, + }; + } + + // ── Handle Set Plan Mode ─────────────────────────────────── + + private async _handleSetPlanMode( + msg: JSONRPCSetPlanModeMessage + ): Promise { + if (!this._soul) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "Plan mode is not supported", + }, + }; + } + + const newState = await this._soul.onSetPlanMode(msg.params.enabled); + + // Send status update event + const statusUpdate = { plan_mode: newState }; + await this._sendMsg(createEventMessage(statusUpdate as any)); + + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: "ok", plan_mode: newState }, + }; + } + + // ── Handle Replay ────────────────────────────────────────── + + private async _handleReplay( + msg: JSONRPCReplayMessage + ): Promise { + if (this.isStreaming) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "An agent turn is already in progress", + }, + }; + } + + const wireFile = this._soul?.wireFile; + this._cancelEvent = new Deferred(); + + let events = 0; + let requests = 0; + + try { + if (!wireFile) { + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: Statuses.FINISHED, events: 0, requests: 0 }, + }; + } + + for await (const record of wireFile.iterRecords()) { + if (this._cancelEvent.settled) { + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: Statuses.CANCELLED, events, requests }, + }; + } + + try { + const { typeName, message } = deserializeWireMessage({ + type: record.message.type, + payload: record.message.payload, + }); + + if (isRequestTypeName(typeName)) { + const reqMsg = message as Request; + const reqObj = reqMsg as Record; + await this._sendMsg( + createRequestMessage(reqObj.id as string, reqMsg) + ); + requests++; + } else if (isEventTypeName(typeName)) { + await this._sendMsg({ + jsonrpc: "2.0", + method: "event", + params: serializeWireMessage(typeName, record.message.payload), + } as JSONRPCEventMessage); + events++; + } + } catch (err) { + console.error("[Wire] Failed to deserialize wire record for replay:", err); + continue; + } + + // Yield control + await new Promise((r) => setTimeout(r, 0)); + } + + if (this._cancelEvent.settled) { + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: Statuses.CANCELLED, events, requests }, + }; + } + + return { + jsonrpc: "2.0", + id: msg.id, + result: { status: Statuses.FINISHED, events, requests }, + }; + } catch (err) { + console.error("[Wire] Replay failed:", err); + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INTERNAL_ERROR, + message: "Replay failed", + }, + }; + } finally { + this._cancelEvent = null; + } + } + + // ── Handle Cancel ────────────────────────────────────────── + + private async _handleCancel( + msg: JSONRPCCancelMessage + ): Promise { + if (!this.isStreaming) { + return { + jsonrpc: "2.0", + id: msg.id, + error: { + code: ErrorCodes.INVALID_STATE, + message: "No agent turn is in progress", + }, + }; + } + + this._cancelEvent!.resolve(); + return { + jsonrpc: "2.0", + id: msg.id, + result: {}, + }; + } + + // ── Handle Response ──────────────────────────────────────── + + private async _handleResponse( + msg: JSONRPCSuccessResponse | JSONRPCErrorResponse + ): Promise { + const id = msg.id; + const request = this._pendingRequests.get(id); + if (!request) { + console.error(`[Wire] No pending request for response id=${id}`); + return; + } + this._pendingRequests.delete(id); + + const isError = "error" in msg; + + if (request instanceof PendingApprovalRequest) { + if (isError) { + request.resolve("reject"); + return; + } + try { + const result = ApprovalResponseKind.safeParse( + (msg as JSONRPCSuccessResponse).result + ); + // Parse as ApprovalResponse object + const resultObj = (msg as JSONRPCSuccessResponse).result as Record< + string, + unknown + >; + const response = (resultObj.response ?? "reject") as + | "approve" + | "approve_for_session" + | "reject"; + const feedback = (resultObj.feedback ?? "") as string; + request.resolve(response, feedback); + } catch { + request.resolve("reject"); + } + } else if (request instanceof PendingToolCallRequest) { + if (isError) { + request.resolve({ + isError: true, + output: (msg as JSONRPCErrorResponse).error.message, + message: "External tool error", + }); + return; + } + request.resolve((msg as JSONRPCSuccessResponse).result); + } else if (request instanceof PendingQuestionRequest) { + if (isError) { + request.resolve({}); + return; + } + try { + const resultObj = (msg as JSONRPCSuccessResponse).result as Record< + string, + unknown + >; + const answers = (resultObj.answers ?? {}) as Record; + request.resolve(answers); + } catch { + request.resolve({}); + } + } else if (request instanceof PendingHookRequest) { + if (isError) { + request.resolve("allow"); + return; + } + try { + const resultObj = (msg as JSONRPCSuccessResponse).result as Record< + string, + unknown + >; + const action = (resultObj.action ?? "allow") as "allow" | "block"; + const reason = (resultObj.reason ?? "") as string; + request.resolve(action, reason); + } catch { + request.resolve("allow"); + } + } + } + + // ── Stream Wire Messages ─────────────────────────────────── + + private async _streamWireMessages(wire: Wire): Promise { + const wireUi = wire.uiSide(false); + + while (true) { + let msg: WireMessage; + try { + msg = await wireUi.receive(); + } catch (e) { + if (e instanceof QueueShutDown) break; + throw e; + } + + const msgObj = msg as Record; + + // Check if it's an ApprovalRequest + if ( + "id" in msgObj && + "tool_call_id" in msgObj && + "sender" in msgObj && + "action" in msgObj + ) { + await this._requestApproval(msg as ApprovalRequest); + } + // Check if it's a ToolCallRequest + else if ( + "id" in msgObj && + "name" in msgObj && + "arguments" in msgObj && + !("tool_call_id" in msgObj && "sender" in msgObj) + ) { + await this._requestExternalTool(msg as ToolCallRequest); + } + // Check if it's a QuestionRequest + else if ("id" in msgObj && "tool_call_id" in msgObj && "questions" in msgObj) { + await this._requestQuestion(msg as QuestionRequest); + } + // Check if it's a HookRequest + else if ("id" in msgObj && "subscription_id" in msgObj && "event" in msgObj) { + // HookRequest — handled via hook engine callbacks + } + // Generic event + else { + await this._sendMsg(createEventMessage(msg)); + } + } + } + + private async _requestApproval(request: ApprovalRequest): Promise { + const msgId = request.id; + const pending = new PendingApprovalRequest(request); + this._pendingRequests.set(msgId, pending); + await this._sendMsg(createRequestMessage(msgId, request as Request)); + // Do NOT await — same rationale as Python: avoid deadlocking the UI loop + } + + private async _requestExternalTool(request: ToolCallRequest): Promise { + const msgId = request.id; + const pending = new PendingToolCallRequest(request); + this._pendingRequests.set(msgId, pending); + await this._sendMsg(createRequestMessage(msgId, request as Request)); + } + + private async _requestQuestion(request: QuestionRequest): Promise { + if (!this._clientSupportsQuestion) { + // Client does not support interactive questions + const pending = new PendingQuestionRequest(request); + pending.setException(new QuestionNotSupported()); + return; + } + const msgId = request.id; + const pending = new PendingQuestionRequest(request); + this._pendingRequests.set(msgId, pending); + await this._sendMsg(createRequestMessage(msgId, request as Request)); + } +} + +// ── Soul Interface ───────────────────────────────────────── + +/** + * Interface that the Soul layer implements to integrate with WireServer. + * This decouples the Wire server from the Soul implementation. + */ +export interface WireServerSoul { + /** + * Called on initialize. Returns additional result fields. + */ + onInitialize( + params: Record + ): Promise>; + + /** + * Called on prompt. Should run the soul and return a status string. + */ + onPrompt( + userInput: string | unknown[], + streamCallback: (wire: Wire) => Promise, + cancelEvent: Deferred + ): Promise; + + /** + * Called on steer. + */ + onSteer(userInput: string | unknown[]): void; + + /** + * Called on set_plan_mode. Returns new plan mode state. + */ + onSetPlanMode(enabled: boolean): Promise; + + /** + * Wire file for replay. + */ + wireFile?: WireFile; +} diff --git a/src/kimi_cli_ts/wire/types.ts b/src/kimi_cli_ts/wire/types.ts new file mode 100644 index 000000000..5b3c01a84 --- /dev/null +++ b/src/kimi_cli_ts/wire/types.ts @@ -0,0 +1,701 @@ +/** + * Wire event types — corresponds to Python's wire/types.py + * Uses Zod v4 discriminated unions for runtime validation. + */ + +import { z } from "zod/v4"; +import { + ContentPart, + TokenUsage, + ToolCall, + ToolReturnValue, + type JsonValue, +} from "../types.ts"; + +// ── Display Blocks ───────────────────────────────────────── + +export const BriefDisplayBlock = z.object({ + type: z.literal("brief"), + brief: z.string(), +}); +export type BriefDisplayBlock = z.infer; + +export const DiffDisplayBlock = z.object({ + type: z.literal("diff"), + path: z.string(), + old_text: z.string(), + new_text: z.string(), + old_start: z.number().default(1), + new_start: z.number().default(1), + is_summary: z.boolean().default(false), +}); +export type DiffDisplayBlock = z.infer; + +export const TodoDisplayItem = z.object({ + title: z.string(), + status: z.enum(["pending", "in_progress", "done"]), +}); +export type TodoDisplayItem = z.infer; + +export const TodoDisplayBlock = z.object({ + type: z.literal("todo"), + items: z.array(TodoDisplayItem), +}); +export type TodoDisplayBlock = z.infer; + +export const ShellDisplayBlock = z.object({ + type: z.literal("shell"), + language: z.string(), + command: z.string(), +}); +export type ShellDisplayBlock = z.infer; + +export const BackgroundTaskDisplayBlock = z.object({ + type: z.literal("background_task"), + task_id: z.string(), + kind: z.string(), + status: z.string(), + description: z.string(), +}); +export type BackgroundTaskDisplayBlock = z.infer< + typeof BackgroundTaskDisplayBlock +>; + +export const UnknownDisplayBlock = z + .object({ + type: z.string(), + }) + .passthrough(); +export type UnknownDisplayBlock = z.infer; + +export const DisplayBlock = z.union([ + BriefDisplayBlock, + DiffDisplayBlock, + TodoDisplayBlock, + ShellDisplayBlock, + BackgroundTaskDisplayBlock, + UnknownDisplayBlock, +]); +export type DisplayBlock = z.infer; + +// ── Wire Event Types ─────────────────────────────────────── + +/** Beginning of a new agent turn. Must be sent before any other event. */ +export const TurnBegin = z.object({ + user_input: z.union([z.string(), z.array(ContentPart)]), +}); +export type TurnBegin = z.infer; + +/** User appended follow-up input to the current running turn. */ +export const SteerInput = z.object({ + user_input: z.union([z.string(), z.array(ContentPart)]), +}); +export type SteerInput = z.infer; + +/** End of the current agent turn. */ +export const TurnEnd = z.object({}); +export type TurnEnd = z.infer; + +/** Beginning of a new agent step. */ +export const StepBegin = z.object({ + n: z.number(), +}); +export type StepBegin = z.infer; + +/** Current step was interrupted. */ +export const StepInterrupted = z.object({}); +export type StepInterrupted = z.infer; + +/** Compaction just began. */ +export const CompactionBegin = z.object({}); +export type CompactionBegin = z.infer; + +/** Compaction just ended. */ +export const CompactionEnd = z.object({}); +export type CompactionEnd = z.infer; + +/** A batch of hooks has been triggered and is now executing. */ +export const HookTriggered = z.object({ + event: z.string(), + target: z.string().default(""), + hook_count: z.number().default(1), +}); +export type HookTriggered = z.infer; + +/** A batch of hooks has finished executing. */ +export const HookResolved = z.object({ + event: z.string(), + target: z.string().default(""), + action: z.enum(["allow", "block"]).default("allow"), + reason: z.string().default(""), + duration_ms: z.number().default(0), +}); +export type HookResolved = z.infer; + +/** MCP tool loading is in progress. */ +export const MCPLoadingBegin = z.object({}); +export type MCPLoadingBegin = z.infer; + +/** MCP tool loading has finished. */ +export const MCPLoadingEnd = z.object({}); +export type MCPLoadingEnd = z.infer; + +/** Snapshot of one MCP server during startup. */ +export const MCPServerSnapshot = z.object({ + name: z.string(), + status: z.enum([ + "pending", + "connecting", + "connected", + "failed", + "unauthorized", + ]), + tools: z.array(z.string()).default([]), +}); +export type MCPServerSnapshot = z.infer; + +/** Snapshot of MCP startup progress. */ +export const MCPStatusSnapshot = z.object({ + loading: z.boolean(), + connected: z.number(), + total: z.number(), + tools: z.number(), + servers: z.array(MCPServerSnapshot).default([]), +}); +export type MCPStatusSnapshot = z.infer; + +/** Status update on the current state of the soul. None fields = no change. */ +export const StatusUpdate = z.object({ + context_usage: z.number().nullable().default(null), + context_tokens: z.number().nullable().default(null), + max_context_tokens: z.number().nullable().default(null), + token_usage: TokenUsage.nullable().default(null), + message_id: z.string().nullable().default(null), + plan_mode: z.boolean().nullable().default(null), + mcp_status: MCPStatusSnapshot.nullable().default(null), +}); +export type StatusUpdate = z.infer; + +/** Generic system notification. */ +export const Notification = z.object({ + id: z.string(), + category: z.string(), + type: z.string(), + source_kind: z.string(), + source_id: z.string(), + title: z.string(), + body: z.string(), + severity: z.string(), + created_at: z.number(), + payload: z.record(z.string(), z.unknown()).default({}), +}); +export type Notification = z.infer; + +/** Displays a plan's content inline in the chat. */ +export const PlanDisplay = z.object({ + content: z.string(), + file_path: z.string(), +}); +export type PlanDisplay = z.infer; + +// ── Content Part types ───────────────────────────────────── + +export const TextPart = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +export const ThinkPart = z.object({ + type: z.literal("think"), + text: z.string(), +}); + +export const ImageURLPart = z.object({ + type: z.literal("image"), + source: z.object({ + type: z.enum(["base64", "url"]), + mediaType: z.string().optional(), + data: z.string(), + }), +}); + +export const AudioURLPart = z.object({ + type: z.literal("audio"), + source: z.object({ + type: z.enum(["base64", "url"]), + mediaType: z.string().optional(), + data: z.string(), + }), +}); + +export const VideoURLPart = z.object({ + type: z.literal("video"), + source: z.object({ + type: z.enum(["base64", "url"]), + mediaType: z.string().optional(), + data: z.string(), + }), +}); + +export const ToolCallPart = z.object({ + type: z.literal("tool_use"), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), +}); + +// ── ToolResult ───────────────────────────────────────────── + +export const ToolResult = z.object({ + tool_call_id: z.string(), + return_value: ToolReturnValue, + display: z.array(DisplayBlock).default([]), +}); +export type ToolResult = z.infer; + +// ── Approval ─────────────────────────────────────────────── + +export const ApprovalResponseKind = z.enum([ + "approve", + "approve_for_session", + "reject", +]); +export type ApprovalResponseKind = z.infer; + +export const ApprovalResponse = z.object({ + request_id: z.string(), + response: ApprovalResponseKind, + feedback: z.string().default(""), +}); +export type ApprovalResponse = z.infer; + +export const ApprovalRequestSchema = z.object({ + id: z.string(), + tool_call_id: z.string(), + sender: z.string(), + action: z.string(), + description: z.string(), + source_kind: z + .enum(["foreground_turn", "background_agent"]) + .nullable() + .default(null), + source_id: z.string().nullable().default(null), + agent_id: z.string().nullable().default(null), + subagent_type: z.string().nullable().default(null), + source_description: z.string().nullable().default(null), + display: z.array(DisplayBlock).default([]), +}); +export type ApprovalRequest = z.infer; + +// Keep original schema name for registry +export const ApprovalRequest = ApprovalRequestSchema; + +// ── Question ─────────────────────────────────────────────── + +export const QuestionOption = z.object({ + label: z.string(), + description: z.string().default(""), +}); +export type QuestionOption = z.infer; + +export const QuestionItem = z.object({ + question: z.string(), + header: z.string().default(""), + options: z.array(QuestionOption), + multi_select: z.boolean().default(false), + body: z.string().default(""), + other_label: z.string().default(""), + other_description: z.string().default(""), +}); +export type QuestionItem = z.infer; + +export const QuestionResponse = z.object({ + request_id: z.string(), + answers: z.record(z.string(), z.string()), +}); +export type QuestionResponse = z.infer; + +export const QuestionRequestSchema = z.object({ + id: z.string(), + tool_call_id: z.string(), + questions: z.array(QuestionItem), +}); +export type QuestionRequest = z.infer; + +export const QuestionRequest = QuestionRequestSchema; + +export class QuestionNotSupported extends Error { + constructor() { + super("Connected client does not support interactive questions"); + this.name = "QuestionNotSupported"; + } +} + +// ── Tool Call Request ────────────────────────────────────── + +export const ToolCallRequestSchema = z.object({ + id: z.string(), + name: z.string(), + arguments: z.string().nullable(), +}); +export type ToolCallRequest = z.infer; + +export const ToolCallRequest = ToolCallRequestSchema; + +// ── Hook ─────────────────────────────────────────────────── + +export const HookResponse = z.object({ + request_id: z.string(), + action: z.enum(["allow", "block"]).default("allow"), + reason: z.string().default(""), +}); +export type HookResponse = z.infer; + +export const HookRequestSchema = z.object({ + id: z.string(), + subscription_id: z.string().default(""), + event: z.string(), + target: z.string().default(""), + input_data: z.record(z.string(), z.unknown()).default({}), +}); +export type HookRequest = z.infer; + +export const HookRequest = HookRequestSchema; + +// ── SubagentEvent ────────────────────────────────────────── + +export const SubagentEvent = z.object({ + parent_tool_call_id: z.string().nullable().default(null), + agent_id: z.string().nullable().default(null), + subagent_type: z.string().nullable().default(null), + event: z.record(z.string(), z.unknown()), // envelope: { type, payload } +}); +export type SubagentEvent = z.infer; + +// ── Promise-based Async Resolution Wrappers ──────────────── + +/** + * Wraps a Request with a Promise for async resolution. + * Corresponds to Python's Future-based pattern on ApprovalRequest, etc. + */ +export class Deferred { + readonly promise: Promise; + private _resolve!: (value: T) => void; + private _reject!: (err: Error) => void; + private _settled = false; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + get settled(): boolean { + return this._settled; + } + + resolve(value: T): void { + if (!this._settled) { + this._settled = true; + this._resolve(value); + } + } + + reject(err: Error): void { + if (!this._settled) { + this._settled = true; + this._reject(err); + } + } +} + +/** ApprovalRequest with async resolution. */ +export class PendingApprovalRequest { + readonly data: ApprovalRequest; + private _deferred = new Deferred(); + private _feedback = ""; + + constructor(data: ApprovalRequest) { + this.data = data; + } + + get id(): string { + return this.data.id; + } + + get resolved(): boolean { + return this._deferred.settled; + } + + get feedback(): string { + return this._feedback; + } + + async wait(): Promise { + return this._deferred.promise; + } + + resolve(response: ApprovalResponseKind, feedback = ""): void { + this._feedback = feedback; + this._deferred.resolve(response); + } +} + +/** QuestionRequest with async resolution. */ +export class PendingQuestionRequest { + readonly data: QuestionRequest; + private _deferred = new Deferred>(); + + constructor(data: QuestionRequest) { + this.data = data; + } + + get id(): string { + return this.data.id; + } + + get resolved(): boolean { + return this._deferred.settled; + } + + async wait(): Promise> { + return this._deferred.promise; + } + + resolve(answers: Record): void { + this._deferred.resolve(answers); + } + + setException(err: Error): void { + this._deferred.reject(err); + } +} + +/** ToolCallRequest with async resolution. */ +export class PendingToolCallRequest { + readonly data: ToolCallRequest; + private _deferred = new Deferred(); + + constructor(data: ToolCallRequest) { + this.data = data; + } + + get id(): string { + return this.data.id; + } + + get resolved(): boolean { + return this._deferred.settled; + } + + async wait(): Promise { + return this._deferred.promise; + } + + resolve(result: unknown): void { + this._deferred.resolve(result); + } +} + +/** HookRequest with async resolution. */ +export class PendingHookRequest { + readonly data: HookRequest; + private _deferred = new Deferred<{ action: "allow" | "block"; reason: string }>(); + + constructor(data: HookRequest) { + this.data = data; + } + + get id(): string { + return this.data.id; + } + + get resolved(): boolean { + return this._deferred.settled; + } + + async wait(): Promise<{ action: "allow" | "block"; reason: string }> { + return this._deferred.promise; + } + + resolve(action: "allow" | "block", reason = ""): void { + this._deferred.resolve({ action, reason }); + } +} + +export type PendingRequest = + | PendingApprovalRequest + | PendingQuestionRequest + | PendingToolCallRequest + | PendingHookRequest; + +// ── Union Types ──────────────────────────────────────────── + +/** + * All possible event types sent over the Wire. + * Events are fire-and-forget; they do not expect a response. + */ +export type Event = + | TurnBegin + | SteerInput + | TurnEnd + | StepBegin + | StepInterrupted + | HookTriggered + | HookResolved + | CompactionBegin + | CompactionEnd + | MCPLoadingBegin + | MCPLoadingEnd + | StatusUpdate + | Notification + | PlanDisplay + | ApprovalResponse + | SubagentEvent + | ToolResult; + +/** + * All possible request types. Requests expect a response. + */ +export type Request = + | ApprovalRequest + | ToolCallRequest + | QuestionRequest + | HookRequest; + +/** + * Any message sent over the Wire. + */ +export type WireMessage = Event | Request; + +// ── Name → Schema registry ──────────────────────────────── + +export const _wireMessageSchemas: Record> = { + TurnBegin, + SteerInput, + TurnEnd, + StepBegin, + StepInterrupted, + CompactionBegin, + CompactionEnd, + HookTriggered, + HookResolved, + MCPLoadingBegin, + MCPLoadingEnd, + StatusUpdate, + Notification, + PlanDisplay, + ApprovalResponse, + SubagentEvent, + ToolResult, + // Requests + ApprovalRequest, + ToolCallRequest, + QuestionRequest, + HookRequest, + // Content parts (also valid events in Python) + TextPart, + ThinkPart, + ImageURLPart, + AudioURLPart, + VideoURLPart, + ToolCallPart, + ToolCall, + // Backwards compatibility + ApprovalRequestResolved: ApprovalResponse, +}; + +/** Known event type names (fire-and-forget). */ +const _eventTypeNames = new Set([ + "TurnBegin", + "SteerInput", + "TurnEnd", + "StepBegin", + "StepInterrupted", + "CompactionBegin", + "CompactionEnd", + "HookTriggered", + "HookResolved", + "MCPLoadingBegin", + "MCPLoadingEnd", + "StatusUpdate", + "Notification", + "PlanDisplay", + "ApprovalResponse", + "SubagentEvent", + "ToolResult", + "TextPart", + "ThinkPart", + "ImageURLPart", + "AudioURLPart", + "VideoURLPart", + "ToolCallPart", + "ToolCall", + "ApprovalRequestResolved", +]); + +/** Known request type names (expect a response). */ +const _requestTypeNames = new Set([ + "ApprovalRequest", + "ToolCallRequest", + "QuestionRequest", + "HookRequest", +]); + +// ── WireMessageEnvelope ──────────────────────────────────── + +export const WireMessageEnvelopeSchema = z.object({ + type: z.string(), + payload: z.record(z.string(), z.unknown()), +}); +export type WireMessageEnvelope = z.infer; + +/** + * Create an envelope from a typed wire message. + */ +export function toEnvelope( + typeName: string, + payload: Record +): WireMessageEnvelope { + return { type: typeName, payload }; +} + +/** + * Parse an envelope back into a validated WireMessage. + * Returns the parsed object and its type name. + */ +export function fromEnvelope(envelope: WireMessageEnvelope): { + typeName: string; + message: unknown; +} { + const schema = _wireMessageSchemas[envelope.type]; + if (!schema) { + throw new Error(`Unknown wire message type: ${envelope.type}`); + } + const message = schema.parse(envelope.payload); + return { typeName: envelope.type, message }; +} + +/** + * Check if a type name corresponds to an Event. + */ +export function isEventTypeName(typeName: string): boolean { + return _eventTypeNames.has(typeName); +} + +/** + * Check if a type name corresponds to a Request. + */ +export function isRequestTypeName(typeName: string): boolean { + return _requestTypeNames.has(typeName); +} + +/** + * Get the Zod schema for a wire message type name. + */ +export function getWireMessageSchema( + typeName: string +): z.ZodType | undefined { + return _wireMessageSchemas[typeName]; +} diff --git a/src/kimi_cli_ts/wire/wire_core.ts b/src/kimi_cli_ts/wire/wire_core.ts new file mode 100644 index 000000000..afde7757c --- /dev/null +++ b/src/kimi_cli_ts/wire/wire_core.ts @@ -0,0 +1,198 @@ +/** + * Wire core classes — corresponds to the Wire/WireSoulSide/WireUISide from Python wire/__init__.py + * Separated to avoid circular imports with server.ts. + */ + +import { + AsyncQueue, + BroadcastQueue, + QueueShutDown, +} from "../utils/queue.ts"; +import { WireFile } from "./file.ts"; +import type { WireMessage } from "./types.ts"; + +type WireMessageQueue = BroadcastQueue; + +/** + * MergeableMixin interface — messages implementing this can be merged in-place. + */ +export interface Mergeable { + /** Try to merge another message into this one. Returns true if merged. */ + mergeInPlace(other: WireMessage): boolean; +} + +export function isMergeable(msg: unknown): msg is Mergeable { + return ( + msg != null && + typeof msg === "object" && + "mergeInPlace" in msg && + typeof (msg as Mergeable).mergeInPlace === "function" + ); +} + +/** + * A spmc channel for communication between the soul and the UI during a soul run. + */ +export class Wire { + private _rawQueue: WireMessageQueue; + private _mergedQueue: WireMessageQueue; + private _soulSide: WireSoulSide; + private _recorder: _WireRecorder | null; + + constructor(opts?: { fileBackend?: WireFile }) { + this._rawQueue = new BroadcastQueue(); + this._mergedQueue = new BroadcastQueue(); + this._soulSide = new WireSoulSide(this._rawQueue, this._mergedQueue); + + if (opts?.fileBackend) { + this._recorder = new _WireRecorder( + opts.fileBackend, + this._mergedQueue.subscribe() + ); + } else { + this._recorder = null; + } + } + + get soulSide(): WireSoulSide { + return this._soulSide; + } + + /** + * Create a UI side of the Wire. + * @param merge Whether to merge Wire messages as much as possible. + */ + uiSide(merge: boolean): WireUISide { + if (merge) { + return new WireUISide(this._mergedQueue.subscribe()); + } else { + return new WireUISide(this._rawQueue.subscribe()); + } + } + + shutdown(): void { + this._soulSide.flush(); + this._rawQueue.shutdown(); + this._mergedQueue.shutdown(); + } + + async join(): Promise { + if (this._recorder === null) return; + try { + await this._recorder.join(); + } catch (err) { + console.error("Wire recorder failed to flush:", err); + } + } +} + +/** + * The soul side of a Wire. + */ +export class WireSoulSide { + private _rawQueue: WireMessageQueue; + private _mergedQueue: WireMessageQueue; + private _mergeBuffer: (WireMessage & Mergeable) | null = null; + + constructor(rawQueue: WireMessageQueue, mergedQueue: WireMessageQueue) { + this._rawQueue = rawQueue; + this._mergedQueue = mergedQueue; + } + + send(msg: WireMessage): void { + // Send raw message + try { + this._rawQueue.publishNowait(msg); + } catch (e) { + if (e instanceof QueueShutDown) { + // Queue shut down, drop the message + } else { + throw e; + } + } + + // Merge and send merged message + if (isMergeable(msg)) { + if (this._mergeBuffer === null) { + this._mergeBuffer = structuredClone(msg) as WireMessage & Mergeable; + } else if (this._mergeBuffer.mergeInPlace(msg)) { + // Successfully merged + } else { + this.flush(); + this._mergeBuffer = structuredClone(msg) as WireMessage & Mergeable; + } + } else { + this.flush(); + this._sendMerged(msg); + } + } + + flush(): void { + const buffer = this._mergeBuffer; + if (buffer === null) return; + this._sendMerged(buffer as WireMessage); + this._mergeBuffer = null; + } + + private _sendMerged(msg: WireMessage): void { + try { + this._mergedQueue.publishNowait(msg); + } catch (e) { + if (e instanceof QueueShutDown) { + // Queue shut down, drop the message + } else { + throw e; + } + } + } +} + +/** + * The UI side of a Wire. + */ +export class WireUISide { + private _queue: AsyncQueue; + + constructor(queue: AsyncQueue) { + this._queue = queue; + } + + async receive(): Promise { + return await this._queue.get(); + } +} + +/** + * Async consumer that records Wire messages to a WireFile. + */ +class _WireRecorder { + private _wireFile: WireFile; + private _running: Promise; + + constructor(wireFile: WireFile, queue: AsyncQueue) { + this._wireFile = wireFile; + this._running = this._consumeLoop(queue); + } + + async join(): Promise { + await this._running; + } + + private async _consumeLoop(queue: AsyncQueue): Promise { + while (true) { + try { + const msg = await queue.get(); + await this._record(msg); + } catch (e) { + if (e instanceof QueueShutDown) { + break; + } + throw e; + } + } + } + + private async _record(msg: WireMessage): Promise { + await this._wireFile.appendMessage(msg as Record); + } +} diff --git a/tests/background/background_manager.test.ts b/tests/background/background_manager.test.ts new file mode 100644 index 000000000..bf0861c46 --- /dev/null +++ b/tests/background/background_manager.test.ts @@ -0,0 +1,51 @@ +/** + * Placeholder tests for background task manager. + * Corresponds to Python tests/background/test_manager.py + * + * The full background task system is not yet ported to TypeScript. + * These tests verify the basic test infrastructure and placeholder structure. + */ + +import { test, expect, describe, afterEach } from "bun:test"; +import { TestContext, createTestConfig, createTestRuntime } from "../conftest"; + +describe("background task manager (placeholder)", () => { + let ctx: TestContext; + + afterEach(() => { + ctx?.cleanup(); + }); + + test("test config includes background settings", () => { + const config = createTestConfig(); + expect(config.background).toBeDefined(); + expect(config.background.max_running_tasks).toBe(4); + expect(config.background.read_max_bytes).toBe(30000); + expect(config.background.agent_task_timeout_s).toBe(900); + }); + + test("test runtime can be created with default config", () => { + ctx = new TestContext(); + const runtime = createTestRuntime(ctx); + expect(runtime).toBeDefined(); + }); + + test("background config can be overridden", () => { + const config = createTestConfig({ + background: { + max_running_tasks: 2, + read_max_bytes: 10000, + notification_tail_lines: 10, + notification_tail_chars: 1000, + wait_poll_interval_ms: 200, + worker_heartbeat_interval_ms: 3000, + worker_stale_after_ms: 10000, + kill_grace_period_ms: 1000, + keep_alive_on_exit: true, + agent_task_timeout_s: 600, + }, + }); + expect(config.background.max_running_tasks).toBe(2); + expect(config.background.keep_alive_on_exit).toBe(true); + }); +}); diff --git a/tests/conftest.ts b/tests/conftest.ts new file mode 100644 index 000000000..cfd00fb4f --- /dev/null +++ b/tests/conftest.ts @@ -0,0 +1,291 @@ +/** + * Test fixtures and helpers — corresponds to Python tests/conftest.py + * Shared utilities for all test files. + */ + +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { Config } from "../src/kimi_cli_ts/config.ts"; +import { type LLM, type LLMProvider, type StreamChunk, type ChatOptions } from "../src/kimi_cli_ts/llm.ts"; +import type { Message, TokenUsage, ModelCapability } from "../src/kimi_cli_ts/types.ts"; +import { Session, SessionState } from "../src/kimi_cli_ts/session.ts"; +import { Approval, ApprovalState } from "../src/kimi_cli_ts/soul/approval.ts"; +import { HookEngine } from "../src/kimi_cli_ts/hooks/engine.ts"; +import { Runtime, type BuiltinSystemPromptArgs } from "../src/kimi_cli_ts/soul/agent.ts"; +import { Context } from "../src/kimi_cli_ts/soul/context.ts"; +import type { ToolContext, ToolResult } from "../src/kimi_cli_ts/tools/types.ts"; + +// ── Temp directory helper ─────────────────────────── + +export function createTempDir(prefix = "kimi-test-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function removeTempDir(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +// ── Test context that sets up and tears down ──────── + +export class TestContext { + readonly tempDir: string; + readonly workDir: string; + readonly shareDir: string; + + constructor() { + this.tempDir = createTempDir(); + this.workDir = join(this.tempDir, "work"); + this.shareDir = join(this.tempDir, "share"); + Bun.spawnSync(["mkdir", "-p", this.workDir, this.shareDir]); + } + + cleanup(): void { + removeTempDir(this.tempDir); + } +} + +// ── Mock LLM Provider ─────────────────────────────── + +export class MockChatProvider implements LLMProvider { + readonly modelName: string; + private responses: StreamChunk[][]; + private callIndex = 0; + readonly calls: { messages: Message[]; options?: ChatOptions }[] = []; + + constructor(responses: StreamChunk[][] = [], modelName = "mock-model") { + this.responses = responses; + this.modelName = modelName; + } + + async *chat( + messages: Message[], + options?: ChatOptions, + ): AsyncIterable { + this.calls.push({ messages, options }); + const chunks = this.responses[this.callIndex] ?? []; + this.callIndex++; + for (const chunk of chunks) { + yield chunk; + } + } +} + +/** + * Create a mock LLM that returns scripted responses. + */ +export function createMockLLM( + responses: StreamChunk[][] = [], + opts?: { maxContextSize?: number; capabilities?: ModelCapability[] }, +): { llm: LLM; provider: MockChatProvider } { + const provider = new MockChatProvider(responses); + const llm: LLM = { + provider, + maxContextSize: opts?.maxContextSize ?? 100_000, + capabilities: new Set( + opts?.capabilities ?? ["image_in", "thinking"], + ), + modelConfig: null, + providerConfig: null, + get modelName() { + return provider.modelName; + }, + hasCapability(cap: ModelCapability) { + return this.capabilities.has(cap); + }, + chat(messages: Message[], options?: ChatOptions) { + return provider.chat(messages, options); + }, + }; + return { llm, provider }; +} + +// ── Default Config ────────────────────────────────── + +export function createTestConfig(overrides?: Partial): Config { + return { + default_model: "", + default_thinking: false, + default_yolo: false, + default_editor: "", + theme: "dark", + models: {}, + providers: {}, + loop_control: { + max_steps_per_turn: 10, + max_retries_per_step: 3, + max_ralph_iterations: 0, + reserved_context_size: 5000, + compaction_trigger_ratio: 0.85, + }, + background: { + max_running_tasks: 4, + read_max_bytes: 30000, + notification_tail_lines: 20, + notification_tail_chars: 3000, + wait_poll_interval_ms: 500, + worker_heartbeat_interval_ms: 5000, + worker_stale_after_ms: 15000, + kill_grace_period_ms: 2000, + keep_alive_on_exit: false, + agent_task_timeout_s: 900, + }, + notifications: { claim_stale_after_ms: 15000 }, + services: {}, + mcp: { client: { tool_call_timeout_ms: 60000 } }, + hooks: [], + ...overrides, + } as Config; +} + +// ── Default Builtin Args ──────────────────────────── + +export function createTestBuiltinArgs( + workDir: string, +): BuiltinSystemPromptArgs { + return { + KIMI_NOW: "1970-01-01T00:00:00+00:00", + KIMI_WORK_DIR: workDir, + KIMI_WORK_DIR_LS: "test ls content", + KIMI_AGENTS_MD: "", + KIMI_SKILLS: "No skills found.", + KIMI_ADDITIONAL_DIRS_INFO: "", + KIMI_OS: "macOS", + KIMI_SHELL: "bash", + }; +} + +// ── Session helper ────────────────────────────────── + +export function createTestSession(workDir: string, shareDir: string): Session { + const sessionsDir = join(shareDir, "sessions"); + Bun.spawnSync(["mkdir", "-p", join(sessionsDir, "test-session")]); + + const contextFile = join(sessionsDir, "test-session", "context.jsonl"); + const wireFile = join(sessionsDir, "test-session", "wire.jsonl"); + + // Create empty files + Bun.spawnSync(["touch", contextFile, wireFile]); + + return new Session({ + id: "test-session", + workDir: resolve(workDir), + sessionsDir, + contextFile, + wireFile, + state: SessionState.parse({}), + title: "Test Session", + updatedAt: 0, + }); +} + +// ── Approval helper ───────────────────────────────── + +export function createTestApproval(yolo = true): Approval { + return new Approval({ yolo }); +} + +// ── Runtime helper ────────────────────────────────── + +export function createTestRuntime( + ctx: TestContext, + opts?: { llm?: LLM; yolo?: boolean; config?: Config }, +): Runtime { + const config = opts?.config ?? createTestConfig(); + const { llm } = opts?.llm + ? { llm: opts.llm } + : createMockLLM(); + const session = createTestSession(ctx.workDir, ctx.shareDir); + const approval = createTestApproval(opts?.yolo ?? true); + const hookEngine = new HookEngine({ cwd: ctx.workDir }); + const builtinArgs = createTestBuiltinArgs(ctx.workDir); + + return new Runtime({ + config, + llm, + session, + approval, + hookEngine, + builtinArgs, + }); +} + +// ── Context helper ────────────────────────────────── + +export function createTestContext(shareDir: string): Context { + const contextFile = join(shareDir, "test-context.jsonl"); + return new Context(contextFile); +} + +// ── Tool Context helper ───────────────────────────── + +export function createTestToolContext( + workDir: string, + opts?: { yolo?: boolean }, +): ToolContext { + return { + workingDir: workDir, + signal: new AbortController().signal, + approval: async () => (opts?.yolo !== false ? "approve" : "reject"), + wireEmit: () => {}, + }; +} + +// ── Stream chunk builders ─────────────────────────── + +export function textChunks(text: string): StreamChunk[] { + return [ + { type: "text", text }, + { + type: "usage", + usage: { inputTokens: 100, outputTokens: text.length }, + }, + { type: "done" }, + ]; +} + +export function toolCallChunks( + name: string, + args: Record, + id = "tc-1", +): StreamChunk[] { + return [ + { + type: "tool_call", + id, + name, + arguments: JSON.stringify(args), + }, + { + type: "usage", + usage: { inputTokens: 100, outputTokens: 50 }, + }, + { type: "done" }, + ]; +} + +export function textAndToolChunks( + text: string, + toolName: string, + toolArgs: Record, + toolId = "tc-1", +): StreamChunk[] { + return [ + { type: "text", text }, + { + type: "tool_call", + id: toolId, + name: toolName, + arguments: JSON.stringify(toolArgs), + }, + { + type: "usage", + usage: { inputTokens: 100, outputTokens: text.length + 50 }, + }, + { type: "done" }, + ]; +} diff --git a/tests/core/agentspec.test.ts b/tests/core/agentspec.test.ts new file mode 100644 index 000000000..79665a69f --- /dev/null +++ b/tests/core/agentspec.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for agentspec.ts — agent specification loading. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { + loadAgentSpec, + AgentSpecError, +} from "../../src/kimi_cli_ts/agentspec.ts"; +import { createTempDir, removeTempDir } from "../conftest.ts"; + +describe("AgentSpec", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + test("loadAgentSpec throws for missing file", async () => { + await expect( + loadAgentSpec(join(tempDir, "nonexistent.yaml")), + ).rejects.toThrow(AgentSpecError); + }); + + test("loadAgentSpec throws for unsupported version", async () => { + const agentFile = join(tempDir, "agent.yaml"); + await Bun.write( + agentFile, + `version: "99"\nagent:\n name: test\n system_prompt_path: prompt.md\n tools:\n - shell\n`, + ); + await expect(loadAgentSpec(agentFile)).rejects.toThrow("Unsupported agent spec version"); + }); + + test("loadAgentSpec loads simple agent", async () => { + const agentFile = join(tempDir, "agent.yaml"); + const promptFile = join(tempDir, "prompt.md"); + await Bun.write(promptFile, "You are a helpful assistant."); + await Bun.write( + agentFile, + [ + "version: 1", + "agent:", + " name: test-agent", + ` system_prompt_path: prompt.md`, + " tools:", + " - shell", + " - read", + ].join("\n"), + ); + + const spec = await loadAgentSpec(agentFile); + expect(spec.name).toBe("test-agent"); + expect(spec.systemPromptPath).toContain("prompt.md"); + expect(spec.tools).toEqual(["shell", "read"]); + }); + + test("loadAgentSpec resolves relative system_prompt_path", async () => { + const subDir = join(tempDir, "sub"); + await Bun.$`mkdir -p ${subDir}`.quiet(); + const agentFile = join(subDir, "agent.yaml"); + const promptFile = join(subDir, "prompt.md"); + await Bun.write(promptFile, "prompt"); + await Bun.write( + agentFile, + [ + "version: 1", + "agent:", + " name: sub-agent", + " system_prompt_path: prompt.md", + " tools:", + " - shell", + ].join("\n"), + ); + + const spec = await loadAgentSpec(agentFile); + expect(spec.systemPromptPath).toBe(join(subDir, "prompt.md")); + }); + + test("loadAgentSpec requires name", async () => { + const agentFile = join(tempDir, "agent.yaml"); + await Bun.write( + agentFile, + [ + "version: 1", + "agent:", + " system_prompt_path: prompt.md", + " tools:", + " - shell", + ].join("\n"), + ); + await expect(loadAgentSpec(agentFile)).rejects.toThrow("Agent name is required"); + }); + + test("loadAgentSpec handles model and whenToUse", async () => { + const agentFile = join(tempDir, "agent.yaml"); + await Bun.write( + agentFile, + [ + "version: 1", + "agent:", + " name: custom", + " system_prompt_path: prompt.md", + " model: gpt-4", + " when_to_use: For complex tasks", + " tools:", + " - shell", + ].join("\n"), + ); + + const spec = await loadAgentSpec(agentFile); + expect(spec.model).toBe("gpt-4"); + expect(spec.whenToUse).toBe("For complex tasks"); + }); +}); diff --git a/tests/core/approval.test.ts b/tests/core/approval.test.ts new file mode 100644 index 000000000..d26317980 --- /dev/null +++ b/tests/core/approval.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for soul/approval.ts — approval system. + */ +import { test, expect, describe, beforeEach } from "bun:test"; +import { + Approval, + ApprovalResult, + ApprovalState, +} from "../../src/kimi_cli_ts/soul/approval.ts"; +import { ApprovalRuntime } from "../../src/kimi_cli_ts/approval_runtime/index.ts"; + +describe("ApprovalResult", () => { + test("approved result", () => { + const r = new ApprovalResult(true); + expect(r.approved).toBe(true); + expect(r.feedback).toBe(""); + expect(r.valueOf()).toBe(true); + }); + + test("rejected result with feedback", () => { + const r = new ApprovalResult(false, "not allowed"); + expect(r.approved).toBe(false); + expect(r.feedback).toBe("not allowed"); + expect(r.valueOf()).toBe(false); + }); +}); + +describe("ApprovalState", () => { + test("default state", () => { + const state = new ApprovalState(); + expect(state.yolo).toBe(false); + expect(state.autoApproveActions.size).toBe(0); + }); + + test("yolo mode", () => { + const state = new ApprovalState({ yolo: true }); + expect(state.yolo).toBe(true); + }); + + test("onChange callback fires", () => { + let changed = false; + const state = new ApprovalState({ onChange: () => { changed = true; } }); + state.notifyChange(); + expect(changed).toBe(true); + }); +}); + +describe("Approval", () => { + test("yolo mode auto-approves", async () => { + const approval = new Approval({ yolo: true }); + const result = await approval.request("test", "shell", "run ls"); + expect(result.approved).toBe(true); + }); + + test("isYolo and setYolo work", () => { + const approval = new Approval({ yolo: false }); + expect(approval.isYolo()).toBe(false); + approval.setYolo(true); + expect(approval.isYolo()).toBe(true); + }); + + test("auto-approve actions bypass approval", async () => { + const state = new ApprovalState({ + autoApproveActions: new Set(["shell"]), + }); + const approval = new Approval({ state }); + const result = await approval.request("test", "shell", "run ls"); + expect(result.approved).toBe(true); + }); + + test("request goes through runtime when not yolo and not auto-approved", async () => { + const runtime = new ApprovalRuntime(); + const approval = new Approval({ yolo: false, runtime }); + + // Start request in background + const requestPromise = approval.request("test", "shell", "run ls"); + + // Find and resolve the pending request + const pending = runtime.listPending(); + expect(pending.length).toBe(1); + expect(pending[0]!.action).toBe("shell"); + + runtime.resolve(pending[0]!.id, "approve"); + const result = await requestPromise; + expect(result.approved).toBe(true); + }); + + test("reject returns false with feedback", async () => { + const runtime = new ApprovalRuntime(); + const approval = new Approval({ yolo: false, runtime }); + + const requestPromise = approval.request("test", "shell", "dangerous cmd"); + + const pending = runtime.listPending(); + runtime.resolve(pending[0]!.id, "reject", "too dangerous"); + + const result = await requestPromise; + expect(result.approved).toBe(false); + expect(result.feedback).toBe("too dangerous"); + }); + + test("approve_for_session adds to auto-approve actions", async () => { + const runtime = new ApprovalRuntime(); + const approval = new Approval({ yolo: false, runtime }); + + const requestPromise = approval.request("test", "shell", "run ls"); + + const pending = runtime.listPending(); + runtime.resolve(pending[0]!.id, "approve_for_session"); + + const result = await requestPromise; + expect(result.approved).toBe(true); + + // Next request for same action should auto-approve + const result2 = await approval.request("test", "shell", "run pwd"); + expect(result2.approved).toBe(true); + }); + + test("share creates approval with shared state", async () => { + const approval = new Approval({ yolo: true }); + const shared = approval.share(); + expect(shared.isYolo()).toBe(true); + + approval.setYolo(false); + expect(shared.isYolo()).toBe(false); + }); +}); diff --git a/tests/core/compaction.test.ts b/tests/core/compaction.test.ts new file mode 100644 index 000000000..a299e5884 --- /dev/null +++ b/tests/core/compaction.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for soul/compaction.ts — context compaction logic. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { shouldCompact, compactContext } from "../../src/kimi_cli_ts/soul/compaction.ts"; +import { Context } from "../../src/kimi_cli_ts/soul/context.ts"; +import { createTempDir, removeTempDir, createMockLLM, textChunks } from "../conftest.ts"; + +describe("shouldCompact", () => { + test("returns false when well below limits", () => { + expect(shouldCompact(1000, 100_000, 5000, 0.85)).toBe(false); + }); + + test("returns true when token count + reserved >= max context", () => { + // tokenCount + reservedContextSize >= maxContextSize + expect(shouldCompact(96_000, 100_000, 5000, 0.85)).toBe(true); + }); + + test("returns true when token count >= trigger ratio * max", () => { + // 85000 >= 100000 * 0.85 + expect(shouldCompact(85_000, 100_000, 5000, 0.85)).toBe(true); + }); + + test("returns false at 84% with 0.85 trigger ratio", () => { + expect(shouldCompact(84_000, 100_000, 5000, 0.85)).toBe(false); + }); + + test("returns true at boundary: tokenCount == maxContextSize * ratio", () => { + expect(shouldCompact(85_000, 100_000, 0, 0.85)).toBe(true); + }); +}); + +describe("compactContext", () => { + let tempDir: string; + let contextFile: string; + + beforeEach(() => { + tempDir = createTempDir(); + contextFile = join(tempDir, "context.jsonl"); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + test("empty context is a no-op", async () => { + const ctx = new Context(contextFile); + const { llm } = createMockLLM([textChunks("summary")]); + await compactContext(ctx, llm); + expect(ctx.history.length).toBe(0); + }); + + test("compactContext uses LLM to summarize and replaces history", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "Write a function" }); + await ctx.appendMessage({ role: "assistant", content: "Here is a function..." }); + await ctx.appendMessage({ role: "user", content: "Add error handling" }); + + const { llm, provider } = createMockLLM([textChunks("Summarized conversation")]); + + await compactContext(ctx, llm); + + // LLM should have been called once + expect(provider.calls.length).toBe(1); + // History should be replaced with summary + preserved messages + // prepareCompaction preserves the last 2 user/assistant messages, + // compacts the first, so: 1 summary + 2 preserved = 3 + expect(ctx.history.length).toBe(3); + expect((ctx.history[0]!.content as string)).toContain("Summarized conversation"); + }); + + test("compactContext calls onBegin and onEnd callbacks", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "test" }); + + const { llm } = createMockLLM([textChunks("summary")]); + let began = false; + let ended = false; + + await compactContext(ctx, llm, { + onBegin: () => { began = true; }, + onEnd: () => { ended = true; }, + }); + + expect(began).toBe(true); + expect(ended).toBe(true); + }); + + test("compactContext falls back when LLM fails", async () => { + const ctx = new Context(contextFile); + // Need more messages than maxPreservedMessages (2) to trigger compaction + await ctx.appendMessage({ role: "user", content: "msg1" }); + await ctx.appendMessage({ role: "assistant", content: "msg2" }); + await ctx.appendMessage({ role: "user", content: "msg3" }); + await ctx.appendMessage({ role: "assistant", content: "msg4" }); + await ctx.appendMessage({ role: "user", content: "msg5" }); + + // Mock LLM that throws + const { llm } = createMockLLM([]); // no responses → will fail + + await compactContext(ctx, llm); + // Fallback summary is generated + preserved messages remain + // At least some messages should exist (summary + preserved) + expect(ctx.history.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts new file mode 100644 index 000000000..b1da334f7 --- /dev/null +++ b/tests/core/config.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for config.ts — configuration loading and validation. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { + Config, + ConfigError, + getDefaultConfig, + loadConfig, + loadConfigFromString, + saveConfig, + LoopControl, + HookDef, + LLMModel, + LLMProvider as LLMProviderSchema, +} from "../../src/kimi_cli_ts/config.ts"; +import { createTempDir, removeTempDir } from "../conftest.ts"; + +describe("Config", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + // ── Default config ─────────────────────────────────── + + test("getDefaultConfig returns valid config with defaults", () => { + const cfg = getDefaultConfig(); + expect(cfg.default_model).toBe(""); + expect(cfg.default_thinking).toBe(false); + expect(cfg.default_yolo).toBe(false); + expect(cfg.theme).toBe("dark"); + // zod/v4 with default({} as any) does not fill nested object defaults + expect(cfg.loop_control).toEqual({}); + expect(cfg.background).toEqual({}); + expect(cfg.hooks).toEqual([]); + }); + + // ── Zod schema validation ──────────────────────────── + + test("Config.parse with empty object returns defaults", () => { + const cfg = Config.parse({}); + expect(cfg.default_model).toBe(""); + expect(cfg.loop_control).toEqual({}); + }); + + test("Config.parse rejects invalid compaction_trigger_ratio", () => { + expect(() => + Config.parse({ + loop_control: { compaction_trigger_ratio: 1.5 }, + }), + ).toThrow(); + }); + + test("Config.parse rejects default_model not in models", () => { + expect(() => + Config.parse({ + default_model: "nonexistent", + models: {}, + }), + ).toThrow(); + }); + + test("Config.parse rejects model with unknown provider", () => { + expect(() => + Config.parse({ + models: { + m1: { provider: "p1", model: "test", max_context_size: 1000 }, + }, + providers: {}, + }), + ).toThrow(); + }); + + test("Config.parse accepts valid model/provider pair", () => { + const cfg = Config.parse({ + default_model: "m1", + models: { + m1: { provider: "p1", model: "test-model", max_context_size: 100000 }, + }, + providers: { + p1: { type: "kimi", base_url: "https://api.example.com", api_key: "key" }, + }, + }); + expect(cfg.default_model).toBe("m1"); + expect(cfg.models.m1.model).toBe("test-model"); + }); + + // ── LoopControl defaults ───────────────────────────── + + test("LoopControl schema fills defaults", () => { + const lc = LoopControl.parse({}); + expect(lc.max_steps_per_turn).toBe(100); + expect(lc.max_retries_per_step).toBe(3); + expect(lc.reserved_context_size).toBe(50_000); + expect(lc.compaction_trigger_ratio).toBe(0.85); + }); + + // ── HookDef schema ────────────────────────────────── + + test("HookDef schema parses with defaults", () => { + const hook = HookDef.parse({ event: "PreToolUse", command: "echo hi" }); + expect(hook.event).toBe("PreToolUse"); + expect(hook.command).toBe("echo hi"); + expect(hook.matcher).toBe(""); + expect(hook.timeout).toBe(30); + }); + + test("HookDef rejects unknown event type", () => { + expect(() => + HookDef.parse({ event: "BadEvent", command: "echo" }), + ).toThrow(); + }); + + // ── loadConfig / saveConfig ────────────────────────── + + test("loadConfig creates default when file missing", async () => { + const configPath = join(tempDir, "config.toml"); + const { config, meta } = await loadConfig(configPath); + expect(config.default_model).toBe(""); + expect(meta.sourceFile).toBe(configPath); + // Should have written the file + const exists = await Bun.file(configPath).exists(); + expect(exists).toBe(true); + }); + + test("saveConfig and loadConfig roundtrip", async () => { + const configPath = join(tempDir, "roundtrip.toml"); + const original = getDefaultConfig(); + await saveConfig(original, configPath); + const { config } = await loadConfig(configPath); + expect(config.default_yolo).toBe(original.default_yolo); + expect(config.default_model).toBe(original.default_model); + }); + + // ── loadConfigFromString ───────────────────────────── + + test("loadConfigFromString parses JSON", async () => { + const { config } = await loadConfigFromString( + JSON.stringify({ default_yolo: true }), + ); + expect(config.default_yolo).toBe(true); + }); + + test("loadConfigFromString rejects empty text", async () => { + await expect(loadConfigFromString("")).rejects.toThrow(ConfigError); + }); + + test("loadConfigFromString rejects invalid TOML/JSON", async () => { + await expect(loadConfigFromString("{{{bad")).rejects.toThrow(ConfigError); + }); + + // ── Environment variable override ──────────────────── + + test("KIMI_MODEL_NAME env var overrides default_model in loadConfig", async () => { + const configPath = join(tempDir, "env.toml"); + await saveConfig(getDefaultConfig(), configPath); + const origEnv = process.env.KIMI_MODEL_NAME; + try { + process.env.KIMI_MODEL_NAME = "env-model"; + const { config } = await loadConfig(configPath); + expect(config.default_model).toBe("env-model"); + } finally { + if (origEnv === undefined) { + delete process.env.KIMI_MODEL_NAME; + } else { + process.env.KIMI_MODEL_NAME = origEnv; + } + } + }); +}); diff --git a/tests/core/context.test.ts b/tests/core/context.test.ts new file mode 100644 index 000000000..a20aa6bbd --- /dev/null +++ b/tests/core/context.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for soul/context.ts — context window management. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { Context } from "../../src/kimi_cli_ts/soul/context.ts"; +import { createTempDir, removeTempDir } from "../conftest.ts"; + +describe("Context", () => { + let tempDir: string; + let contextFile: string; + + beforeEach(() => { + tempDir = createTempDir(); + contextFile = join(tempDir, "context.jsonl"); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + // ── Empty context ──────────────────────────────────── + + test("new context is empty", () => { + const ctx = new Context(contextFile); + expect(ctx.history).toEqual([]); + expect(ctx.tokenCount).toBe(0); + expect(ctx.tokenCountWithPending).toBe(0); + expect(ctx.systemPrompt).toBeNull(); + expect(ctx.nCheckpoints).toBe(0); + }); + + // ── Append message ─────────────────────────────────── + + test("appendMessage adds to history and persists", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "hello" }); + expect(ctx.history.length).toBe(1); + expect(ctx.history[0]!.role).toBe("user"); + expect(ctx.history[0]!.content).toBe("hello"); + + // File should contain the message + const text = await Bun.file(contextFile).text(); + const record = JSON.parse(text.trim()); + expect(record.role).toBe("user"); + expect(record.content).toBe("hello"); + }); + + test("appendMessage increases pending token estimate", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "a".repeat(100) }); + expect(ctx.tokenCountWithPending).toBeGreaterThan(0); + }); + + // ── Restore from file ──────────────────────────────── + + test("restore recovers messages from file", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "msg1" }); + await ctx.appendMessage({ role: "assistant", content: "msg2" }); + + const ctx2 = new Context(contextFile); + await ctx2.restore(); + expect(ctx2.history.length).toBe(2); + expect(ctx2.history[0]!.content).toBe("msg1"); + expect(ctx2.history[1]!.content).toBe("msg2"); + }); + + test("restore recovers system prompt", async () => { + const ctx = new Context(contextFile); + await ctx.writeSystemPrompt("You are helpful."); + await ctx.appendMessage({ role: "user", content: "hi" }); + + const ctx2 = new Context(contextFile); + await ctx2.restore(); + expect(ctx2.systemPrompt).toBe("You are helpful."); + expect(ctx2.history.length).toBe(1); + }); + + test("restore from nonexistent file is no-op", async () => { + const ctx = new Context(join(tempDir, "nonexistent.jsonl")); + await ctx.restore(); + expect(ctx.history).toEqual([]); + }); + + // ── Token count update ─────────────────────────────── + + test("updateTokenCount sets token count and resets pending", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "hello" }); + expect(ctx.tokenCountWithPending).toBeGreaterThan(0); + + await ctx.updateTokenCount({ inputTokens: 100, outputTokens: 50 }); + // Only input tokens count toward context window (output doesn't consume context) + expect(ctx.tokenCount).toBe(100); + expect(ctx.tokenCountWithPending).toBe(100); + }); + + test("restore recovers token count from usage record", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "hello" }); + await ctx.updateTokenCount({ inputTokens: 200, outputTokens: 100 }); + + const ctx2 = new Context(contextFile); + await ctx2.restore(); + // Only input tokens are restored for context tracking + expect(ctx2.tokenCount).toBe(200); + }); + + // ── Checkpoint and revert ──────────────────────────── + + test("checkpoint increments checkpoint id", async () => { + const ctx = new Context(contextFile); + const id0 = await ctx.checkpoint(); + expect(id0).toBe(0); + expect(ctx.nCheckpoints).toBe(1); + + const id1 = await ctx.checkpoint(); + expect(id1).toBe(1); + expect(ctx.nCheckpoints).toBe(2); + }); + + test("checkpoint with reminder injects system-reminder message", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "msg1" }); + await ctx.checkpoint("remember this"); + expect(ctx.history.length).toBe(2); + expect((ctx.history[1]!.content as string)).toContain("remember this"); + }); + + test("revertTo restores context up to checkpoint", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "before" }); + const cpId = await ctx.checkpoint(); + await ctx.appendMessage({ role: "user", content: "after" }); + + await ctx.revertTo(cpId); + // After revert, should only have messages up to checkpoint + expect(ctx.history.some((m) => (m.content as string) === "after")).toBe(false); + expect(ctx.history.some((m) => (m.content as string) === "before")).toBe(true); + }); + + // ── Compact ────────────────────────────────────────── + + test("compact clears history and creates backup", async () => { + const ctx = new Context(contextFile); + await ctx.appendMessage({ role: "user", content: "msg1" }); + await ctx.appendMessage({ role: "assistant", content: "msg2" }); + + await ctx.compact(); + expect(ctx.history.length).toBe(0); + expect(ctx.tokenCount).toBe(0); + + // Backup file should exist + const bakExists = await Bun.file(contextFile + ".bak").exists(); + expect(bakExists).toBe(true); + }); + + test("compact preserves system prompt", async () => { + const ctx = new Context(contextFile); + await ctx.writeSystemPrompt("System prompt here."); + await ctx.appendMessage({ role: "user", content: "hello" }); + + await ctx.compact(); + expect(ctx.systemPrompt).toBe("System prompt here."); + expect(ctx.history.length).toBe(0); + + // Restoring should recover system prompt + const ctx2 = new Context(contextFile); + await ctx2.restore(); + expect(ctx2.systemPrompt).toBe("System prompt here."); + }); +}); diff --git a/tests/core/llm.test.ts b/tests/core/llm.test.ts new file mode 100644 index 000000000..dc769d60e --- /dev/null +++ b/tests/core/llm.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for llm.ts — LLM abstraction layer. + */ +import { test, expect, describe } from "bun:test"; +import { + LLM, + createLLM, + estimateTokenCount, + estimateMessagesTokenCount, + deriveModelCapabilities, + modelDisplayName, + augmentProviderWithEnvVars, + type LLMProviderConfig, + type LLMModelConfig, +} from "../../src/kimi_cli_ts/llm.ts"; +import { createMockLLM } from "../conftest.ts"; + +describe("estimateTokenCount", () => { + test("estimates ~4 chars per token", () => { + expect(estimateTokenCount("abcd")).toBe(1); + expect(estimateTokenCount("abcdefgh")).toBe(2); + expect(estimateTokenCount("a")).toBe(1); // ceil(1/4) + }); + + test("empty string is 0 tokens", () => { + expect(estimateTokenCount("")).toBe(0); + }); +}); + +describe("estimateMessagesTokenCount", () => { + test("estimates tokens for string messages", () => { + const count = estimateMessagesTokenCount([ + { role: "user", content: "hello world" }, // ~3 + 4 overhead + ]); + expect(count).toBeGreaterThan(0); + }); + + test("estimates tokens for content part messages", () => { + const count = estimateMessagesTokenCount([ + { + role: "user", + content: [{ type: "text" as const, text: "hello world" }], + }, + ]); + expect(count).toBeGreaterThan(0); + }); + + test("empty messages array returns 0", () => { + expect(estimateMessagesTokenCount([])).toBe(0); + }); +}); + +describe("deriveModelCapabilities", () => { + test("model with thinking in name gets thinking capabilities", () => { + const caps = deriveModelCapabilities({ + model: "gpt-4-thinking", + provider: "openai", + maxContextSize: 128000, + }); + expect(caps.has("thinking")).toBe(true); + expect(caps.has("always_thinking")).toBe(true); + }); + + test("model with reason in name gets thinking capabilities", () => { + const caps = deriveModelCapabilities({ + model: "deepseek-reasoner", + provider: "openai", + maxContextSize: 128000, + }); + expect(caps.has("thinking")).toBe(true); + }); + + test("kimi-for-coding gets image, video, thinking", () => { + const caps = deriveModelCapabilities({ + model: "kimi-for-coding", + provider: "kimi", + maxContextSize: 200000, + }); + expect(caps.has("thinking")).toBe(true); + expect(caps.has("image_in")).toBe(true); + expect(caps.has("video_in")).toBe(true); + }); + + test("explicit capabilities are preserved", () => { + const caps = deriveModelCapabilities({ + model: "generic-model", + provider: "openai", + maxContextSize: 128000, + capabilities: ["image_in"], + }); + expect(caps.has("image_in")).toBe(true); + expect(caps.has("thinking")).toBe(false); + }); +}); + +describe("modelDisplayName", () => { + test("null returns empty string", () => { + expect(modelDisplayName(null)).toBe(""); + }); + + test("kimi-for-coding gets powered by suffix", () => { + expect(modelDisplayName("kimi-for-coding")).toContain("kimi-k2.5"); + }); + + test("regular model name returned as-is", () => { + expect(modelDisplayName("gpt-4")).toBe("gpt-4"); + }); +}); + +describe("createLLM", () => { + test("returns null for empty base_url", () => { + const result = createLLM( + { type: "kimi", baseUrl: "", apiKey: "key" }, + { model: "test", provider: "p", maxContextSize: 100000 }, + ); + expect(result).toBeNull(); + }); + + test("returns null for empty model name", () => { + const result = createLLM( + { type: "kimi", baseUrl: "https://api.example.com", apiKey: "key" }, + { model: "", provider: "p", maxContextSize: 100000 }, + ); + expect(result).toBeNull(); + }); + + test("creates LLM with valid config", () => { + const llm = createLLM( + { type: "kimi", baseUrl: "https://api.example.com", apiKey: "key" }, + { model: "kimi-for-coding", provider: "p", maxContextSize: 200000 }, + ); + expect(llm).not.toBeNull(); + expect(llm!.modelName).toBe("kimi-for-coding"); + expect(llm!.maxContextSize).toBe(200000); + expect(llm!.hasCapability("thinking")).toBe(true); + }); + + test("_echo provider type allows empty base_url", () => { + const llm = createLLM( + { type: "_echo", baseUrl: "", apiKey: "" }, + { model: "echo", provider: "p", maxContextSize: 100000 }, + ); + expect(llm).not.toBeNull(); + }); +}); + +describe("LLM class", () => { + test("hasCapability checks capability set", () => { + const { llm } = createMockLLM([], { capabilities: ["image_in", "thinking"] }); + expect(llm.hasCapability("image_in")).toBe(true); + expect(llm.hasCapability("thinking")).toBe(true); + expect(llm.hasCapability("video_in")).toBe(false); + }); + + test("modelName delegates to provider", () => { + const { llm } = createMockLLM(); + expect(llm.modelName).toBe("mock-model"); + }); +}); + +describe("augmentProviderWithEnvVars", () => { + test("KIMI env vars override provider settings", () => { + const origKey = process.env.KIMI_API_KEY; + const origUrl = process.env.KIMI_BASE_URL; + try { + process.env.KIMI_API_KEY = "test-key"; + process.env.KIMI_BASE_URL = "https://override.example.com"; + + const provider: LLMProviderConfig = { + type: "kimi", + baseUrl: "https://original.example.com", + apiKey: "original-key", + }; + const model: LLMModelConfig = { + model: "test", + provider: "p", + maxContextSize: 100000, + }; + + const applied = augmentProviderWithEnvVars(provider, model); + expect(provider.baseUrl).toBe("https://override.example.com"); + expect(provider.apiKey).toBe("test-key"); + expect(applied).toHaveProperty("KIMI_API_KEY"); + expect(applied).toHaveProperty("KIMI_BASE_URL"); + } finally { + if (origKey === undefined) delete process.env.KIMI_API_KEY; + else process.env.KIMI_API_KEY = origKey; + if (origUrl === undefined) delete process.env.KIMI_BASE_URL; + else process.env.KIMI_BASE_URL = origUrl; + } + }); +}); diff --git a/tests/core/session.test.ts b/tests/core/session.test.ts new file mode 100644 index 000000000..aca19d366 --- /dev/null +++ b/tests/core/session.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for session.ts — session management. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { + Session, + SessionState, + loadSessionState, + saveSessionState, +} from "../../src/kimi_cli_ts/session.ts"; +import { createTempDir, removeTempDir, createTestSession } from "../conftest.ts"; + +describe("SessionState", () => { + test("default session state", () => { + const state = SessionState.parse({}); + expect(state.version).toBe(1); + // zod/v4 with default({} as any) does not fill nested defaults + expect(state.approval).toEqual({}); + expect(state.additional_dirs).toEqual([]); + expect(state.custom_title).toBeNull(); + expect(state.plan_mode).toBe(false); + expect(state.archived).toBe(false); + }); + + test("parse with values", () => { + const state = SessionState.parse({ + approval: { yolo: true, auto_approve_actions: ["shell"] }, + plan_mode: true, + custom_title: "My Session", + }); + expect(state.approval.yolo).toBe(true); + expect(state.approval.auto_approve_actions).toEqual(["shell"]); + expect(state.plan_mode).toBe(true); + expect(state.custom_title).toBe("My Session"); + }); +}); + +describe("Session State Persistence", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + test("saveSessionState and loadSessionState roundtrip", async () => { + const state = SessionState.parse({ + approval: { yolo: true }, + custom_title: "Test", + }); + await saveSessionState(state, tempDir); + const loaded = await loadSessionState(tempDir); + expect(loaded.approval.yolo).toBe(true); + expect(loaded.custom_title).toBe("Test"); + }); + + test("loadSessionState returns defaults for missing file", async () => { + const state = await loadSessionState(join(tempDir, "nonexistent")); + expect(state.version).toBe(1); + expect(state.approval).toEqual({}); + }); +}); + +describe("Session", () => { + let tempDir: string; + let workDir: string; + let shareDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + workDir = join(tempDir, "work"); + shareDir = join(tempDir, "share"); + Bun.spawnSync(["mkdir", "-p", workDir, shareDir]); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + test("createTestSession produces valid session", () => { + const session = createTestSession(workDir, shareDir); + expect(session.id).toBe("test-session"); + expect(session.title).toBe("Test Session"); + }); + + test("Session.dir returns correct path", () => { + const session = createTestSession(workDir, shareDir); + expect(session.dir).toContain("test-session"); + }); + + test("isEmpty returns true for empty context", async () => { + const session = createTestSession(workDir, shareDir); + const empty = await session.isEmpty(); + expect(empty).toBe(true); + }); + + test("isEmpty returns false when context has messages", async () => { + const session = createTestSession(workDir, shareDir); + // Write a user message to context file + await Bun.write( + session.contextFile, + JSON.stringify({ role: "user", content: "hello" }) + "\n", + ); + const empty = await session.isEmpty(); + expect(empty).toBe(false); + }); + + test("isEmpty returns false when custom_title is set", async () => { + const session = createTestSession(workDir, shareDir); + session.state.custom_title = "Custom Title"; + const empty = await session.isEmpty(); + expect(empty).toBe(false); + }); + + test("saveState persists state to file", async () => { + const session = createTestSession(workDir, shareDir); + session.state.approval.yolo = true; + await session.saveState(); + + const loaded = await loadSessionState(session.dir); + expect(loaded.approval.yolo).toBe(true); + }); +}); diff --git a/tests/core/slash_commands.test.ts b/tests/core/slash_commands.test.ts new file mode 100644 index 000000000..c37602cee --- /dev/null +++ b/tests/core/slash_commands.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for soul/slash.ts — slash command registry. + */ +import { test, expect, describe, beforeEach } from "bun:test"; +import { + SlashCommandRegistry, + createDefaultRegistry, +} from "../../src/kimi_cli_ts/soul/slash.ts"; + +describe("SlashCommandRegistry", () => { + let registry: SlashCommandRegistry; + let executedArgs: string[]; + + beforeEach(() => { + registry = new SlashCommandRegistry(); + executedArgs = []; + }); + + test("register and get command", () => { + registry.register({ + name: "test", + description: "A test command", + handler: async () => {}, + }); + expect(registry.get("test")).toBeDefined(); + expect(registry.get("test")!.name).toBe("test"); + }); + + test("has returns true for registered command", () => { + registry.register({ + name: "test", + description: "Test", + handler: async () => {}, + }); + expect(registry.has("test")).toBe(true); + expect(registry.has("nonexistent")).toBe(false); + }); + + test("aliases resolve to original command", () => { + registry.register({ + name: "test", + description: "Test", + aliases: ["t", "tst"], + handler: async () => {}, + }); + expect(registry.has("t")).toBe(true); + expect(registry.has("tst")).toBe(true); + expect(registry.get("t")!.name).toBe("test"); + expect(registry.get("tst")!.name).toBe("test"); + }); + + test("list returns all registered commands", () => { + registry.register({ name: "a", description: "A", handler: async () => {} }); + registry.register({ name: "b", description: "B", handler: async () => {} }); + const cmds = registry.list(); + expect(cmds.length).toBe(2); + expect(cmds.map((c) => c.name).sort()).toEqual(["a", "b"]); + }); + + test("execute dispatches to handler", async () => { + registry.register({ + name: "greet", + description: "Greet", + handler: async (args) => { + executedArgs.push(args); + }, + }); + + const result = await registry.execute("/greet world"); + expect(result).toBe(true); + expect(executedArgs).toEqual(["world"]); + }); + + test("execute with no args passes empty string", async () => { + registry.register({ + name: "ping", + description: "Ping", + handler: async (args) => { + executedArgs.push(args); + }, + }); + + await registry.execute("/ping"); + expect(executedArgs).toEqual([""]); + }); + + test("execute returns false for unknown command", async () => { + const result = await registry.execute("/unknown"); + expect(result).toBe(false); + }); + + test("execute returns false for non-slash input", async () => { + const result = await registry.execute("hello"); + expect(result).toBe(false); + }); + + test("execute resolves alias", async () => { + registry.register({ + name: "help", + description: "Help", + aliases: ["?"], + handler: async (args) => { + executedArgs.push(args); + }, + }); + + const result = await registry.execute("/?"); + expect(result).toBe(true); + expect(executedArgs.length).toBe(1); + }); +}); + +describe("createDefaultRegistry", () => { + test("contains built-in commands", () => { + const reg = createDefaultRegistry(); + expect(reg.has("clear")).toBe(true); + expect(reg.has("compact")).toBe(true); + expect(reg.has("yolo")).toBe(true); + expect(reg.has("plan")).toBe(true); + expect(reg.has("model")).toBe(true); + expect(reg.has("help")).toBe(true); + expect(reg.has("init")).toBe(true); + expect(reg.has("add-dir")).toBe(true); + }); + + test("yolo has auto-approve alias", () => { + const reg = createDefaultRegistry(); + expect(reg.has("auto-approve")).toBe(true); + expect(reg.get("auto-approve")!.name).toBe("yolo"); + }); + + test("help has ? alias", () => { + const reg = createDefaultRegistry(); + expect(reg.has("?")).toBe(true); + expect(reg.get("?")!.name).toBe("help"); + }); +}); diff --git a/tests/core/toolset.test.ts b/tests/core/toolset.test.ts new file mode 100644 index 000000000..297356a66 --- /dev/null +++ b/tests/core/toolset.test.ts @@ -0,0 +1,172 @@ +/** + * Tests for soul/toolset.ts — tool registry with hooks. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { z } from "zod/v4"; +import { KimiToolset } from "../../src/kimi_cli_ts/soul/toolset.ts"; +import { CallableTool } from "../../src/kimi_cli_ts/tools/base.ts"; +import { HookEngine } from "../../src/kimi_cli_ts/hooks/engine.ts"; +import type { ToolContext, ToolResult } from "../../src/kimi_cli_ts/tools/types.ts"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; + +// ── Stub tool ───────────────────────────────────── + +class EchoTool extends CallableTool { + static readonly Schema = z.object({ text: z.string() }); + readonly name = "echo"; + readonly description = "Echo text"; + readonly schema = EchoTool.Schema; + + async execute(params: { text: string }): Promise { + return { isError: false, output: params.text }; + } +} + +class FailTool extends CallableTool { + static readonly Schema = z.object({}); + readonly name = "fail"; + readonly description = "Always fails"; + readonly schema = FailTool.Schema; + + async execute(): Promise { + throw new Error("intentional failure"); + } +} + +describe("KimiToolset", () => { + let tempDir: string; + let ctx: ToolContext; + + beforeEach(() => { + tempDir = createTempDir(); + ctx = createTestToolContext(tempDir); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + // ── Registration and lookup ────────────────────────── + + test("add and find tool", () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new EchoTool()); + expect(toolset.find("echo")).toBeDefined(); + expect(toolset.find("nonexistent")).toBeUndefined(); + }); + + test("list returns all tools", () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new EchoTool()); + toolset.add(new FailTool()); + expect(toolset.list().length).toBe(2); + }); + + // ── Definitions (hide/unhide) ──────────────────────── + + test("definitions excludes hidden tools", () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new EchoTool()); + toolset.add(new FailTool()); + + toolset.hide("fail"); + const defs = toolset.definitions(); + expect(defs.length).toBe(1); + expect(defs[0]!.name).toBe("echo"); + + toolset.unhide("fail"); + expect(toolset.definitions().length).toBe(2); + }); + + // ── Handle tool call ───────────────────────────────── + + test("handle executes tool and returns result", async () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new EchoTool()); + + const result = await toolset.handle({ + id: "tc-1", + name: "echo", + arguments: JSON.stringify({ text: "hello world" }), + }); + expect(result.isError).toBe(false); + expect(result.output).toBe("hello world"); + }); + + test("handle returns error for invalid JSON arguments", async () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new EchoTool()); + + const result = await toolset.handle({ + id: "tc-1", + name: "echo", + arguments: "{bad json", + }); + expect(result.isError).toBe(true); + expect(result.message).toContain("Failed to parse arguments"); + }); + + test("handle catches tool execution errors", async () => { + const toolset = new KimiToolset({ context: ctx }); + toolset.add(new FailTool()); + + const result = await toolset.handle({ + id: "tc-1", + name: "fail", + arguments: "{}", + }); + expect(result.isError).toBe(true); + expect(result.message).toContain("intentional failure"); + }); + + // ── Callbacks ──────────────────────────────────────── + + test("onToolCall and onToolResult callbacks fire", async () => { + const calls: string[] = []; + const results: string[] = []; + + const toolset = new KimiToolset({ + context: ctx, + onToolCall: (tc) => calls.push(tc.name), + onToolResult: (id, r) => results.push(id), + }); + toolset.add(new EchoTool()); + + await toolset.handle({ + id: "tc-1", + name: "echo", + arguments: JSON.stringify({ text: "hi" }), + }); + + expect(calls).toEqual(["echo"]); + expect(results).toEqual(["tc-1"]); + }); + + // ── Hook integration (PreToolUse block) ────────────── + + test("PreToolUse hook can block tool execution", async () => { + // Create a hook that blocks "echo" + const hookEngine = new HookEngine({ + hooks: [ + { + event: "PreToolUse", + command: 'echo \'{"action":"block","reason":"blocked by test"}\'', + matcher: "echo", + timeout: 5, + }, + ], + cwd: tempDir, + }); + + const toolset = new KimiToolset({ context: ctx, hookEngine }); + toolset.add(new EchoTool()); + + const result = await toolset.handle({ + id: "tc-1", + name: "echo", + arguments: JSON.stringify({ text: "hello" }), + }); + expect(result.isError).toBe(true); + expect(result.message).toContain("blocked by hook"); + }); +}); diff --git a/tests/core/wire_message.test.ts b/tests/core/wire_message.test.ts new file mode 100644 index 000000000..f4fe06eab --- /dev/null +++ b/tests/core/wire_message.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for wire/types.ts + wire/serde.ts — wire message system. + */ +import { test, expect, describe } from "bun:test"; +import { + TurnBegin, + StepBegin, + StatusUpdate, + HookTriggered, + HookResolved, + ApprovalRequest, + ApprovalResponse, + BriefDisplayBlock, + DiffDisplayBlock, + toEnvelope, + fromEnvelope, + isEventTypeName, + isRequestTypeName, + getWireMessageSchema, +} from "../../src/kimi_cli_ts/wire/types.ts"; +import { + serializeWireMessage, + deserializeWireMessage, + serializeWireMessageToJSON, + deserializeWireMessageFromJSON, +} from "../../src/kimi_cli_ts/wire/serde.ts"; + +describe("Wire Event Zod Parsing", () => { + test("TurnBegin parses string input", () => { + const msg = TurnBegin.parse({ user_input: "hello" }); + expect(msg.user_input).toBe("hello"); + }); + + test("TurnBegin parses content part array", () => { + const msg = TurnBegin.parse({ + user_input: [{ type: "text", text: "hello" }], + }); + expect(Array.isArray(msg.user_input)).toBe(true); + }); + + test("StepBegin parses step number", () => { + const msg = StepBegin.parse({ n: 3 }); + expect(msg.n).toBe(3); + }); + + test("StatusUpdate parses with defaults", () => { + const msg = StatusUpdate.parse({}); + expect(msg.context_usage).toBeNull(); + expect(msg.context_tokens).toBeNull(); + expect(msg.plan_mode).toBeNull(); + }); + + test("StatusUpdate parses with values", () => { + const msg = StatusUpdate.parse({ + context_usage: 0.5, + context_tokens: 5000, + max_context_tokens: 10000, + plan_mode: true, + }); + expect(msg.context_usage).toBe(0.5); + expect(msg.plan_mode).toBe(true); + }); + + test("HookTriggered parses with defaults", () => { + const msg = HookTriggered.parse({ event: "PreToolUse" }); + expect(msg.event).toBe("PreToolUse"); + expect(msg.target).toBe(""); + expect(msg.hook_count).toBe(1); + }); + + test("HookResolved parses with values", () => { + const msg = HookResolved.parse({ + event: "PreToolUse", + target: "shell", + action: "block", + reason: "not allowed", + duration_ms: 123, + }); + expect(msg.action).toBe("block"); + expect(msg.reason).toBe("not allowed"); + }); + + test("ApprovalRequest parses", () => { + const msg = ApprovalRequest.parse({ + id: "req-1", + tool_call_id: "tc-1", + sender: "user", + action: "shell", + description: "Run ls", + }); + expect(msg.id).toBe("req-1"); + expect(msg.source_kind).toBeNull(); + expect(msg.display).toEqual([]); + }); + + test("ApprovalResponse parses", () => { + const msg = ApprovalResponse.parse({ + request_id: "req-1", + response: "approve", + }); + expect(msg.response).toBe("approve"); + expect(msg.feedback).toBe(""); + }); +}); + +describe("Display Blocks", () => { + test("BriefDisplayBlock", () => { + const block = BriefDisplayBlock.parse({ type: "brief", brief: "hello" }); + expect(block.brief).toBe("hello"); + }); + + test("DiffDisplayBlock with defaults", () => { + const block = DiffDisplayBlock.parse({ + type: "diff", + path: "test.ts", + old_text: "a", + new_text: "b", + }); + expect(block.old_start).toBe(1); + expect(block.new_start).toBe(1); + expect(block.is_summary).toBe(false); + }); +}); + +describe("Envelope serialization", () => { + test("toEnvelope creates correct structure", () => { + const env = toEnvelope("TurnBegin", { user_input: "test" }); + expect(env.type).toBe("TurnBegin"); + expect(env.payload.user_input).toBe("test"); + }); + + test("fromEnvelope validates and returns parsed message", () => { + const env = toEnvelope("StepBegin", { n: 5 }); + const { typeName, message } = fromEnvelope(env); + expect(typeName).toBe("StepBegin"); + expect((message as any).n).toBe(5); + }); + + test("fromEnvelope throws for unknown type", () => { + const env = { type: "NonexistentType", payload: {} }; + expect(() => fromEnvelope(env)).toThrow("Unknown wire message type"); + }); +}); + +describe("Wire serde", () => { + test("serializeWireMessage creates envelope object", () => { + const obj = serializeWireMessage("TurnBegin", { user_input: "hi" }); + expect(obj.type).toBe("TurnBegin"); + expect((obj.payload as any).user_input).toBe("hi"); + }); + + test("deserializeWireMessage round-trips", () => { + const serialized = serializeWireMessage("StepBegin", { n: 3 }); + const { typeName, message } = deserializeWireMessage(serialized); + expect(typeName).toBe("StepBegin"); + expect((message as any).n).toBe(3); + }); + + test("JSON string round-trip", () => { + const json = serializeWireMessageToJSON("HookTriggered", { + event: "PreToolUse", + target: "shell", + hook_count: 2, + }); + const { typeName, message } = deserializeWireMessageFromJSON(json); + expect(typeName).toBe("HookTriggered"); + expect((message as any).event).toBe("PreToolUse"); + expect((message as any).hook_count).toBe(2); + }); + + test("deserializeWireMessage throws for malformed data", () => { + expect(() => deserializeWireMessage("not json")).toThrow(); + }); +}); + +describe("Type name helpers", () => { + test("isEventTypeName identifies events", () => { + expect(isEventTypeName("TurnBegin")).toBe(true); + expect(isEventTypeName("StatusUpdate")).toBe(true); + expect(isEventTypeName("ToolResult")).toBe(true); + expect(isEventTypeName("ApprovalRequest")).toBe(false); + }); + + test("isRequestTypeName identifies requests", () => { + expect(isRequestTypeName("ApprovalRequest")).toBe(true); + expect(isRequestTypeName("QuestionRequest")).toBe(true); + expect(isRequestTypeName("HookRequest")).toBe(true); + expect(isRequestTypeName("TurnBegin")).toBe(false); + }); + + test("getWireMessageSchema returns schema for known type", () => { + expect(getWireMessageSchema("TurnBegin")).toBeDefined(); + expect(getWireMessageSchema("StatusUpdate")).toBeDefined(); + }); + + test("getWireMessageSchema returns undefined for unknown type", () => { + expect(getWireMessageSchema("NonexistentType")).toBeUndefined(); + }); +}); diff --git a/tests/e2e/basic_e2e.test.ts b/tests/e2e/basic_e2e.test.ts new file mode 100644 index 000000000..e7dc582e0 --- /dev/null +++ b/tests/e2e/basic_e2e.test.ts @@ -0,0 +1,86 @@ +/** + * Basic E2E test — create a mock conversation round. + * Corresponds to Python tests/e2e/test_basic_e2e.py + * + * Note: Full E2E with scripted echo provider is not yet ported. + * This tests the basic building blocks: mock LLM, context, session. + */ + +import { test, expect, describe, afterEach } from "bun:test"; +import { + TestContext, + createMockLLM, + createTestSession, + createTestContext, + textChunks, +} from "../conftest"; + +describe("basic E2E with mock LLM", () => { + let ctx: TestContext; + + afterEach(() => { + ctx?.cleanup(); + }); + + test("mock LLM returns scripted text response", async () => { + ctx = new TestContext(); + const { llm, provider } = createMockLLM([textChunks("Hello from mock!")]); + + const chunks: string[] = []; + for await (const chunk of llm.chat([{ role: "user", content: "hi" }])) { + if (chunk.type === "text") { + chunks.push(chunk.text); + } + } + + expect(chunks.join("")).toBe("Hello from mock!"); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0].messages[0].content).toBe("hi"); + }); + + test("mock LLM tracks multiple calls", async () => { + ctx = new TestContext(); + const { llm, provider } = createMockLLM([ + textChunks("Response 1"), + textChunks("Response 2"), + ]); + + // First call + const chunks1: string[] = []; + for await (const chunk of llm.chat([{ role: "user", content: "first" }])) { + if (chunk.type === "text") chunks1.push(chunk.text); + } + + // Second call + const chunks2: string[] = []; + for await (const chunk of llm.chat([{ role: "user", content: "second" }])) { + if (chunk.type === "text") chunks2.push(chunk.text); + } + + expect(chunks1.join("")).toBe("Response 1"); + expect(chunks2.join("")).toBe("Response 2"); + expect(provider.calls).toHaveLength(2); + }); + + test("session is created with correct properties", () => { + ctx = new TestContext(); + const session = createTestSession(ctx.workDir, ctx.shareDir); + expect(session.id).toBe("test-session"); + expect(session.title).toBe("Test Session"); + }); + + test("context can be created", () => { + ctx = new TestContext(); + const context = createTestContext(ctx.shareDir); + expect(context).toBeDefined(); + }); + + test("LLM reports capabilities", () => { + ctx = new TestContext(); + const { llm } = createMockLLM([], { + capabilities: ["image_in", "thinking"], + }); + expect(llm.hasCapability("image_in")).toBe(true); + expect(llm.hasCapability("thinking")).toBe(true); + }); +}); diff --git a/tests/e2e/cli_entry.test.ts b/tests/e2e/cli_entry.test.ts new file mode 100644 index 000000000..892975ecc --- /dev/null +++ b/tests/e2e/cli_entry.test.ts @@ -0,0 +1,52 @@ +/** + * E2E tests for CLI entry point — version, help, invalid options. + * Corresponds to Python tests/e2e/test_cli_error_output.py + */ + +import { test, expect, describe } from "bun:test"; +import { resolve } from "node:path"; + +const PROJECT_ROOT = resolve(import.meta.dir, "../.."); +const ENTRY = resolve(PROJECT_ROOT, "src/kimi_cli_ts/index.ts"); + +function runCli(args: string[]): { exitCode: number; stdout: string; stderr: string } { + const result = Bun.spawnSync(["bun", "run", ENTRY, ...args], { + cwd: PROJECT_ROOT, + env: { ...process.env, NODE_ENV: "test" }, + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +describe("CLI --version", () => { + test("outputs version number", () => { + const { stdout, exitCode } = runCli(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/\d+\.\d+\.\d+/); + }); +}); + +describe("CLI --help", () => { + test("outputs help information", () => { + const { stdout, exitCode } = runCli(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("kimi"); + expect(stdout).toContain("--help"); + }); + + test("help mentions model option", () => { + const { stdout } = runCli(["--help"]); + expect(stdout).toContain("--model"); + }); +}); + +describe("CLI invalid options", () => { + test("unknown option produces error", () => { + const { exitCode, stderr } = runCli(["--nonexistent-flag-xyz"]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("unknown option"); + }); +}); diff --git a/tests/hooks/engine.test.ts b/tests/hooks/engine.test.ts new file mode 100644 index 000000000..2f44553f5 --- /dev/null +++ b/tests/hooks/engine.test.ts @@ -0,0 +1,206 @@ +/** + * Tests for hooks/engine.ts — hook engine. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { HookEngine } from "../../src/kimi_cli_ts/hooks/engine.ts"; +import { createTempDir, removeTempDir } from "../conftest.ts"; + +describe("HookEngine", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + // ── Basic setup ────────────────────────────────────── + + test("empty engine has no hooks", () => { + const engine = new HookEngine({ cwd: tempDir }); + expect(engine.hasHooks).toBe(false); + expect(engine.hasHooksFor("PreToolUse")).toBe(false); + }); + + test("engine with hooks reports hasHooks", () => { + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: "echo ok", matcher: "", timeout: 5 }, + ], + cwd: tempDir, + }); + expect(engine.hasHooks).toBe(true); + expect(engine.hasHooksFor("PreToolUse")).toBe(true); + expect(engine.hasHooksFor("PostToolUse")).toBe(false); + }); + + test("summary returns hook counts by event", () => { + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: "echo a", matcher: "", timeout: 5 }, + { event: "PreToolUse", command: "echo b", matcher: "", timeout: 5 }, + { event: "PostToolUse", command: "echo c", matcher: "", timeout: 5 }, + ], + cwd: tempDir, + }); + const summary = engine.summary; + expect(summary["PreToolUse"]).toBe(2); + expect(summary["PostToolUse"]).toBe(1); + }); + + // ── Matching ───────────────────────────────────────── + + test("trigger returns empty for unmatched event", async () => { + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: "echo ok", matcher: "", timeout: 5 }, + ], + cwd: tempDir, + }); + const results = await engine.trigger("PostToolUse", { inputData: {} }); + expect(results).toEqual([]); + }); + + test("trigger matches by event with empty matcher", async () => { + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: 'echo \'{"action":"allow"}\'', matcher: "", timeout: 5 }, + ], + cwd: tempDir, + }); + const results = await engine.trigger("PreToolUse", { + matcherValue: "shell", + inputData: { tool_name: "shell" }, + }); + expect(results.length).toBe(1); + expect(results[0]!.action).toBe("allow"); + }); + + test("matcher regex filters by target", async () => { + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: 'echo \'{"action":"block","reason":"no shell"}\'', matcher: "^shell$", timeout: 5 }, + ], + cwd: tempDir, + }); + + // Matches "shell" + const results = await engine.trigger("PreToolUse", { + matcherValue: "shell", + inputData: {}, + }); + expect(results.length).toBe(1); + expect(results[0]!.action).toBe("block"); + + // Does not match "read" + const results2 = await engine.trigger("PreToolUse", { + matcherValue: "read", + inputData: {}, + }); + expect(results2).toEqual([]); + }); + + // ── Block/Allow ────────────────────────────────────── + + test("hook that outputs block action", async () => { + const engine = new HookEngine({ + hooks: [ + { + event: "PreToolUse", + command: 'echo \'{"action":"block","reason":"blocked by policy"}\'', + matcher: "", + timeout: 5, + }, + ], + cwd: tempDir, + }); + const results = await engine.trigger("PreToolUse", { + inputData: { tool_name: "shell" }, + }); + expect(results[0]!.action).toBe("block"); + expect(results[0]!.reason).toBe("blocked by policy"); + }); + + test("hook that exits non-zero fails open", async () => { + const engine = new HookEngine({ + hooks: [ + { + event: "PreToolUse", + command: "exit 1", + matcher: "", + timeout: 5, + }, + ], + cwd: tempDir, + }); + const results = await engine.trigger("PreToolUse", { inputData: {} }); + expect(results[0]!.action).toBe("allow"); + }); + + test("hook with invalid JSON output fails open", async () => { + const engine = new HookEngine({ + hooks: [ + { + event: "PreToolUse", + command: "echo 'not json'", + matcher: "", + timeout: 5, + }, + ], + cwd: tempDir, + }); + const results = await engine.trigger("PreToolUse", { inputData: {} }); + expect(results[0]!.action).toBe("allow"); + }); + + // ── Callbacks ──────────────────────────────────────── + + test("onTriggered and onResolved callbacks fire", async () => { + let triggered = false; + let resolved = false; + + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: "echo '{}'", matcher: "", timeout: 5 }, + ], + cwd: tempDir, + onTriggered: () => { triggered = true; }, + onResolved: () => { resolved = true; }, + }); + + await engine.trigger("PreToolUse", { inputData: {} }); + expect(triggered).toBe(true); + expect(resolved).toBe(true); + }); + + // ── addHooks ───────────────────────────────────────── + + test("addHooks dynamically adds hooks", async () => { + const engine = new HookEngine({ cwd: tempDir }); + expect(engine.hasHooksFor("PreToolUse")).toBe(false); + + engine.addHooks([ + { event: "PreToolUse", command: "echo '{}'", matcher: "", timeout: 5 }, + ]); + expect(engine.hasHooksFor("PreToolUse")).toBe(true); + }); + + // ── Deduplication ──────────────────────────────────── + + test("duplicate commands are deduplicated", async () => { + let callCount = 0; + const engine = new HookEngine({ + hooks: [ + { event: "PreToolUse", command: "echo '{}'", matcher: "", timeout: 5 }, + { event: "PreToolUse", command: "echo '{}'", matcher: "", timeout: 5 }, // duplicate + ], + cwd: tempDir, + onTriggered: (_e, _t, count) => { callCount = count; }, + }); + + await engine.trigger("PreToolUse", { inputData: {} }); + expect(callCount).toBe(1); // deduplicated to 1 + }); +}); diff --git a/tests/tools/ask_user.test.ts b/tests/tools/ask_user.test.ts new file mode 100644 index 000000000..144328b3b --- /dev/null +++ b/tests/tools/ask_user.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for AskUserQuestion tool. + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/ask_user.ts"; + +let tempDir: string; +let tool: AskUserQuestion; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new AskUserQuestion(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("AskUserQuestion", () => { + test("basic question returns response", async () => { + const result = await tool.execute( + { + questions: [ + { + question: "Which approach do you prefer?", + header: "Approach", + options: [ + { label: "Option A", description: "First option" }, + { label: "Option B", description: "Second option" }, + ], + multi_select: false, + }, + ], + }, + ctx, + ); + + expect(result.isError).toBe(false); + // Stub returns a JSON payload + expect(result.output).toBeTruthy(); + const parsed = JSON.parse(result.output); + expect(parsed).toHaveProperty("answers"); + }); + + test("multiple questions", async () => { + const result = await tool.execute( + { + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [ + { label: "A", description: "a" }, + { label: "B", description: "b" }, + ], + multi_select: false, + }, + { + question: "Question 2?", + header: "Q2", + options: [ + { label: "C", description: "c" }, + { label: "D", description: "d" }, + ], + multi_select: true, + }, + ], + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toBeTruthy(); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("AskUserQuestion"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/fetch_url.test.ts b/tests/tools/fetch_url.test.ts new file mode 100644 index 000000000..602492354 --- /dev/null +++ b/tests/tools/fetch_url.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for FetchURL tool. + * Corresponds to Python tests/tools/test_fetch_url.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { FetchURL } from "../../src/kimi_cli_ts/tools/web/fetch.ts"; + +let tempDir: string; +let tool: FetchURL; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new FetchURL(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("FetchURL", () => { + test("fetch from local HTTP server", async () => { + // Start a simple local server using Bun.serve + const server = Bun.serve({ + port: 0, // random port + fetch() { + return new Response("

Hello Test

Test content here.

", { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + try { + const result = await tool.execute({ url: `http://localhost:${server.port}/` }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Hello Test"); + expect(result.output).toContain("Test content"); + } finally { + server.stop(); + } + }); + + test("fetch plain text content", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response("# Title\n\nThis is plain markdown content.", { + headers: { "Content-Type": "text/plain" }, + }); + }, + }); + + try { + const result = await tool.execute({ url: `http://localhost:${server.port}/` }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("# Title"); + expect(result.output).toContain("plain markdown content"); + expect(result.message).toContain("full content of the page"); + } finally { + server.stop(); + } + }); + + test("fetch markdown content", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response("# Markdown\n\nHello world.", { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); + }, + }); + + try { + const result = await tool.execute({ url: `http://localhost:${server.port}/` }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("# Markdown"); + expect(result.message).toContain("full content of the page"); + } finally { + server.stop(); + } + }); + + test("fetch returns 404 error", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response("Not Found", { status: 404 }); + }, + }); + + try { + const result = await tool.execute({ url: `http://localhost:${server.port}/` }, ctx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("Status: 404"); + } finally { + server.stop(); + } + }); + + test("fetch invalid URL", async () => { + const result = await tool.execute( + { url: "https://this-domain-definitely-does-not-exist-12345.com/" }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("Failed to fetch URL"); + }); + + test("fetch empty URL", async () => { + const result = await tool.execute({ url: "" }, ctx); + + expect(result.isError).toBe(true); + }); + + test("fetch malformed URL", async () => { + const result = await tool.execute({ url: "not-a-valid-url" }, ctx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("Failed to fetch URL"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("FetchURL"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/glob.test.ts b/tests/tools/glob.test.ts new file mode 100644 index 000000000..8e274c940 --- /dev/null +++ b/tests/tools/glob.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for Glob tool. + * Corresponds to Python tests/tools/test_glob.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { Glob } from "../../src/kimi_cli_ts/tools/file/glob.ts"; + +let tempDir: string; +let tool: Glob; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new Glob(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +async function setupTestFiles(): Promise { + // Create directory structure + mkdirSync(join(tempDir, "src", "main"), { recursive: true }); + mkdirSync(join(tempDir, "src", "test"), { recursive: true }); + mkdirSync(join(tempDir, "docs"), { recursive: true }); + + // Create test files + await Bun.write(join(tempDir, "README.md"), "# README"); + await Bun.write(join(tempDir, "setup.py"), "setup"); + await Bun.write(join(tempDir, "src", "main.py"), "main"); + await Bun.write(join(tempDir, "src", "utils.py"), "utils"); + await Bun.write(join(tempDir, "src", "main", "app.py"), "app"); + await Bun.write(join(tempDir, "src", "main", "config.py"), "config"); + await Bun.write(join(tempDir, "src", "test", "test_app.py"), "test app"); + await Bun.write(join(tempDir, "src", "test", "test_config.py"), "test config"); + await Bun.write(join(tempDir, "docs", "guide.md"), "guide"); + await Bun.write(join(tempDir, "docs", "api.md"), "api"); + + return tempDir; +} + +describe("Glob", () => { + test("simple pattern matching", async () => { + await setupTestFiles(); + const result = await tool.execute( + { pattern: "*.py", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("setup.py"); + }); + + test("recursive pattern with prefix (allowed)", async () => { + await setupTestFiles(); + const result = await tool.execute( + { pattern: "src/**/*.py", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("src/main.py"); + expect(result.output).toContain("src/utils.py"); + expect(result.output).toContain("src/main/app.py"); + expect(result.output).toContain("src/main/config.py"); + expect(result.output).toContain("src/test/test_app.py"); + expect(result.output).toContain("src/test/test_config.py"); + expect(result.message).toContain("Found 6 matches"); + }); + + test("** at start is prohibited", async () => { + await setupTestFiles(); + const result = await tool.execute( + { pattern: "**/*.py", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("starts with '**' which is not allowed"); + }); + + test("specific directory search", async () => { + await setupTestFiles(); + const srcDir = join(tempDir, "src"); + const result = await tool.execute( + { pattern: "*.py", directory: srcDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("main.py"); + expect(result.output).toContain("utils.py"); + expect(result.message).toContain("Found 2 matches"); + }); + + test("recursive in subdirectory", async () => { + await setupTestFiles(); + const srcDir = join(tempDir, "src"); + const result = await tool.execute( + { pattern: "main/**/*.py", directory: srcDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("main/app.py"); + expect(result.output).toContain("main/config.py"); + expect(result.message).toContain("Found 2 matches"); + }); + + test("no matches", async () => { + await setupTestFiles(); + const result = await tool.execute( + { pattern: "*.xyz", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toBe(""); + expect(result.message).toContain("No matches found"); + }); + + test("relative path rejected", async () => { + const result = await tool.execute( + { pattern: "*.py", directory: "relative/path", include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("not an absolute path"); + }); + + test("character class pattern", async () => { + await Bun.write(join(tempDir, "file1.py"), "content1"); + await Bun.write(join(tempDir, "file2.py"), "content2"); + await Bun.write(join(tempDir, "file3.txt"), "content3"); + + const result = await tool.execute( + { pattern: "file[1-2].py", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("file1.py"); + expect(result.output).toContain("file2.py"); + expect(result.output).not.toContain("file3.txt"); + }); + + test("exclude directories with include_dirs=false", async () => { + await Bun.write(join(tempDir, "test_file.txt"), "content"); + mkdirSync(join(tempDir, "test_dir")); + + const result = await tool.execute( + { pattern: "test_*", directory: tempDir, include_dirs: false }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test_file.txt"); + expect(result.output).not.toContain("test_dir"); + }); + + test("test files pattern", async () => { + await setupTestFiles(); + const result = await tool.execute( + { pattern: "src/**/*test*.py", directory: tempDir, include_dirs: true }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("src/test/test_app.py"); + expect(result.output).toContain("src/test/test_config.py"); + expect(result.message).toContain("Found 2 matches"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Glob"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/grep.test.ts b/tests/tools/grep.test.ts new file mode 100644 index 000000000..118d6b62b --- /dev/null +++ b/tests/tools/grep.test.ts @@ -0,0 +1,476 @@ +/** + * Tests for Grep tool. + * Corresponds to Python tests/tools/test_grep.py + */ + +import { test, expect, describe, beforeEach, afterEach, beforeAll } from "bun:test"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { Grep } from "../../src/kimi_cli_ts/tools/file/grep.ts"; + +// Check if rg (ripgrep) is available in PATH +let rgAvailable = false; +try { + const proc = Bun.spawnSync(["rg", "--version"]); + rgAvailable = proc.exitCode === 0; +} catch { + rgAvailable = false; +} + +let tempDir: string; +let tool: Grep; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new Grep(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +async function setupTestFiles(): Promise { + await Bun.write( + join(tempDir, "test1.py"), + `def hello_world(): + print("Hello, World!") + return "hello" + +class TestClass: + def __init__(self): + self.message = "hello there" +`, + ); + + await Bun.write( + join(tempDir, "test2.js"), + `function helloWorld() { + console.log("Hello, World!"); + return "hello"; +} + +class TestClass { + constructor() { + this.message = "hello there"; + } +} +`, + ); + + await Bun.write( + join(tempDir, "readme.txt"), + `This is a readme file. +It contains some text. +Hello world example is here. +`, + ); + + mkdirSync(join(tempDir, "subdir"), { recursive: true }); + await Bun.write(join(tempDir, "subdir", "subtest.py"), "def sub_hello():\n return 'hello from subdir'\n"); + + return tempDir; +} + +// Use test.skipIf for tests requiring rg binary +const rgTest = rgAvailable ? test : test.skip; + +describe("Grep", () => { + rgTest("files_with_matches mode", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "Hello", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test1.py"); + expect(result.output).toContain("test2.js"); + expect(result.output).toContain("readme.txt"); + }); + + rgTest("content mode", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "hello", + path: tempDir, + output_mode: "content", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output.toLowerCase()).toContain("hello"); + // Content mode should have colons (file:linenum:content) + expect(result.output).toContain(":"); + }); + + rgTest("case insensitive search", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "HELLO", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test1.py"); + }); + + rgTest("context lines with -C", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "TestClass", + path: tempDir, + output_mode: "content", + "-B": null, + "-A": null, + "-C": 1, + "-n": true, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + const lines = result.output.split("\n"); + // Should have more than just the matching lines (context added) + expect(lines.length).toBeGreaterThan(2); + }); + + rgTest("count_matches mode", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "hello", + path: tempDir, + output_mode: "count_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test1.py"); + expect(result.output).toContain("test2.js"); + // Message should contain summary + expect(result.message).toContain("Found"); + expect(result.message).toContain("total occurrences"); + }); + + rgTest("glob filter", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "hello", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: "*.py", + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test1.py"); + expect(result.output).toContain("subtest.py"); + expect(result.output).not.toContain("test2.js"); + expect(result.output).not.toContain("readme.txt"); + }); + + rgTest("type filter", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "hello", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: null, + type: "py", + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test1.py"); + expect(result.output).not.toContain("test2.js"); + expect(result.output).not.toContain("readme.txt"); + }); + + rgTest("head_limit truncation", async () => { + await setupTestFiles(); + const result = await tool.execute( + { + pattern: "hello", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": true, + glob: null, + type: null, + head_limit: 2, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + const lines = result.output.split("\n").filter((l: string) => l.trim()); + expect(lines.length).toBeLessThanOrEqual(2); + expect(result.message).toContain("Results truncated to 2 lines"); + }); + + rgTest("no matches", async () => { + await Bun.write(join(tempDir, "empty.py"), "# No matching content\n"); + + const result = await tool.execute( + { + pattern: "nonexistent_pattern", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.message).toContain("No matches found"); + }); + + rgTest("multiline mode", async () => { + await Bun.write( + join(tempDir, "multiline.py"), + `def function(): + '''This is a + multiline docstring''' + pass +`, + ); + + const result = await tool.execute( + { + pattern: "This is a\\n multiline", + path: tempDir, + output_mode: "content", + "-B": null, + "-A": null, + "-C": null, + "-n": false, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: true, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("This is a"); + expect(result.output).toContain("multiline"); + }); + + rgTest("hidden files are searchable", async () => { + await Bun.write(join(tempDir, ".env"), "SECRET_KEY=abc123\n"); + await Bun.write(join(tempDir, "visible.txt"), "SECRET_KEY=xyz\n"); + + const result = await tool.execute( + { + pattern: "SECRET_KEY", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain(".env"); + expect(result.output).toContain("visible.txt"); + }); + + rgTest(".git directory is excluded", async () => { + mkdirSync(join(tempDir, ".git"), { recursive: true }); + await Bun.write(join(tempDir, ".git", "config"), "vcs_marker\n"); + await Bun.write(join(tempDir, "real.txt"), "vcs_marker\n"); + + const result = await tool.execute( + { + pattern: "vcs_marker", + path: tempDir, + output_mode: "files_with_matches", + "-B": null, + "-A": null, + "-C": null, + "-n": true, + "-i": false, + glob: null, + type: null, + head_limit: 250, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("real.txt"); + expect(result.output).not.toContain(".git"); + }); + + rgTest("offset pagination", async () => { + // Create a file with many matching lines + const lines = Array.from({ length: 10 }, (_, i) => `line${i} word`).join("\n") + "\n"; + await Bun.write(join(tempDir, "data.txt"), lines); + + // Page 1: first 3 + const r1 = await tool.execute( + { + pattern: "word", + path: tempDir, + output_mode: "content", + "-B": null, + "-A": null, + "-C": null, + "-n": false, + "-i": false, + glob: null, + type: null, + head_limit: 3, + offset: 0, + multiline: false, + }, + ctx, + ); + + expect(r1.isError).toBe(false); + const lines1 = r1.output.split("\n").filter((l: string) => l.trim()); + expect(lines1.length).toBe(3); + expect(r1.message).toContain("Use offset=3 to see more"); + + // Page 2: next 3 + const r2 = await tool.execute( + { + pattern: "word", + path: tempDir, + output_mode: "content", + "-B": null, + "-A": null, + "-C": null, + "-n": false, + "-i": false, + glob: null, + type: null, + head_limit: 3, + offset: 3, + multiline: false, + }, + ctx, + ); + + expect(r2.isError).toBe(false); + const lines2 = r2.output.split("\n").filter((l: string) => l.trim()); + expect(lines2.length).toBe(3); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Grep"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/read_file.test.ts b/tests/tools/read_file.test.ts new file mode 100644 index 000000000..56cba6696 --- /dev/null +++ b/tests/tools/read_file.test.ts @@ -0,0 +1,292 @@ +/** + * Tests for ReadFile tool. + * Corresponds to Python tests/tools/test_read_file.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { ReadFile } from "../../src/kimi_cli_ts/tools/file/read.ts"; + +let tempDir: string; +let tool: ReadFile; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new ReadFile(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +const sampleContent = `Line 1: Hello World +Line 2: This is a test file +Line 3: With multiple lines +Line 4: For testing purposes +Line 5: End of file`; + +async function createSampleFile(name = "sample.txt", content = sampleContent): Promise { + const filePath = join(tempDir, name); + await Bun.write(filePath, content); + return filePath; +} + +describe("ReadFile", () => { + test("read entire file", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 1: Hello World"); + expect(result.output).toContain("Line 5: End of file"); + expect(result.message).toContain("5 lines read from file starting from line 1"); + expect(result.message).toContain("Total lines in file: 5."); + expect(result.message).toContain("End of file reached"); + }); + + test("read with line offset", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 3, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 3: With multiple lines"); + expect(result.output).toContain("Line 5: End of file"); + expect(result.output).not.toContain("Line 1: Hello World"); + expect(result.message).toContain("3 lines read from file starting from line 3"); + }); + + test("read with n_lines limit", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 2 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 1: Hello World"); + expect(result.output).toContain("Line 2: This is a test file"); + expect(result.output).not.toContain("Line 3"); + expect(result.message).toContain("2 lines read from file starting from line 1"); + }); + + test("read with line offset and n_lines combined", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 2, n_lines: 2 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 2: This is a test file"); + expect(result.output).toContain("Line 3: With multiple lines"); + expect(result.output).not.toContain("Line 1"); + expect(result.output).not.toContain("Line 4"); + expect(result.message).toContain("2 lines read from file starting from line 2"); + }); + + test("read nonexistent file", async () => { + const nonexistent = join(tempDir, "nonexistent.txt"); + const result = await tool.execute({ path: nonexistent, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("does not exist"); + }); + + test("read with relative path", async () => { + await createSampleFile(); + const result = await tool.execute({ path: "sample.txt", line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 1: Hello World"); + expect(result.message).toContain("5 lines read from file starting from line 1"); + }); + + test("read empty file", async () => { + const filePath = await createSampleFile("empty.txt", ""); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toBe(""); + expect(result.message).toContain("No lines read from file."); + expect(result.message).toContain("Total lines in file: 0."); + }); + + test("read with line offset beyond file length", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 100, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toBe(""); + expect(result.message).toContain("No lines read from file"); + }); + + test("cat -n format line numbers", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + // cat -n format: right-aligned line number, then tab, then content + expect(result.output).toMatch(/\s+1\t/); + expect(result.output).toMatch(/\s+5\t/); + }); + + test("read unicode file", async () => { + const filePath = await createSampleFile("unicode.txt", "Hello 世界 🌍\nUnicode test: café, naïve, résumé"); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Hello 世界 🌍"); + expect(result.output).toContain("café"); + expect(result.message).toContain("2 lines read from file starting from line 1"); + }); + + test("line truncation for long lines", async () => { + const longContent = "A".repeat(2500) + " This should be truncated"; + const filePath = await createSampleFile("long.txt", longContent); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + // Should be truncated with "..." + expect(result.output).toContain("..."); + expect(result.message).toContain("Lines [1] were truncated"); + }); + + test("max lines boundary", async () => { + // Create file with > 1000 lines + const lines = Array.from({ length: 1010 }, (_, i) => `Line ${i + 1}`).join("\n"); + const filePath = await createSampleFile("large.txt", lines); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1005 }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("Max 1000 lines reached"); + }); + + test("max bytes boundary", async () => { + // Create file with lines that exceed 100KB total + const lineContent = "A".repeat(1000); + const numLines = 110; // 110 * 1000 = 110KB > 100KB + const content = Array.from({ length: numLines }, () => lineContent).join("\n"); + const filePath = await createSampleFile("large_bytes.txt", content); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("Max 102400 bytes reached"); + }); + + test("empty path returns error", async () => { + const result = await tool.execute({ path: "", line_offset: 1, n_lines: 1000 }, ctx); + expect(result.isError).toBe(true); + expect(result.message).toContain("cannot be empty"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("ReadFile"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + expect(typeof def.parameters).toBe("object"); + }); + + // ── Tests for totalLines and tail (negative offset) ────────────────────── + + test("totalLines included in positive offset reads", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 3, n_lines: 1 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 3: With multiple lines"); + expect(result.output).not.toContain("Line 1:"); + expect(result.output).not.toContain("Line 4:"); + expect(result.message).toContain("Total lines in file: 5."); + }); + + test("totalLines for empty file", async () => { + const filePath = await createSampleFile("empty_total.txt", ""); + const result = await tool.execute({ path: filePath, line_offset: 1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("Total lines in file: 0."); + }); + + test("tail mode basic - negative line_offset", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: -3, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 3: With multiple lines"); + expect(result.output).toContain("Line 4: For testing purposes"); + expect(result.output).toContain("Line 5: End of file"); + expect(result.output).not.toContain("Line 1:"); + expect(result.output).not.toContain("Line 2:"); + expect(result.message).toContain("Total lines in file: 5."); + }); + + test("tail mode with n_lines limit", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: -5, n_lines: 2 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 1: Hello World"); + expect(result.output).toContain("Line 2: This is a test file"); + expect(result.output).not.toContain("Line 3:"); + expect(result.message).toContain("Total lines in file: 5."); + }); + + test("tail mode exceeds file length returns entire file", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: -100, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Line 1: Hello World"); + expect(result.output).toContain("Line 5: End of file"); + expect(result.message).toContain("Total lines in file: 5."); + }); + + test("tail mode on empty file", async () => { + const filePath = await createSampleFile("empty_tail.txt", ""); + const result = await tool.execute({ path: filePath, line_offset: -10, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toBe(""); + expect(result.message).toContain("Total lines in file: 0."); + }); + + test("tail mode last line only", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: -1, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toContain("5\tLine 5: End of file"); + expect(result.message).toContain("1 lines read from file starting from line 5."); + expect(result.message).toContain("Total lines in file: 5."); + expect(result.message).toContain("End of file reached."); + }); + + test("tail mode line_offset=0 returns error", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: 0, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("line_offset cannot be 0"); + }); + + test("tail mode line_offset too negative returns error", async () => { + const filePath = await createSampleFile(); + const result = await tool.execute({ path: filePath, line_offset: -1001, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("line_offset cannot be less than -1000"); + }); + + test("tail mode line truncation", async () => { + const shortLine = "Short line"; + const longLine = "X".repeat(2500); // Exceeds MAX_LINE_LENGTH=2000 + const content = `${shortLine}\n${longLine}\n${shortLine}\n${longLine}\n${shortLine}`; + const filePath = await createSampleFile("tail_trunc.txt", content); + + // Read last 3 lines (lines 3, 4, 5) + const result = await tool.execute({ path: filePath, line_offset: -3, n_lines: 1000 }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("Total lines in file: 5."); + expect(result.message).toContain("Lines [4] were truncated."); + expect(result.output).toContain("..."); + }); +}); diff --git a/tests/tools/shell.test.ts b/tests/tools/shell.test.ts new file mode 100644 index 000000000..3ac68bb37 --- /dev/null +++ b/tests/tools/shell.test.ts @@ -0,0 +1,224 @@ +/** + * Tests for Shell tool. + * Corresponds to Python tests/tools/test_shell_bash.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { Shell } from "../../src/kimi_cli_ts/tools/shell/shell.ts"; + +let tempDir: string; +let tool: Shell; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new Shell(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("Shell", () => { + test("simple echo command", async () => { + const result = await tool.execute( + { command: "echo 'Hello World'", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Hello World"); + expect(result.message).toContain("Command executed successfully"); + }); + + test("command with error", async () => { + const result = await tool.execute( + { command: "ls /nonexistent/directory", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.output).toContain("No such file or directory"); + expect(result.message).toContain("Command failed with exit code"); + }); + + test("command chaining with &&", async () => { + const result = await tool.execute( + { command: "echo 'First' && echo 'Second'", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("First"); + expect(result.output).toContain("Second"); + }); + + test("sequential commands with ;", async () => { + const result = await tool.execute( + { command: "echo 'One'; echo 'Two'", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("One"); + expect(result.output).toContain("Two"); + }); + + test("conditional execution with ||", async () => { + const result = await tool.execute( + { command: "false || echo 'Success'", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Success"); + }); + + test("piping commands", async () => { + const result = await tool.execute( + { command: "echo 'Hello World' | wc -w", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output.trim()).toBe("2"); + }); + + test("command with timeout parameter", async () => { + const result = await tool.execute( + { command: "sleep 0.1", timeout: 5, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(false); + }); + + test("command timeout expires", async () => { + const result = await tool.execute( + { command: "sleep 10", timeout: 1, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("Command killed by timeout (1s)"); + }); + + test("environment variables", async () => { + const result = await tool.execute( + { + command: "export TEST_VAR='test_value' && echo $TEST_VAR", + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("test_value"); + }); + + test("file operations", async () => { + // Create a file + const result1 = await tool.execute( + { + command: `echo 'Test content' > ${join(tempDir, "test_file.txt")}`, + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + expect(result1.isError).toBe(false); + + // Read the file + const result2 = await tool.execute( + { + command: `cat ${join(tempDir, "test_file.txt")}`, + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + expect(result2.isError).toBe(false); + expect(result2.output).toContain("Test content"); + }); + + test("command substitution", async () => { + const result = await tool.execute( + { + command: 'echo "Result: $(echo hello)"', + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Result: hello"); + }); + + test("arithmetic substitution", async () => { + const result = await tool.execute( + { + command: 'echo "Answer: $((2 + 2))"', + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.output).toContain("Answer: 4"); + }); + + test("empty command returns error", async () => { + const result = await tool.execute( + { command: "", timeout: 60, run_in_background: false, description: "" }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("Command cannot be empty"); + }); + + test("rejection from approval", async () => { + const rejectCtx = createTestToolContext(tempDir, { yolo: false }); + const result = await tool.execute( + { command: "echo test", timeout: 60, run_in_background: false, description: "" }, + rejectCtx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("rejected"); + }); + + test("stdout and stderr capture", async () => { + const result = await tool.execute( + { + command: "echo stdout_msg && echo stderr_msg >&2", + timeout: 60, + run_in_background: false, + description: "", + }, + ctx, + ); + + // Both stdout and stderr should be captured + expect(result.output).toContain("stdout_msg"); + expect(result.output).toContain("stderr_msg"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Shell"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/str_replace_file.test.ts b/tests/tools/str_replace_file.test.ts new file mode 100644 index 000000000..56709558d --- /dev/null +++ b/tests/tools/str_replace_file.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for StrReplaceFile tool. + * Corresponds to Python tests/tools/test_str_replace_file.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { StrReplaceFile } from "../../src/kimi_cli_ts/tools/file/replace.ts"; + +let tempDir: string; +let tool: StrReplaceFile; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new StrReplaceFile(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +async function writeTestFile(name: string, content: string): Promise { + const filePath = join(tempDir, name); + await Bun.write(filePath, content); + return filePath; +} + +describe("StrReplaceFile", () => { + test("replace single occurrence", async () => { + const filePath = await writeTestFile("test.txt", "Hello world! This is a test."); + const result = await tool.execute( + { path: filePath, edit: { old: "world", new: "universe", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.message).toContain("successfully edited"); + const content = await Bun.file(filePath).text(); + expect(content).toBe("Hello universe! This is a test."); + }); + + test("replace all occurrences", async () => { + const filePath = await writeTestFile("test.txt", "apple banana apple cherry apple"); + const result = await tool.execute( + { path: filePath, edit: { old: "apple", new: "fruit", replace_all: true } }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.message).toContain("successfully edited"); + const content = await Bun.file(filePath).text(); + expect(content).toBe("fruit banana fruit cherry fruit"); + }); + + test("replace multiple edits", async () => { + const filePath = await writeTestFile("test.txt", "Hello world! Goodbye world!"); + const result = await tool.execute( + { + path: filePath, + edit: [ + { old: "Hello", new: "Hi", replace_all: false }, + { old: "Goodbye", new: "See you", replace_all: false }, + ], + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.message).toContain("successfully edited"); + const content = await Bun.file(filePath).text(); + expect(content).toBe("Hi world! See you world!"); + }); + + test("replace multiline content", async () => { + const filePath = await writeTestFile("test.txt", "Line 1\nLine 2\nLine 3\n"); + const result = await tool.execute( + { + path: filePath, + edit: { old: "Line 2\nLine 3", new: "Modified line 2\nModified line 3", replace_all: false }, + }, + ctx, + ); + + expect(result.isError).toBe(false); + const content = await Bun.file(filePath).text(); + expect(content).toBe("Line 1\nModified line 2\nModified line 3\n"); + }); + + test("replace unicode content", async () => { + const filePath = await writeTestFile("test.txt", "Hello 世界! café"); + const result = await tool.execute( + { path: filePath, edit: { old: "世界", new: "地球", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(false); + const content = await Bun.file(filePath).text(); + expect(content).toBe("Hello 地球! café"); + }); + + test("no match returns error", async () => { + const filePath = await writeTestFile("test.txt", "Hello world!"); + const result = await tool.execute( + { path: filePath, edit: { old: "notfound", new: "replacement", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("No replacements were made"); + // Content should be unchanged + const content = await Bun.file(filePath).text(); + expect(content).toBe("Hello world!"); + }); + + test("nonexistent file returns error", async () => { + const filePath = join(tempDir, "nonexistent.txt"); + const result = await tool.execute( + { path: filePath, edit: { old: "old", new: "new", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("does not exist"); + }); + + test("replace with relative path", async () => { + const subDir = join(tempDir, "relative", "path"); + Bun.spawnSync(["mkdir", "-p", subDir]); + await Bun.write(join(subDir, "file.txt"), "old content"); + + const result = await tool.execute( + { path: "relative/path/file.txt", edit: { old: "old", new: "new", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(false); + const content = await Bun.file(join(subDir, "file.txt")).text(); + expect(content).toBe("new content"); + }); + + test("replace with empty new string (deletion)", async () => { + const filePath = await writeTestFile("test.txt", "Hello world!"); + const result = await tool.execute( + { path: filePath, edit: { old: "world", new: "", replace_all: false } }, + ctx, + ); + + expect(result.isError).toBe(false); + const content = await Bun.file(filePath).text(); + expect(content).toBe("Hello !"); + }); + + test("mixed multiple edits with different replace_all settings", async () => { + const filePath = await writeTestFile("test.txt", "apple apple banana apple cherry"); + const result = await tool.execute( + { + path: filePath, + edit: [ + { old: "apple", new: "fruit", replace_all: false }, // Only first + { old: "banana", new: "tasty", replace_all: true }, // All (only one) + ], + }, + ctx, + ); + + expect(result.isError).toBe(false); + const content = await Bun.file(filePath).text(); + expect(content).toBe("fruit apple tasty apple cherry"); + }); + + test("empty path returns error", async () => { + const result = await tool.execute( + { path: "", edit: { old: "old", new: "new", replace_all: false } }, + ctx, + ); + expect(result.isError).toBe(true); + expect(result.message).toContain("cannot be empty"); + }); + + test("rejection from approval", async () => { + const rejectCtx = createTestToolContext(tempDir, { yolo: false }); + const filePath = await writeTestFile("test.txt", "Hello world!"); + const result = await tool.execute( + { path: filePath, edit: { old: "world", new: "universe", replace_all: false } }, + rejectCtx, + ); + + expect(result.isError).toBe(true); + expect(result.message).toContain("rejected"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("StrReplaceFile"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/think.test.ts b/tests/tools/think.test.ts new file mode 100644 index 000000000..0b53a3f7b --- /dev/null +++ b/tests/tools/think.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for Think tool. + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { Think } from "../../src/kimi_cli_ts/tools/think/think.ts"; + +let tempDir: string; +let tool: Think; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new Think(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("Think", () => { + test("basic thought returns success", async () => { + const result = await tool.execute({ thought: "I need to consider the approach." }, ctx); + + expect(result.isError).toBe(false); + expect(result.output).toBe(""); + expect(result.message).toBe("Thought logged"); + }); + + test("empty thought still succeeds", async () => { + const result = await tool.execute({ thought: "" }, ctx); + + expect(result.isError).toBe(false); + }); + + test("long thought still succeeds", async () => { + const longThought = "A".repeat(10000); + const result = await tool.execute({ thought: longThought }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toBe("Thought logged"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Think"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); +}); diff --git a/tests/tools/todo.test.ts b/tests/tools/todo.test.ts new file mode 100644 index 000000000..6f639c344 --- /dev/null +++ b/tests/tools/todo.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for SetTodoList tool. + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/todo.ts"; + +let tempDir: string; +let tool: SetTodoList; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new SetTodoList(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("SetTodoList", () => { + test("basic todo list update", async () => { + const result = await tool.execute( + { + todos: [ + { title: "Task 1", status: "pending" }, + { title: "Task 2", status: "in_progress" }, + { title: "Task 3", status: "done" }, + ], + }, + ctx, + ); + + expect(result.isError).toBe(false); + expect(result.message).toBe("Todo list updated"); + expect(result.display).toBeDefined(); + expect(result.display!.length).toBe(1); + + const todoBlock = result.display![0] as { type: string; items: Array<{ title: string; status: string }> }; + expect(todoBlock.type).toBe("todo"); + expect(todoBlock.items.length).toBe(3); + expect(todoBlock.items[0].title).toBe("Task 1"); + expect(todoBlock.items[0].status).toBe("pending"); + expect(todoBlock.items[2].status).toBe("done"); + }); + + test("empty todo list", async () => { + const result = await tool.execute({ todos: [] }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toBe("Todo list updated"); + }); + + test("single item todo list", async () => { + const result = await tool.execute( + { todos: [{ title: "Only task", status: "in_progress" }] }, + ctx, + ); + + expect(result.isError).toBe(false); + const todoBlock = result.display![0] as { type: string; items: Array<{ title: string; status: string }> }; + expect(todoBlock.items.length).toBe(1); + expect(todoBlock.items[0].title).toBe("Only task"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("SetTodoList"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/tools/tool_schemas.test.ts b/tests/tools/tool_schemas.test.ts new file mode 100644 index 000000000..6c9e3daa9 --- /dev/null +++ b/tests/tools/tool_schemas.test.ts @@ -0,0 +1,304 @@ +/** + * Tests for all tool Zod schemas and toDefinition(). + * Corresponds to Python tests/tools/test_tool_schemas.py + */ + +import { test, expect, describe } from "bun:test"; +import { ReadFile } from "../../src/kimi_cli_ts/tools/file/read.ts"; +import { WriteFile } from "../../src/kimi_cli_ts/tools/file/write.ts"; +import { StrReplaceFile } from "../../src/kimi_cli_ts/tools/file/replace.ts"; +import { Glob } from "../../src/kimi_cli_ts/tools/file/glob.ts"; +import { Grep } from "../../src/kimi_cli_ts/tools/file/grep.ts"; +import { Shell } from "../../src/kimi_cli_ts/tools/shell/shell.ts"; +import { FetchURL } from "../../src/kimi_cli_ts/tools/web/fetch.ts"; +import { Think } from "../../src/kimi_cli_ts/tools/think/think.ts"; +import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/ask_user.ts"; +import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/todo.ts"; + +describe("Tool Schemas", () => { + describe("ReadFile schema", () => { + const tool = new ReadFile(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt" }); + expect(result.success).toBe(true); + }); + + test("defaults are applied", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.line_offset).toBe(1); + expect(result.data.n_lines).toBe(1000); + } + }); + + test("missing path fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("ReadFile"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("WriteFile schema", () => { + const tool = new WriteFile(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt", content: "hello" }); + expect(result.success).toBe(true); + }); + + test("defaults are applied", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt", content: "hello" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mode).toBe("overwrite"); + } + }); + + test("missing content fails", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt" }); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("WriteFile"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("StrReplaceFile schema", () => { + const tool = new StrReplaceFile(); + + test("single edit parses successfully", () => { + const result = tool.schema.safeParse({ + path: "/tmp/test.txt", + edit: { old: "hello", new: "world" }, + }); + expect(result.success).toBe(true); + }); + + test("array of edits parses successfully", () => { + const result = tool.schema.safeParse({ + path: "/tmp/test.txt", + edit: [ + { old: "hello", new: "world" }, + { old: "foo", new: "bar", replace_all: true }, + ], + }); + expect(result.success).toBe(true); + }); + + test("missing edit fails", () => { + const result = tool.schema.safeParse({ path: "/tmp/test.txt" }); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("StrReplaceFile"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("Glob schema", () => { + const tool = new Glob(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ pattern: "*.ts" }); + expect(result.success).toBe(true); + }); + + test("missing pattern fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Glob"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("Grep schema", () => { + const tool = new Grep(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ pattern: "test" }); + expect(result.success).toBe(true); + }); + + test("defaults are applied", () => { + const result = tool.schema.safeParse({ pattern: "test" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.output_mode).toBe("files_with_matches"); + expect(result.data["-n"]).toBe(true); + expect(result.data["-i"]).toBe(false); + expect(result.data.head_limit).toBe(250); + expect(result.data.offset).toBe(0); + expect(result.data.multiline).toBe(false); + } + }); + + test("missing pattern fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Grep"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("Shell schema", () => { + const tool = new Shell(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ command: "echo test" }); + expect(result.success).toBe(true); + }); + + test("defaults are applied", () => { + const result = tool.schema.safeParse({ command: "echo test" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timeout).toBe(60); + expect(result.data.run_in_background).toBe(false); + } + }); + + test("missing command fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Shell"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("FetchURL schema", () => { + const tool = new FetchURL(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ url: "https://example.com" }); + expect(result.success).toBe(true); + }); + + test("missing url fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("FetchURL"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("Think schema", () => { + const tool = new Think(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ thought: "thinking..." }); + expect(result.success).toBe(true); + }); + + test("missing thought fails", () => { + const result = tool.schema.safeParse({}); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("Think"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("AskUserQuestion schema", () => { + const tool = new AskUserQuestion(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ + questions: [ + { + question: "Which?", + header: "Choice", + options: [ + { label: "A", description: "opt A" }, + { label: "B", description: "opt B" }, + ], + multi_select: false, + }, + ], + }); + expect(result.success).toBe(true); + }); + + test("empty questions array fails (min 1)", () => { + const result = tool.schema.safeParse({ questions: [] }); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("AskUserQuestion"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); + + describe("SetTodoList schema", () => { + const tool = new SetTodoList(); + + test("valid params parse successfully", () => { + const result = tool.schema.safeParse({ + todos: [{ title: "Test", status: "pending" }], + }); + expect(result.success).toBe(true); + }); + + test("invalid status fails", () => { + const result = tool.schema.safeParse({ + todos: [{ title: "Test", status: "invalid" }], + }); + expect(result.success).toBe(false); + }); + + test("empty title fails", () => { + const result = tool.schema.safeParse({ + todos: [{ title: "", status: "pending" }], + }); + expect(result.success).toBe(false); + }); + + test("toDefinition returns valid definition", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("SetTodoList"); + expect(def.description).toBeTruthy(); + expect(typeof def.parameters).toBe("object"); + }); + }); +}); diff --git a/tests/tools/write_file.test.ts b/tests/tools/write_file.test.ts new file mode 100644 index 000000000..d6e885317 --- /dev/null +++ b/tests/tools/write_file.test.ts @@ -0,0 +1,160 @@ +/** + * Tests for WriteFile tool. + * Corresponds to Python tests/tools/test_write_file.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; +import { WriteFile } from "../../src/kimi_cli_ts/tools/file/write.ts"; + +let tempDir: string; +let tool: WriteFile; +let ctx: ReturnType; + +beforeEach(() => { + tempDir = createTempDir(); + tool = new WriteFile(); + ctx = createTestToolContext(tempDir); +}); + +afterEach(() => { + removeTempDir(tempDir); +}); + +describe("WriteFile", () => { + test("write new file", async () => { + const filePath = join(tempDir, "new_file.txt"); + const content = "Hello, World!"; + + const result = await tool.execute({ path: filePath, content, mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("successfully"); + const written = await Bun.file(filePath).text(); + expect(written).toBe(content); + }); + + test("overwrite existing file", async () => { + const filePath = join(tempDir, "existing.txt"); + await Bun.write(filePath, "Original content"); + + const newContent = "New content"; + const result = await tool.execute({ path: filePath, content: newContent, mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("overwritten"); + const written = await Bun.file(filePath).text(); + expect(written).toBe(newContent); + }); + + test("append to existing file", async () => { + const filePath = join(tempDir, "append_test.txt"); + const original = "First line\n"; + await Bun.write(filePath, original); + + const appendContent = "Second line\n"; + const result = await tool.execute({ path: filePath, content: appendContent, mode: "append" }, ctx); + + expect(result.isError).toBe(false); + expect(result.message).toContain("appended"); + const written = await Bun.file(filePath).text(); + expect(written).toBe(original + appendContent); + }); + + test("write unicode content", async () => { + const filePath = join(tempDir, "unicode.txt"); + const content = "Hello 世界 🌍\nUnicode: café, naïve, résumé"; + + const result = await tool.execute({ path: filePath, content, mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + const written = await Bun.file(filePath).text(); + expect(written).toBe(content); + }); + + test("write empty content", async () => { + const filePath = join(tempDir, "empty.txt"); + + const result = await tool.execute({ path: filePath, content: "", mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + const written = await Bun.file(filePath).text(); + expect(written).toBe(""); + }); + + test("write multiline content", async () => { + const filePath = join(tempDir, "multiline.txt"); + const content = "Line 1\nLine 2\nLine 3\n"; + + const result = await tool.execute({ path: filePath, content, mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + const written = await Bun.file(filePath).text(); + expect(written).toBe(content); + }); + + test("write with relative path", async () => { + const subDir = join(tempDir, "relative", "path"); + mkdirSync(subDir, { recursive: true }); + + const result = await tool.execute( + { path: "relative/path/file.txt", content: "content", mode: "overwrite" }, + ctx, + ); + + expect(result.isError).toBe(false); + const written = await Bun.file(join(tempDir, "relative/path/file.txt")).text(); + expect(written).toBe("content"); + }); + + test("create intermediate directories via Bun.write", async () => { + // Bun.write auto-creates parent directories + const filePath = join(tempDir, "deep", "nested", "dir", "file.txt"); + + const result = await tool.execute( + { path: filePath, content: "deep content", mode: "overwrite" }, + ctx, + ); + + // Bun.write creates parent dirs automatically + expect(result.isError).toBe(false); + const written = await Bun.file(filePath).text(); + expect(written).toBe("deep content"); + }); + + test("write large content", async () => { + const filePath = join(tempDir, "large.txt"); + const content = "Large content line\n".repeat(1000); + + const result = await tool.execute({ path: filePath, content, mode: "overwrite" }, ctx); + + expect(result.isError).toBe(false); + const written = await Bun.file(filePath).text(); + expect(written).toBe(content); + }); + + test("empty path returns error", async () => { + const result = await tool.execute({ path: "", content: "test", mode: "overwrite" }, ctx); + expect(result.isError).toBe(true); + expect(result.message).toContain("cannot be empty"); + }); + + test("rejection from approval", async () => { + const rejectCtx = createTestToolContext(tempDir, { yolo: false }); + const filePath = join(tempDir, "rejected.txt"); + + const result = await tool.execute({ path: filePath, content: "test", mode: "overwrite" }, rejectCtx); + + expect(result.isError).toBe(true); + expect(result.message).toContain("rejected"); + }); + + test("toDefinition returns valid schema", () => { + const def = tool.toDefinition(); + expect(def.name).toBe("WriteFile"); + expect(def.description).toBeTruthy(); + expect(def.parameters).toBeDefined(); + }); +}); diff --git a/tests/ui/console.test.ts b/tests/ui/console.test.ts new file mode 100644 index 000000000..8543977ac --- /dev/null +++ b/tests/ui/console.test.ts @@ -0,0 +1,35 @@ +/** + * Tests for ui/shell/console.ts — terminal size detection. + */ + +import { test, expect, describe } from "bun:test"; +import { getTerminalSize, onResize } from "../../src/kimi_cli_ts/ui/shell/console"; + +describe("getTerminalSize", () => { + test("returns an object with columns and rows", () => { + const size = getTerminalSize(); + expect(typeof size.columns).toBe("number"); + expect(typeof size.rows).toBe("number"); + }); + + test("columns and rows are positive", () => { + const size = getTerminalSize(); + expect(size.columns).toBeGreaterThan(0); + expect(size.rows).toBeGreaterThan(0); + }); + + test("defaults to at least 80x24", () => { + const size = getTerminalSize(); + expect(size.columns).toBeGreaterThanOrEqual(80); + expect(size.rows).toBeGreaterThanOrEqual(24); + }); +}); + +describe("onResize", () => { + test("returns an unsubscribe function", () => { + const unsub = onResize(() => {}); + expect(typeof unsub).toBe("function"); + // Clean up + unsub(); + }); +}); diff --git a/tests/ui/events.test.ts b/tests/ui/events.test.ts new file mode 100644 index 000000000..e7704b84a --- /dev/null +++ b/tests/ui/events.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for ui/shell/events.ts — event type definitions. + */ + +import { test, expect, describe } from "bun:test"; +import type { + UIMessageRole, + TextSegment, + ThinkSegment, + ToolCallSegment, + MessageSegment, + UIMessage, + WireUIEvent, +} from "../../src/kimi_cli_ts/ui/shell/events"; + +describe("event types", () => { + test("UIMessageRole accepts valid roles", () => { + const roles: UIMessageRole[] = ["user", "assistant", "system", "tool"]; + expect(roles).toHaveLength(4); + }); + + test("TextSegment structure", () => { + const seg: TextSegment = { type: "text", text: "hello" }; + expect(seg.type).toBe("text"); + expect(seg.text).toBe("hello"); + }); + + test("ThinkSegment structure", () => { + const seg: ThinkSegment = { type: "think", text: "thinking..." }; + expect(seg.type).toBe("think"); + }); + + test("ToolCallSegment structure", () => { + const seg: ToolCallSegment = { + type: "tool_call", + id: "tc-1", + name: "ReadFile", + arguments: '{"path":"test.ts"}', + collapsed: false, + }; + expect(seg.type).toBe("tool_call"); + expect(seg.id).toBe("tc-1"); + expect(seg.name).toBe("ReadFile"); + }); + + test("UIMessage structure", () => { + const msg: UIMessage = { + id: "msg-1", + role: "assistant", + segments: [{ type: "text", text: "hello" }], + timestamp: Date.now(), + }; + expect(msg.role).toBe("assistant"); + expect(msg.segments).toHaveLength(1); + }); + + test("WireUIEvent union covers expected types", () => { + const events: WireUIEvent[] = [ + { type: "turn_begin", userInput: "hi" }, + { type: "turn_end" }, + { type: "text_delta", text: "hello" }, + { type: "think_delta", text: "hmm" }, + { type: "error", message: "oops" }, + ]; + expect(events).toHaveLength(5); + expect(events[0].type).toBe("turn_begin"); + expect(events[4].type).toBe("error"); + }); +}); diff --git a/tests/ui/keyboard.test.ts b/tests/ui/keyboard.test.ts new file mode 100644 index 000000000..53c5174d0 --- /dev/null +++ b/tests/ui/keyboard.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for ui/shell/keyboard.ts — KeyAction types and useKeyboard structure. + * Note: We cannot test the React hook directly without rendering, + * so we test exported types and the function's existence. + */ + +import { test, expect, describe } from "bun:test"; +import type { KeyAction, UseKeyboardOptions } from "../../src/kimi_cli_ts/ui/shell/keyboard"; +import { useKeyboard } from "../../src/kimi_cli_ts/ui/shell/keyboard"; + +describe("keyboard types and exports", () => { + test("KeyAction type covers expected actions", () => { + const actions: KeyAction[] = [ + "submit", + "interrupt", + "escape", + "history-prev", + "history-next", + "tab", + ]; + expect(actions).toHaveLength(6); + }); + + test("useKeyboard is exported as a function", () => { + expect(typeof useKeyboard).toBe("function"); + }); + + test("UseKeyboardOptions interface accepts valid options", () => { + const opts: UseKeyboardOptions = { + onAction: (_action: KeyAction) => {}, + active: true, + }; + expect(opts.active).toBe(true); + expect(typeof opts.onAction).toBe("function"); + }); +}); diff --git a/tests/ui/print_mode.test.ts b/tests/ui/print_mode.test.ts new file mode 100644 index 000000000..53d2c086d --- /dev/null +++ b/tests/ui/print_mode.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for ui/print/index.ts — PrintMode event handling and classifyError. + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { PrintMode, classifyError, type OutputFormat } from "../../src/kimi_cli_ts/ui/print/index"; +import type { WireUIEvent } from "../../src/kimi_cli_ts/ui/shell/events"; + +// ── Capture stdout/stderr ─────────────────────────────── + +let stdoutChunks: string[]; +let stderrChunks: string[]; +let origStdoutWrite: typeof process.stdout.write; +let origStderrWrite: typeof process.stderr.write; + +beforeEach(() => { + stdoutChunks = []; + stderrChunks = []; + origStdoutWrite = process.stdout.write; + origStderrWrite = process.stderr.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdoutChunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; +}); + +afterEach(() => { + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; +}); + +// ── PrintMode text format ─────────────────────────────── + +describe("PrintMode text format", () => { + test("text_delta prints directly in streaming mode", () => { + const pm = new PrintMode({ outputFormat: "text", finalOnly: false }); + pm.handleEvent({ type: "text_delta", text: "hello" }); + expect(stdoutChunks.join("")).toBe("hello"); + }); + + test("turn_end adds newline in streaming text mode", () => { + const pm = new PrintMode({ outputFormat: "text", finalOnly: false }); + pm.handleEvent({ type: "text_delta", text: "hello" }); + pm.handleEvent({ type: "turn_end" }); + expect(stdoutChunks.join("")).toBe("hello\n"); + }); + + test("finalOnly buffers and prints on turn_end", () => { + const pm = new PrintMode({ outputFormat: "text", finalOnly: true }); + pm.handleEvent({ type: "text_delta", text: "part1" }); + pm.handleEvent({ type: "text_delta", text: "part2" }); + expect(stdoutChunks.join("")).toBe(""); // nothing yet + + pm.handleEvent({ type: "turn_end" }); + expect(stdoutChunks.join("")).toBe("part1part2\n"); + }); +}); + +// ── PrintMode stream-json format ──────────────────────── + +describe("PrintMode stream-json format", () => { + test("text_delta emits JSON line", () => { + const pm = new PrintMode({ outputFormat: "stream-json", finalOnly: false }); + pm.handleEvent({ type: "text_delta", text: "hi" }); + const parsed = JSON.parse(stdoutChunks[0]); + expect(parsed.type).toBe("text_delta"); + expect(parsed.text).toBe("hi"); + }); + + test("tool_call emits JSON in stream-json mode", () => { + const pm = new PrintMode({ outputFormat: "stream-json", finalOnly: false }); + pm.handleEvent({ + type: "tool_call", + id: "tc-1", + name: "ReadFile", + arguments: '{"path":"f.ts"}', + }); + const parsed = JSON.parse(stdoutChunks[0]); + expect(parsed.type).toBe("tool_call"); + expect(parsed.name).toBe("ReadFile"); + }); + + test("finalOnly stream-json emits final_text on turn_end", () => { + const pm = new PrintMode({ outputFormat: "stream-json", finalOnly: true }); + pm.handleEvent({ type: "text_delta", text: "abc" }); + pm.handleEvent({ type: "turn_end" }); + const parsed = JSON.parse(stdoutChunks[0]); + expect(parsed.type).toBe("final_text"); + expect(parsed.text).toBe("abc"); + }); +}); + +// ── Error handling ────────────────────────────────────── + +describe("PrintMode error handling", () => { + test("error events write to stderr", () => { + const pm = new PrintMode({ outputFormat: "text", finalOnly: false }); + pm.handleEvent({ type: "error", message: "something broke" }); + expect(stderrChunks.join("")).toContain("something broke"); + }); +}); + +// ── Ignored events ────────────────────────────────────── + +describe("PrintMode ignored events", () => { + test("step_begin is silently ignored", () => { + const pm = new PrintMode({ outputFormat: "text", finalOnly: false }); + pm.handleEvent({ type: "step_begin", n: 1 }); + expect(stdoutChunks).toHaveLength(0); + expect(stderrChunks).toHaveLength(0); + }); +}); + +// ── classifyError ─────────────────────────────────────── + +describe("classifyError", () => { + test("429 is retryable", () => { + expect(classifyError(new Error("HTTP 429 rate limit"))).toBe("retryable"); + }); + + test("timeout is retryable", () => { + expect(classifyError(new Error("Request timeout"))).toBe("retryable"); + }); + + test("connection error is retryable", () => { + expect(classifyError(new Error("Connection refused"))).toBe("retryable"); + }); + + test("500/502/503/504 are retryable", () => { + for (const code of ["500", "502", "503", "504"]) { + expect(classifyError(new Error(`HTTP ${code}`))).toBe("retryable"); + } + }); + + test("auth error is permanent", () => { + expect(classifyError(new Error("Unauthorized 401"))).toBe("permanent"); + }); + + test("non-Error is unknown", () => { + expect(classifyError("string error")).toBe("unknown"); + expect(classifyError(42)).toBe("unknown"); + }); +}); diff --git a/tests/ui/slash_commands.test.ts b/tests/ui/slash_commands.test.ts new file mode 100644 index 000000000..c5228b75f --- /dev/null +++ b/tests/ui/slash_commands.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for ui/shell/slash.ts — slash command creation, parsing, finding. + * Corresponds to Python tests/ui_and_conv/test_shell_slash_commands.py + */ + +import { test, expect, describe } from "bun:test"; +import { + createShellSlashCommands, + parseSlashCommand, + findSlashCommand, + type ShellSlashContext, +} from "../../src/kimi_cli_ts/ui/shell/slash"; + +// ── Helper: mock context ──────────────────────────────── + +function makeMockCtx(): ShellSlashContext { + return { + clearMessages: () => {}, + exit: () => {}, + setTheme: () => {}, + }; +} + +// ── createShellSlashCommands ──────────────────────────── + +describe("createShellSlashCommands", () => { + test("returns a non-empty list of commands", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + expect(cmds.length).toBeGreaterThan(0); + }); + + test("includes expected command names", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + const names = cmds.map((c) => c.name); + expect(names).toContain("clear"); + expect(names).toContain("exit"); + expect(names).toContain("help"); + expect(names).toContain("theme"); + expect(names).toContain("version"); + }); + + test("each command has a handler function", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + for (const cmd of cmds) { + expect(typeof cmd.handler).toBe("function"); + } + }); + + test("exit command has aliases", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + const exitCmd = cmds.find((c) => c.name === "exit"); + expect(exitCmd).toBeDefined(); + expect(exitCmd!.aliases).toContain("quit"); + expect(exitCmd!.aliases).toContain("q"); + }); + + test("clear command has cls alias", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + const clearCmd = cmds.find((c) => c.name === "clear"); + expect(clearCmd).toBeDefined(); + expect(clearCmd!.aliases).toContain("cls"); + }); +}); + +// ── parseSlashCommand ─────────────────────────────────── + +describe("parseSlashCommand", () => { + test("returns null for non-slash input", () => { + expect(parseSlashCommand("hello")).toBeNull(); + expect(parseSlashCommand("")).toBeNull(); + }); + + test("returns null for bare slash", () => { + expect(parseSlashCommand("/")).toBeNull(); + expect(parseSlashCommand("/ ")).toBeNull(); + }); + + test("parses command without args", () => { + const result = parseSlashCommand("/help"); + expect(result).toEqual({ name: "help", args: "" }); + }); + + test("parses command with args", () => { + const result = parseSlashCommand("/theme dark"); + expect(result).toEqual({ name: "theme", args: "dark" }); + }); + + test("trims args", () => { + const result = parseSlashCommand("/theme light "); + expect(result).toEqual({ name: "theme", args: "light" }); + }); +}); + +// ── findSlashCommand ──────────────────────────────────── + +describe("findSlashCommand", () => { + const cmds = createShellSlashCommands(makeMockCtx()); + + test("finds by exact name", () => { + const found = findSlashCommand(cmds, "exit"); + expect(found).toBeDefined(); + expect(found!.name).toBe("exit"); + }); + + test("finds by alias", () => { + const found = findSlashCommand(cmds, "q"); + expect(found).toBeDefined(); + expect(found!.name).toBe("exit"); + }); + + test("returns undefined for unknown command", () => { + const found = findSlashCommand(cmds, "nonexistent"); + expect(found).toBeUndefined(); + }); + + test("finds help by ? alias", () => { + const found = findSlashCommand(cmds, "?"); + expect(found).toBeDefined(); + expect(found!.name).toBe("help"); + }); +}); diff --git a/tests/ui/theme.test.ts b/tests/ui/theme.test.ts new file mode 100644 index 000000000..c73c4efe5 --- /dev/null +++ b/tests/ui/theme.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for ui/theme.ts — theme switching, color getters. + * Corresponds to Python tests/ui_and_conv/test_theme.py + */ + +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { + setActiveTheme, + getActiveTheme, + getDiffColors, + getToolbarColors, + getMcpPromptColors, + getMessageColors, + getStyles, + type ThemeName, +} from "../../src/kimi_cli_ts/ui/theme"; + +// Reset theme around every test +beforeEach(() => setActiveTheme("dark")); +afterEach(() => setActiveTheme("dark")); + +// ── set / get ─────────────────────────────────────────── + +describe("setActiveTheme / getActiveTheme", () => { + test("defaults to dark", () => { + expect(getActiveTheme()).toBe("dark"); + }); + + test("can switch to light and back", () => { + setActiveTheme("light"); + expect(getActiveTheme()).toBe("light"); + setActiveTheme("dark"); + expect(getActiveTheme()).toBe("dark"); + }); +}); + +// ── Diff colors ───────────────────────────────────────── + +describe("getDiffColors", () => { + test("dark theme returns dark add background", () => { + const colors = getDiffColors(); + expect(colors.addBg).toBe("#12261e"); + }); + + test("light theme returns light add background", () => { + setActiveTheme("light"); + const colors = getDiffColors(); + expect(colors.addBg).toBe("#dafbe1"); + }); +}); + +// ── All getters respond to theme switch ───────────────── + +describe("color getters respond to theme switch", () => { + test("diff colors differ between themes", () => { + const darkDiff = getDiffColors(); + setActiveTheme("light"); + const lightDiff = getDiffColors(); + expect(darkDiff.addBg).not.toBe(lightDiff.addBg); + }); + + test("toolbar colors differ between themes", () => { + const darkToolbar = getToolbarColors(); + setActiveTheme("light"); + const lightToolbar = getToolbarColors(); + expect(darkToolbar.separator).not.toBe(lightToolbar.separator); + }); + + test("mcp prompt colors differ between themes", () => { + const darkMcp = getMcpPromptColors(); + setActiveTheme("light"); + const lightMcp = getMcpPromptColors(); + expect(darkMcp.text).not.toBe(lightMcp.text); + }); + + test("message colors differ between themes", () => { + const darkMsg = getMessageColors(); + setActiveTheme("light"); + const lightMsg = getMessageColors(); + expect(darkMsg.user).not.toBe(lightMsg.user); + }); +}); + +// ── getStyles ─────────────────────────────────────────── + +describe("getStyles", () => { + test("returns ThemeStyles with expected keys", () => { + const styles = getStyles(); + expect(styles.user).toBeDefined(); + expect(styles.assistant).toBeDefined(); + expect(styles.system).toBeDefined(); + expect(styles.tool).toBeDefined(); + expect(styles.error).toBeDefined(); + expect(styles.dim).toBeDefined(); + expect(styles.thinking).toBeDefined(); + expect(styles.highlight).toBeDefined(); + expect(styles.bold).toBeDefined(); + expect(styles.italic).toBeDefined(); + }); + + test("styles are callable (ChalkInstance)", () => { + const styles = getStyles(); + expect(typeof styles.user).toBe("function"); + const result = styles.user("hello"); + expect(typeof result).toBe("string"); + }); +}); diff --git a/tests/utils/async.test.ts b/tests/utils/async.test.ts new file mode 100644 index 000000000..211273d58 --- /dev/null +++ b/tests/utils/async.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for utils/async.ts — async utilities. + */ +import { test, expect, describe } from "bun:test"; +import { + sleep, + withTimeout, + TimeoutError, + Deferred, + mapConcurrent, +} from "../../src/kimi_cli_ts/utils/async.ts"; + +describe("sleep", () => { + test("resolves after delay", async () => { + const t0 = performance.now(); + await sleep(50); + const elapsed = performance.now() - t0; + expect(elapsed).toBeGreaterThanOrEqual(40); // allow slight imprecision + }); +}); + +describe("withTimeout", () => { + test("resolves when function completes within timeout", async () => { + const result = await withTimeout(async () => 42, 1000); + expect(result).toBe(42); + }); + + test("rejects with TimeoutError when exceeding timeout", async () => { + await expect( + withTimeout(() => sleep(500).then(() => 42), 50), + ).rejects.toThrow(TimeoutError); + }); + + test("TimeoutError has correct name", () => { + const err = new TimeoutError("test"); + expect(err.name).toBe("TimeoutError"); + expect(err.message).toBe("test"); + }); +}); + +describe("Deferred", () => { + test("resolves externally", async () => { + const d = new Deferred(); + expect(d.settled).toBe(false); + d.resolve(42); + const value = await d.promise; + expect(value).toBe(42); + expect(d.settled).toBe(true); + }); + + test("rejects externally", async () => { + const d = new Deferred(); + d.reject(new Error("fail")); + await expect(d.promise).rejects.toThrow("fail"); + expect(d.settled).toBe(true); + }); + + test("double resolve is ignored", async () => { + const d = new Deferred(); + d.resolve(1); + d.resolve(2); // should be ignored + const value = await d.promise; + expect(value).toBe(1); + }); + + test("double reject is ignored", async () => { + const d = new Deferred(); + d.reject(new Error("first")); + d.reject(new Error("second")); // should be ignored + await expect(d.promise).rejects.toThrow("first"); + }); + + test("resolve after reject is ignored", async () => { + const d = new Deferred(); + d.reject(new Error("fail")); + d.resolve(42); // should be ignored + await expect(d.promise).rejects.toThrow("fail"); + }); +}); + +describe("mapConcurrent", () => { + test("processes all items", async () => { + const items = [1, 2, 3, 4, 5]; + const results = await mapConcurrent(items, 2, async (x) => x * 2); + expect(results).toEqual([2, 4, 6, 8, 10]); + }); + + test("respects concurrency limit", async () => { + let active = 0; + let maxActive = 0; + const items = [1, 2, 3, 4, 5]; + + await mapConcurrent(items, 2, async (x) => { + active++; + maxActive = Math.max(maxActive, active); + await sleep(10); + active--; + return x; + }); + + expect(maxActive).toBeLessThanOrEqual(2); + }); + + test("empty items returns empty array", async () => { + const results = await mapConcurrent([], 4, async (x) => x); + expect(results).toEqual([]); + }); + + test("single item works", async () => { + const results = await mapConcurrent([42], 1, async (x) => x * 2); + expect(results).toEqual([84]); + }); +}); diff --git a/tests/utils/environment.test.ts b/tests/utils/environment.test.ts new file mode 100644 index 000000000..573beab4b --- /dev/null +++ b/tests/utils/environment.test.ts @@ -0,0 +1,41 @@ +/** + * Tests for utils/environment.ts — environment detection. + */ +import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; + +describe("detectEnvironment", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + Object.defineProperty(process, "arch", { value: originalArch }); + delete process.env.SYSTEMROOT; + }); + + test("detects non-Windows environment", async () => { + // Skip on Windows + if (process.platform === "win32") return; + + const { detectEnvironment } = await import( + "../../src/kimi_cli_ts/utils/environment.ts" + ); + const env = await detectEnvironment(); + + expect(["macOS", "Linux"]).toContain(env.osKind); + expect(env.osArch).toBeTruthy(); + expect(env.shellName).toMatch(/bash|sh/); + expect(env.shellPath).toBeTruthy(); + }); + + test("Windows detection with valid path", async () => { + // Skip on Windows (mocking is for non-Windows hosts) + if (process.platform === "win32") return; + + // We can't easily mock process.platform + existsSync together in Bun, + // so we test the logic indirectly by verifying the module structure. + const mod = await import("../../src/kimi_cli_ts/utils/environment.ts"); + expect(typeof mod.detectEnvironment).toBe("function"); + }); +}); diff --git a/tests/utils/logging.test.ts b/tests/utils/logging.test.ts new file mode 100644 index 000000000..9e8ea11e4 --- /dev/null +++ b/tests/utils/logging.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for utils/logging.ts — logger. + */ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { logger } from "../../src/kimi_cli_ts/utils/logging.ts"; + +describe("Logger", () => { + // Capture stderr writes + const originalWrite = process.stderr.write; + let stderrOutput: string[]; + + beforeEach(() => { + stderrOutput = []; + process.stderr.write = ((data: string | Uint8Array) => { + stderrOutput.push(typeof data === "string" ? data : new TextDecoder().decode(data)); + return true; + }) as any; + }); + + afterEach(() => { + process.stderr.write = originalWrite; + // Reset to info + logger.setLevel("info"); + }); + + test("info level logs info, warn, error but not debug", () => { + logger.setLevel("info"); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + const output = stderrOutput.join(""); + expect(output).not.toContain("[DEBUG]"); + expect(output).toContain("[INFO]"); + expect(output).toContain("[WARN]"); + expect(output).toContain("[ERROR]"); + }); + + test("debug level logs everything", () => { + logger.setLevel("debug"); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + const output = stderrOutput.join(""); + expect(output).toContain("[DEBUG]"); + expect(output).toContain("[INFO]"); + expect(output).toContain("[WARN]"); + expect(output).toContain("[ERROR]"); + }); + + test("error level only logs errors", () => { + logger.setLevel("error"); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + const output = stderrOutput.join(""); + expect(output).not.toContain("[DEBUG]"); + expect(output).not.toContain("[INFO]"); + expect(output).not.toContain("[WARN]"); + expect(output).toContain("[ERROR]"); + }); + + test("warn level logs warn and error", () => { + logger.setLevel("warn"); + logger.debug("dbg"); + logger.info("inf"); + logger.warn("wrn"); + logger.error("err"); + + const output = stderrOutput.join(""); + expect(output).not.toContain("[DEBUG]"); + expect(output).not.toContain("[INFO]"); + expect(output).toContain("[WARN]"); + expect(output).toContain("[ERROR]"); + }); + + test("log messages have level prefix", () => { + logger.setLevel("debug"); + logger.debug("test message"); + expect(stderrOutput.join("")).toContain("[DEBUG] test message"); + + stderrOutput = []; + logger.info("test message"); + expect(stderrOutput.join("")).toContain("[INFO] test message"); + }); +}); diff --git a/tests/utils/path.test.ts b/tests/utils/path.test.ts new file mode 100644 index 000000000..06b76a8a9 --- /dev/null +++ b/tests/utils/path.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for utils/path.ts — path utilities. + */ +import { test, expect, describe } from "bun:test"; +import { homedir } from "node:os"; +import { resolve, join } from "node:path"; +import { + expandHome, + resolvePath, + shortPath, + isInsideDir, +} from "../../src/kimi_cli_ts/utils/path.ts"; + +describe("expandHome", () => { + test("expands ~ to home directory", () => { + const result = expandHome("~/Documents"); + expect(result).toBe(join(homedir(), "Documents")); + }); + + test("expands lone ~", () => { + const result = expandHome("~"); + expect(result).toBe(homedir()); + }); + + test("does not expand paths without ~", () => { + const result = expandHome("/usr/local"); + expect(result).toBe("/usr/local"); + }); + + test("does not expand ~ in the middle", () => { + const result = expandHome("/home/~user"); + expect(result).toBe("/home/~user"); + }); +}); + +describe("resolvePath", () => { + test("resolves relative path from base", () => { + const result = resolvePath("/base", "sub/file.txt"); + expect(result).toBe(resolve("/base", "sub/file.txt")); + }); + + test("resolves absolute path ignoring base", () => { + const result = resolvePath("/base", "/absolute/path"); + expect(result).toBe("/absolute/path"); + }); + + test("resolves ~ path", () => { + const result = resolvePath("/base", "~/file.txt"); + expect(result).toBe(resolve(join(homedir(), "file.txt"))); + }); +}); + +describe("shortPath", () => { + test("returns relative path when shorter", () => { + const base = "/Users/test/projects/myapp"; + const p = "/Users/test/projects/myapp/src/index.ts"; + const result = shortPath(base, p); + expect(result).toBe("src/index.ts"); + }); + + test("returns absolute path when relative is longer", () => { + const base = "/a"; + const p = "/b"; + const result = shortPath(base, p); + // "../b" is longer than "/b" + expect(result).toBe("/b"); + }); +}); + +describe("isInsideDir", () => { + test("file inside directory", () => { + expect(isInsideDir("/home/user", "/home/user/file.txt")).toBe(true); + }); + + test("deeply nested file", () => { + expect(isInsideDir("/home/user", "/home/user/a/b/c.txt")).toBe(true); + }); + + test("directory itself counts as inside", () => { + expect(isInsideDir("/home/user", "/home/user")).toBe(true); + }); + + test("sibling directory is not inside", () => { + expect(isInsideDir("/home/user", "/home/other/file.txt")).toBe(false); + }); + + test("parent directory is not inside", () => { + expect(isInsideDir("/home/user/sub", "/home/user")).toBe(false); + }); + + test("path prefix that is not a directory boundary", () => { + // /home/username should NOT be inside /home/user + expect(isInsideDir("/home/user", "/home/username")).toBe(false); + }); +}); diff --git a/tests/utils/result_builder.test.ts b/tests/utils/result_builder.test.ts new file mode 100644 index 000000000..e5ce0eed3 --- /dev/null +++ b/tests/utils/result_builder.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for tools/types.ts — ToolResultBuilder and helpers. + */ +import { test, expect, describe } from "bun:test"; +import { + ToolResultBuilder, + ToolOk, + ToolError, +} from "../../src/kimi_cli_ts/tools/types.ts"; + +describe("ToolOk / ToolError", () => { + test("ToolOk creates successful result", () => { + const result = ToolOk("output text", "message"); + expect(result.isError).toBe(false); + expect(result.output).toBe("output text"); + expect(result.message).toBe("message"); + }); + + test("ToolError creates error result", () => { + const result = ToolError("something failed", "partial output"); + expect(result.isError).toBe(true); + expect(result.output).toBe("partial output"); + expect(result.message).toBe("something failed"); + }); + + test("ToolOk with display blocks", () => { + const result = ToolOk("out", "msg", [{ type: "brief", brief: "hi" }]); + expect(result.display).toEqual([{ type: "brief", brief: "hi" }]); + }); + + test("ToolOk with extras", () => { + const result = ToolOk("out", undefined, undefined, { key: "value" }); + expect(result.extras).toEqual({ key: "value" }); + }); +}); + +describe("ToolResultBuilder", () => { + test("write adds text to buffer", () => { + const b = new ToolResultBuilder(); + b.write("hello\n"); + b.write("world\n"); + const result = b.ok(); + expect(result.output).toBe("hello\nworld\n"); + expect(result.isError).toBe(false); + expect(b.nLines).toBe(2); + expect(b.nChars).toBe(12); + }); + + test("write truncates at maxChars", () => { + const b = new ToolResultBuilder(10); + b.write("12345\n"); + b.write("67890\n"); + b.write("abcde\n"); // should not fit + expect(b.isFull).toBe(true); + const result = b.ok(); + // First line (6 chars) fits, second line gets truncated marker + newline + expect(result.output).toBe("12345\n[...truncated]\n"); + expect(result.message).toContain("truncated"); + }); + + test("write truncates long lines", () => { + const b = new ToolResultBuilder(50000, 10); + b.write("a very long line that exceeds the max line length\n"); + const result = b.ok(); + // Line gets replaced by truncation marker [...truncated] which is longer than maxLineLength + // The marker itself is the minimum output + expect(result.output).toBe("[...truncated]\n"); + expect(result.message).toContain("truncated"); + }); + + test("write returns 0 when full", () => { + const b = new ToolResultBuilder(5); + b.write("12345"); + expect(b.isFull).toBe(true); + const written = b.write("more"); + expect(written).toBe(0); + }); + + test("ok message gets period appended", () => { + const b = new ToolResultBuilder(); + b.write("content"); + const result = b.ok("Done"); + expect(result.message).toBe("Done."); + }); + + test("ok message with period is not doubled", () => { + const b = new ToolResultBuilder(); + b.write("content"); + const result = b.ok("Done."); + expect(result.message).toBe("Done."); + }); + + test("ok with empty message and no truncation returns undefined message", () => { + const b = new ToolResultBuilder(); + b.write("content"); + const result = b.ok(); + expect(result.message).toBeUndefined(); + }); + + test("ok with truncation adds truncation note", () => { + const b = new ToolResultBuilder(5); + b.write("1234567890"); + const result = b.ok(); + expect(result.message).toContain("truncated"); + }); + + test("error includes message", () => { + const b = new ToolResultBuilder(); + b.write("error output"); + const result = b.error("Something went wrong"); + expect(result.isError).toBe(true); + expect(result.message).toBe("Something went wrong"); + expect(result.output).toBe("error output"); + }); + + test("error with truncation appends truncation note", () => { + const b = new ToolResultBuilder(5); + b.write("1234567890"); + const result = b.error("Error"); + expect(result.message).toContain("truncated"); + expect(result.message).toContain("Error"); + }); + + test("display blocks are passed through", () => { + const b = new ToolResultBuilder(); + b.display({ type: "brief", brief: "info" }); + const result = b.ok(); + expect(result.display).toEqual([{ type: "brief", brief: "info" }]); + }); + + test("extras are passed through", () => { + const b = new ToolResultBuilder(); + b.extras({ count: 42 }); + const result = b.ok(); + expect(result.extras).toEqual({ count: 42 }); + }); + + test("extras merge multiple calls", () => { + const b = new ToolResultBuilder(); + b.extras({ a: 1 }); + b.extras({ b: 2 }); + const result = b.ok(); + expect(result.extras).toEqual({ a: 1, b: 2 }); + }); + + test("no display or extras returns undefined", () => { + const b = new ToolResultBuilder(); + b.write("content"); + const result = b.ok(); + expect(result.display).toBeUndefined(); + expect(result.extras).toBeUndefined(); + }); + + test("maxLineLength null disables line truncation", () => { + const b = new ToolResultBuilder(50000, null); + const longLine = "a".repeat(5000) + "\n"; + b.write(longLine); + const result = b.ok(); + expect(result.output.length).toBe(5001); // 5000 chars + newline + }); +}); diff --git a/tests/utils/sensitive.test.ts b/tests/utils/sensitive.test.ts new file mode 100644 index 000000000..cc1dd320c --- /dev/null +++ b/tests/utils/sensitive.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for the sensitive file detection module. + * Corresponds to Python tests/utils/test_sensitive.py + */ + +import { describe, test, expect } from "bun:test"; +import { isSensitiveFile, sensitiveFileWarning } from "../../src/kimi_cli_ts/utils/sensitive.ts"; + +describe("isSensitiveFile", () => { + test.each([".env", "/app/.env", "project/.env"])( + "detects .env files: %s", + (path) => { + expect(isSensitiveFile(path)).toBe(true); + }, + ); + + test.each([".env.local", ".env.production", "/app/.env.staging"])( + "detects .env variants: %s", + (path) => { + expect(isSensitiveFile(path)).toBe(true); + }, + ); + + test.each([ + "id_rsa", + "id_ed25519", + "id_ecdsa", + "/home/user/.ssh/id_rsa", + "/home/user/.ssh/id_ed25519", + ])("detects SSH keys: %s", (path) => { + expect(isSensitiveFile(path)).toBe(true); + }); + + test.each([ + "/home/user/.aws/credentials", + "/home/user/.gcp/credentials", + ".aws/credentials", + ".gcp/credentials", + "credentials", + ])("detects cloud credentials: %s", (path) => { + expect(isSensitiveFile(path)).toBe(true); + }); + + test.each([ + "app.py", + "config.yml", + "README.md", + "package.json", + "server.key.example", + "id_rsa.pub", + "credentials.json", + ".envrc", + "environment.py", + ".env_example", + ".env.example", + ".env.sample", + ".env.template", + "/app/.env.example", + ])("allows normal files: %s", (path) => { + expect(isSensitiveFile(path)).toBe(false); + }); +}); + +describe("sensitiveFileWarning", () => { + test("single file warning", () => { + const warning = sensitiveFileWarning([".env"]); + expect(warning).toContain("1 sensitive file(s)"); + expect(warning).toContain(".env"); + expect(warning).toContain("protect secrets"); + }); + + test("multiple file warning", () => { + const warning = sensitiveFileWarning([".env", ".env.local", "id_rsa"]); + expect(warning).toContain("3 sensitive file(s)"); + expect(warning).toContain(".env"); + expect(warning).toContain("id_rsa"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..a6789c5cd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} From 441cc0966acb16c03020a4cd2e2b999c9a5e3794 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Fri, 3 Apr 2026 22:35:39 +0800 Subject: [PATCH 02/28] feat(ts): persist input history to disk matching Python implementation Store input history per-working-directory in ~/.kimi/user-history/{md5(cwd)}.jsonl, enabling up-arrow recall across sessions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/ui/hooks/useInput.ts | 89 +++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/src/kimi_cli_ts/ui/hooks/useInput.ts b/src/kimi_cli_ts/ui/hooks/useInput.ts index fc4c0623a..d0e37269c 100644 --- a/src/kimi_cli_ts/ui/hooks/useInput.ts +++ b/src/kimi_cli_ts/ui/hooks/useInput.ts @@ -1,11 +1,75 @@ /** * useInputHistory hook — manages input history and slash command parsing. * Corresponds to history logic in Python's prompt.py. + * + * History is persisted per-working-directory to ~/.kimi/user-history/{md5(cwd)}.jsonl + * matching the Python implementation's behaviour. */ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { getShareDir } from "../../config.ts"; import type { SlashCommand } from "../../types"; +// ── Persistent history helpers ───────────────────────────── + +interface HistoryEntry { + content: string; +} + +function getHistoryDir(): string { + return join(getShareDir(), "user-history"); +} + +function getHistoryFile(): string { + const cwd = process.cwd(); + const hash = createHash("md5").update(cwd, "utf-8").digest("hex"); + return join(getHistoryDir(), `${hash}.jsonl`); +} + +/** Load history entries from the JSONL file (sync, called once on mount). */ +function loadHistoryEntries(filePath: string): string[] { + try { + const file = Bun.file(filePath); + // Bun.file doesn't have a sync exists check, use node:fs + const fs = require("node:fs"); + if (!fs.existsSync(filePath)) return []; + const text = fs.readFileSync(filePath, "utf-8") as string; + const entries: string[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as HistoryEntry; + if (parsed.content) { + entries.push(parsed.content); + } + } catch { + // Skip malformed lines + } + } + return entries; + } catch { + return []; + } +} + +/** Append a single history entry to the JSONL file. */ +function appendHistoryEntry(filePath: string, content: string): void { + try { + const fs = require("node:fs"); + const dir = getHistoryDir(); + fs.mkdirSync(dir, { recursive: true }); + const entry: HistoryEntry = { content }; + fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8"); + } catch { + // Silently ignore write failures (matches Python behaviour) + } +} + +// ── Hook ─────────────────────────────────────────────────── + export interface InputHistoryState { /** Current input value */ value: string; @@ -31,22 +95,41 @@ export function useInputHistory(maxHistory = 100): InputHistoryState { const history = useRef([]); const historyIndex = useRef(-1); const savedInput = useRef(""); + const historyFile = useRef(getHistoryFile()); + const lastHistoryContent = useRef(""); + const initialized = useRef(false); + + // Load persisted history on first mount + useEffect(() => { + if (initialized.current) return; + initialized.current = true; + const entries = loadHistoryEntries(historyFile.current); + history.current = entries; + if (entries.length > 0) { + lastHistoryContent.current = entries[entries.length - 1]!; + } + }, []); const addToHistory = useCallback( (entry: string) => { const trimmed = entry.trim(); if (!trimmed) return; - // Deduplicate: remove if already exists at end + // Deduplicate consecutive entries if ( history.current.length > 0 && history.current[history.current.length - 1] === trimmed ) { - // Already the last entry + // Already the last entry — skip } else { history.current.push(trimmed); if (history.current.length > maxHistory) { history.current.shift(); } + // Persist to disk (only if different from last persisted) + if (trimmed !== lastHistoryContent.current) { + appendHistoryEntry(historyFile.current, trimmed); + lastHistoryContent.current = trimmed; + } } historyIndex.current = -1; savedInput.current = ""; From 0608a110296a1033f6cdfd8250be4c9f1589121e Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Fri, 3 Apr 2026 22:53:12 +0800 Subject: [PATCH 03/28] Implement notification toast stack to separate UI notifications from conversation Replace direct message stream notification handling with a dedicated transient toast notification system. This keeps the conversation history clean and matches standard UX patterns (Discord, Slack, GitHub). Changes: - Create NotificationStack.tsx component with Toast type - Add severity-based coloring (info/warning/error) - Implement auto-dismiss timers (4s default, 6s for errors) - Update useWire.ts to manage notifications separately - Modify Shell.tsx layout to render notification stack above input - Add dismissNotification callback for manual dismissal Benefits: - Notifications no longer pollute the message stream - Auto-dismiss reduces visual noise - Color-coded severity indicates importance - Supports both retryable and non-retryable errors - Familiar UX pattern for users Closes: Notification handling in message stream Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/components/NotificationStack.tsx | 75 +++++++++++++++++++ src/kimi_cli_ts/ui/hooks/useWire.ts | 36 ++++++--- src/kimi_cli_ts/ui/shell/Shell.tsx | 9 ++- 3 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 src/kimi_cli_ts/ui/components/NotificationStack.tsx diff --git a/src/kimi_cli_ts/ui/components/NotificationStack.tsx b/src/kimi_cli_ts/ui/components/NotificationStack.tsx new file mode 100644 index 000000000..634edfaf5 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/NotificationStack.tsx @@ -0,0 +1,75 @@ +/** + * NotificationStack.tsx — Transient toast notifications + * Auto-dismisses after a configurable duration (default 4s). + * Renders above the input area. + */ + +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; + +export interface Toast { + id: string; + title: string; + body: string; + severity?: "info" | "warning" | "error"; + duration?: number; // milliseconds, 0 = no auto-dismiss +} + +interface NotificationStackProps { + toasts: Toast[]; + onDismiss: (id: string) => void; +} + +const COLORS: Record = { + info: "#4a90e2", // Blue + warning: "#f5a623", // Orange + error: "#d0021b", // Red +}; + +export function NotificationStack({ toasts, onDismiss }: NotificationStackProps) { + if (toasts.length === 0) { + return null; + } + + return ( + + {toasts.map((toast) => ( + onDismiss(toast.id)} + /> + ))} + + ); +} + +interface ToastNotificationProps { + toast: Toast; + onDismiss: () => void; +} + +function ToastNotification({ toast, onDismiss }: ToastNotificationProps) { + const severity = toast.severity || "info"; + const color = COLORS[severity]; + const duration = toast.duration ?? 4000; + + // Auto-dismiss after duration + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(onDismiss, duration); + return () => clearTimeout(timer); + } + }, [duration, onDismiss]); + + const fullMessage = toast.title + (toast.body ? `: ${toast.body}` : ""); + + return ( + + + {`[${severity.toUpperCase()}] `} + + {fullMessage} + + ); +} diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts index 74147c719..68aed5d5f 100644 --- a/src/kimi_cli_ts/ui/hooks/useWire.ts +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -12,6 +12,7 @@ import type { ToolCallSegment, } from "../shell/events"; import type { StatusUpdate, ApprovalRequest } from "../../wire/types"; +import type { Toast } from "../components/NotificationStack"; import { nanoid } from "nanoid"; export interface WireState { @@ -21,6 +22,7 @@ export interface WireState { status: StatusUpdate | null; stepCount: number; isCompacting: boolean; + notifications: Toast[]; } export interface UseWireOptions { @@ -34,6 +36,7 @@ export interface UseWireOptions { export function useWire(options?: UseWireOptions): WireState & { pushEvent: (event: WireUIEvent) => void; clearMessages: () => void; + dismissNotification: (id: string) => void; } { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); @@ -42,6 +45,7 @@ export function useWire(options?: UseWireOptions): WireState & { const [status, setStatus] = useState(null); const [stepCount, setStepCount] = useState(0); const [isCompacting, setIsCompacting] = useState(false); + const [notifications, setNotifications] = useState([]); // Use ref for current assistant message being built const currentAssistantRef = useRef(null); @@ -185,26 +189,28 @@ export function useWire(options?: UseWireOptions): WireState & { } case "notification": { - const sysMsg: UIMessage = { + // Add to notification stack instead of message stream + const toast: Toast = { id: nanoid(), - role: "system", - segments: [ - { type: "text", text: `${event.title}: ${event.body}` }, - ], - timestamp: Date.now(), + title: event.title, + body: event.body, + severity: "info", + duration: 4000, // auto-dismiss after 4 seconds }; - setMessages((prev) => [...prev, sysMsg]); + setNotifications((prev) => [...prev, toast]); break; } case "error": { - const errMsg: UIMessage = { + // Errors are also shown as notifications with longer duration + const toast: Toast = { id: nanoid(), - role: "system", - segments: [{ type: "text", text: `Error: ${event.message}` }], - timestamp: Date.now(), + title: "Error", + body: event.message, + severity: "error", + duration: event.retryable ? 0 : 6000, // retryable errors don't auto-dismiss }; - setMessages((prev) => [...prev, errMsg]); + setNotifications((prev) => [...prev, toast]); setIsStreaming(false); break; } @@ -219,6 +225,10 @@ export function useWire(options?: UseWireOptions): WireState & { setStepCount(0); }, []); + const dismissNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + // Notify caller that pushEvent is ready const onReady = options?.onReady; useEffect(() => { @@ -232,7 +242,9 @@ export function useWire(options?: UseWireOptions): WireState & { status, stepCount, isCompacting, + notifications, pushEvent, clearMessages, + dismissNotification, }; } diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 82b78d08c..4e8e43215 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -20,6 +20,7 @@ import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; import { ApprovalPrompt } from "../components/ApprovalPrompt.tsx"; import { CommandPanel } from "../components/CommandPanel.tsx"; +import { NotificationStack } from "../components/NotificationStack.tsx"; import { StreamingSpinner, CompactionSpinner } from "../components/Spinner.tsx"; import { useWire } from "../hooks/useWire.ts"; import { useKeyboard } from "./keyboard.ts"; @@ -93,7 +94,7 @@ export function Shell({ // Wire state const wire = useWire({ onReady: onWireReady }); - // Helper to push notifications to chat area + // Helper to push notifications to notification stack const pushNotification = useCallback( (title: string, body: string) => { wire.pushEvent({ type: "notification", title, body }); @@ -269,6 +270,12 @@ export function Shell({ )} + {/* ═══ Notification Stack: transient toasts ═══ */} + + {/* ═══ InputBox: fills remaining, min 6 lines, text at top ═══ */} Date: Fri, 3 Apr 2026 23:29:48 +0800 Subject: [PATCH 04/28] feat(ui): enhance StatusBar with git info, tips rotation, and inline toasts Merge notification toasts into the status bar, add git branch/dirty/ahead/behind display, rotating tips, context token detail, plan mode prompt icon, and remove standalone NotificationStack from Shell. Co-Authored-By: Claude Opus 4.6 (1M context) --- index.ts | 1 - .../ui/components/NotificationStack.tsx | 3 + src/kimi_cli_ts/ui/components/StatusBar.tsx | 151 +++++++++++++++--- src/kimi_cli_ts/ui/hooks/useGitStatus.ts | 101 ++++++++++++ src/kimi_cli_ts/ui/hooks/useWire.ts | 17 +- src/kimi_cli_ts/ui/shell/Prompt.tsx | 4 +- src/kimi_cli_ts/ui/shell/Shell.tsx | 44 ++--- 7 files changed, 274 insertions(+), 47 deletions(-) delete mode 100644 index.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useGitStatus.ts diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c645..000000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/src/kimi_cli_ts/ui/components/NotificationStack.tsx b/src/kimi_cli_ts/ui/components/NotificationStack.tsx index 634edfaf5..b1342a7d0 100644 --- a/src/kimi_cli_ts/ui/components/NotificationStack.tsx +++ b/src/kimi_cli_ts/ui/components/NotificationStack.tsx @@ -13,6 +13,9 @@ export interface Toast { body: string; severity?: "info" | "warning" | "error"; duration?: number; // milliseconds, 0 = no auto-dismiss + position?: "left" | "right"; // toolbar position, default "left" + topic?: string; // for deduplication — new toast with same topic replaces old + createdAt: number; // timestamp for expiry tracking } interface NotificationStackProps { diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index cb742dd25..8202baa98 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -1,17 +1,29 @@ /** - * StatusBar component — bottom status bar. - * Matches Python's toolbar: separator line + single status line. + * StatusBar component — 3-line bottom toolbar matching Python's layout. * * Layout: - * ────────────────────────────────────────────────────── - * agent (kimi-k2.5 ●) ~/workdir main context: 0.0% + * ──────────────────────────────────────────────────────────────── + * [yolo] [plan] agent (model ●) ~/cwd main↑1 ⚙2 tip1 | tip2 + * [left toast] context: 45.2% (12k/200k) */ -import React from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Box, Text, useStdout } from "ink"; -import type { StatusUpdate } from "../../wire/types"; +import type { StatusUpdate } from "../../wire/types.ts"; +import type { Toast } from "./NotificationStack.tsx"; const DIM = "#888888"; +const TIP_ROTATE_MS = 30_000; + +const DEFAULT_TIPS = [ + "ctrl-x: toggle mode", + "shift-tab: plan mode", + "ctrl-o: editor", + "ctrl-j: newline", + "/feedback: send feedback", + "/theme: switch dark/light", + "@: mention files", +]; interface StatusBarProps { modelName?: string; @@ -23,6 +35,18 @@ interface StatusBarProps { planMode?: boolean; yolo?: boolean; thinking?: boolean; + // Git info + gitBranch?: string | null; + gitDirty?: boolean; + gitAhead?: number; + gitBehind?: number; + // Background tasks + bgTaskCount?: number; + // Toast notifications (embedded in line 2) + toasts?: Toast[]; + onDismissToast?: (id: string) => void; + // Tips (rotatable) + tips?: string[]; } export function StatusBar({ @@ -35,14 +59,53 @@ export function StatusBar({ planMode = false, yolo = false, thinking = false, + gitBranch, + gitDirty = false, + gitAhead = 0, + gitBehind = 0, + bgTaskCount = 0, + toasts = [], + onDismissToast, + tips = DEFAULT_TIPS, }: StatusBarProps) { const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; + // Tip rotation + const [tipIndex, setTipIndex] = useState(0); + useEffect(() => { + if (tips.length === 0) return; + const timer = setInterval(() => { + setTipIndex((i) => (i + 1) % tips.length); + }, TIP_ROTATE_MS); + return () => clearInterval(timer); + }, [tips.length]); + + // Auto-dismiss toasts + useEffect(() => { + if (toasts.length === 0 || !onDismissToast) return; + const timers: ReturnType[] = []; + for (const toast of toasts) { + const duration = toast.duration ?? 5000; + if (duration > 0) { + const elapsed = Date.now() - toast.createdAt; + const remaining = Math.max(0, duration - elapsed); + timers.push(setTimeout(() => onDismissToast(toast.id), remaining)); + } + } + return () => timers.forEach(clearTimeout); + }, [toasts, onDismissToast]); + // Context usage const contextUsage = status?.context_usage; const contextPercent = contextUsage != null ? (contextUsage * 100).toFixed(1) : "0.0"; + const contextTokens = (status as Record)?.context_tokens as number | undefined; + const maxContextTokens = (status as Record)?.max_context_tokens as number | undefined; + const contextDetail = + contextTokens != null && maxContextTokens != null + ? ` (${formatTokenCount(contextTokens)}/${formatTokenCount(maxContextTokens)})` + : ""; // Shorten workDir const home = process.env.HOME || process.env.USERPROFILE || ""; @@ -52,27 +115,53 @@ export function StatusBar({ : workDir : ""; - // Build left section: [yolo] [plan] agent (model ●) - const leftParts: string[] = []; - if (yolo) leftParts.push("yolo"); - if (planMode) leftParts.push("plan"); + // Git badge + let gitBadge = ""; + if (gitBranch) { + gitBadge = gitBranch; + const parts: string[] = []; + if (gitDirty) parts.push("*"); + if (gitAhead > 0) parts.push(`↑${gitAhead}`); + if (gitBehind > 0) parts.push(`↓${gitBehind}`); + if (parts.length > 0) gitBadge += parts.join(""); + } + // Build mode string const thinkingDot = thinking ? "●" : "○"; const modeStr = modelName ? `agent (${modelName} ${thinkingDot})` : "agent"; - leftParts.push(modeStr); - const leftText = leftParts.join(" "); - // Build right section - const rightText = `context: ${contextPercent}%`; + // Rotating tips (show 2 tips separated by |) + let tipText = ""; + if (tips.length > 0) { + const tip1 = tips[tipIndex % tips.length]!; + if (tips.length > 1) { + const tip2 = tips[(tipIndex + 1) % tips.length]!; + tipText = `${tip1} | ${tip2}`; + } else { + tipText = tip1; + } + } + + // Left toast (first unexpired toast with position=left) + const leftToast = toasts.find((t) => (t.position ?? "left") === "left"); + const leftToastText = leftToast + ? `${leftToast.title}${leftToast.body ? `: ${leftToast.body}` : ""}` + : ""; + + // Right side: context info + const rightText = `context: ${contextPercent}%${contextDetail}`; // Separator const separator = "─".repeat(columns); return ( + {/* Line 0: separator */} {separator} + + {/* Line 1: status indicators */} {yolo && ( @@ -81,21 +170,38 @@ export function StatusBar({
)} {planMode && ( - + plan )} {modeStr} - {displayDir && {displayDir}} + {displayDir && {truncate(displayDir, 30)}} + {gitBadge && {truncate(gitBadge, 22)}} + {bgTaskCount > 0 && ( + ⚙ bash: {bgTaskCount} + )} {isStreaming && ( step {stepCount} )} {isCompacting && compacting...} - - - shift-tab: plan mode | ctrl-o: editor - + + {tipText} + + + + {/* Line 2: left toast + right context */} + + + {leftToastText ? ( + + {truncate(leftToastText, Math.max(0, columns - rightText.length - 4))} + + ) : ( + + )} + + {rightText} @@ -108,3 +214,8 @@ function formatTokenCount(count: number): string { if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`; return `${(count / 1_000_000).toFixed(1)}M`; } + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 1) + "…"; +} diff --git a/src/kimi_cli_ts/ui/hooks/useGitStatus.ts b/src/kimi_cli_ts/ui/hooks/useGitStatus.ts new file mode 100644 index 000000000..fb3b53877 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useGitStatus.ts @@ -0,0 +1,101 @@ +/** + * useGitStatus hook — periodically fetches git branch and status info. + * Matches Python's _get_git_branch / _get_git_status in prompt.py. + * + * - Branch refreshes every 5s + * - Status (dirty/ahead/behind) refreshes every 15s + */ + +import { useState, useEffect, useRef } from "react"; + +const BRANCH_TTL_MS = 5_000; +const STATUS_TTL_MS = 15_000; + +export interface GitStatus { + branch: string | null; + dirty: boolean; + ahead: number; + behind: number; +} + +async function execQuiet(cmd: string[]): Promise { + try { + const proc = Bun.spawn(cmd, { + stdout: "pipe", + stderr: "ignore", + cwd: process.cwd(), + }); + const text = await new Response(proc.stdout).text(); + const code = await proc.exited; + return code === 0 ? text.trim() : ""; + } catch { + return ""; + } +} + +async function fetchBranch(): Promise { + const result = await execQuiet(["git", "rev-parse", "--abbrev-ref", "HEAD"]); + return result || null; +} + +async function fetchStatus(): Promise<{ dirty: boolean; ahead: number; behind: number }> { + // Porcelain status for dirty check + const porcelain = await execQuiet(["git", "status", "--porcelain", "-uno"]); + const dirty = porcelain.length > 0; + + // Ahead/behind from rev-list + let ahead = 0; + let behind = 0; + const upstream = await execQuiet(["git", "rev-parse", "--abbrev-ref", "@{u}"]); + if (upstream) { + const aheadStr = await execQuiet(["git", "rev-list", "--count", `${upstream}..HEAD`]); + const behindStr = await execQuiet(["git", "rev-list", "--count", `HEAD..${upstream}`]); + ahead = parseInt(aheadStr, 10) || 0; + behind = parseInt(behindStr, 10) || 0; + } + + return { dirty, ahead, behind }; +} + +export function useGitStatus(): GitStatus { + const [branch, setBranch] = useState(null); + const [dirty, setDirty] = useState(false); + const [ahead, setAhead] = useState(0); + const [behind, setBehind] = useState(0); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + // Initial fetch + fetchBranch().then((b) => { if (mountedRef.current) setBranch(b); }); + fetchStatus().then((s) => { + if (!mountedRef.current) return; + setDirty(s.dirty); + setAhead(s.ahead); + setBehind(s.behind); + }); + + // Periodic refresh + const branchTimer = setInterval(() => { + fetchBranch().then((b) => { if (mountedRef.current) setBranch(b); }); + }, BRANCH_TTL_MS); + + const statusTimer = setInterval(() => { + fetchStatus().then((s) => { + if (!mountedRef.current) return; + setDirty(s.dirty); + setAhead(s.ahead); + setBehind(s.behind); + }); + }, STATUS_TTL_MS); + + return () => { + mountedRef.current = false; + clearInterval(branchTimer); + clearInterval(statusTimer); + }; + }, []); + + return { branch, dirty, ahead, behind }; +} diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts index 68aed5d5f..644c9d5f1 100644 --- a/src/kimi_cli_ts/ui/hooks/useWire.ts +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -194,10 +194,19 @@ export function useWire(options?: UseWireOptions): WireState & { id: nanoid(), title: event.title, body: event.body, - severity: "info", - duration: 4000, // auto-dismiss after 4 seconds + severity: (event.severity as Toast["severity"]) || "info", + duration: 5000, + position: "left", + topic: event.title, // deduplicate by title + createdAt: Date.now(), }; - setNotifications((prev) => [...prev, toast]); + setNotifications((prev) => { + // Topic dedup: remove existing toast with same topic + const filtered = toast.topic + ? prev.filter((t) => t.topic !== toast.topic) + : prev; + return [...filtered, toast]; + }); break; } @@ -209,6 +218,8 @@ export function useWire(options?: UseWireOptions): WireState & { body: event.message, severity: "error", duration: event.retryable ? 0 : 6000, // retryable errors don't auto-dismiss + position: "left", + createdAt: Date.now(), }; setNotifications((prev) => [...prev, toast]); setIsStreaming(false); diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 9c2ed9660..edc153582 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -21,6 +21,7 @@ interface PromptProps { disabled?: boolean; placeholder?: string; isStreaming?: boolean; + planMode?: boolean; commands?: SlashCommand[]; onSlashMenuChange?: (visible: boolean) => void; /** Incremented by parent to signal "clear the input box" */ @@ -35,6 +36,7 @@ export function Prompt({ disabled = false, placeholder = "Send a message... (/ for commands)", isStreaming = false, + planMode = false, commands = [], onSlashMenuChange, clearSignal = 0, @@ -161,7 +163,7 @@ export function Prompt({ {/* Input line — always rendered, always on top */} - {isStreaming ? "🔄 " : "✨ "} + {isStreaming ? "💫 " : planMode ? "📋 " : "✨ "} { @@ -236,8 +239,6 @@ export function Shell({ [wire.pendingApproval, onApprovalResponse, wire], ); - // Calculate status bar height (separator + 2 lines of status) - const statusBarHeight = slashMenuVisible ? 0 : 3; return ( @@ -270,12 +271,6 @@ export function Shell({ )} - {/* ═══ Notification Stack: transient toasts ═══ */} - - {/* ═══ InputBox: fills remaining, min 6 lines, text at top ═══ */} - {/* ═══ Bottom: Status bar (always visible, hidden when slash menu or panel) ═══ */} - {!slashMenuVisible && !activePanel && ( - - )} + {/* ═══ Bottom: Status bar (always visible) ═══ */} + ); } From aa9061007175d6bdf4d6544154c05275c6e55023 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Fri, 3 Apr 2026 23:47:10 +0800 Subject: [PATCH 05/28] feat(ui): improve slash menu, model command, keyboard shortcuts and welcome box Co-Authored-By: Claude Opus 4.6 (1M context) --- .python-version | 2 +- src/kimi_cli_ts/soul/kimisoul.ts | 7 +- src/kimi_cli_ts/ui/components/SlashMenu.tsx | 43 ++++++++++-- src/kimi_cli_ts/ui/components/StatusBar.tsx | 4 +- src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 11 ++- src/kimi_cli_ts/ui/shell/Shell.tsx | 47 +++++++++++++ src/kimi_cli_ts/ui/shell/commands/model.ts | 74 +++++++++++++++----- src/kimi_cli_ts/ui/shell/keyboard.ts | 9 ++- src/kimi_cli_ts/ui/shell/slash.ts | 2 +- 9 files changed, 166 insertions(+), 33 deletions(-) diff --git a/.python-version b/.python-version index 6324d401a..24ee5b1be 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.13 diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 4b3272506..f57e33de9 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -20,7 +20,7 @@ import { normalizeHistory } from "./dynamic_injection.ts"; import { PlanModeInjectionProvider } from "./dynamic_injections/plan_mode.ts"; import { YoloModeInjectionProvider } from "./dynamic_injections/yolo_mode.ts"; import { handleNew, handleSessions, handleTitle } from "../ui/shell/commands/session.ts"; -import { handleModel } from "../ui/shell/commands/model.ts"; +import { handleModel, createModelPanel } from "../ui/shell/commands/model.ts"; import { handleLogin, handleLogout, createLoginPanel } from "../ui/shell/commands/login.ts"; import { handleHooks, handleMcp, handleDebug, handleChangelog } from "../ui/shell/commands/info.ts"; import { handleExport, handleImport } from "../ui/shell/commands/export_import.ts"; @@ -811,9 +811,12 @@ export class KimiSoul { // Wire /model const modelCmd = registry.get("model"); if (modelCmd) { + const notify = (t: string, b: string) => this.notify(t, b); + const configMeta = { isFromDefaultLocation: true, sourceFile: null }; modelCmd.handler = async () => { - await handleModel(this.agent.runtime.config, { isFromDefaultLocation: true, sourceFile: null }); + await handleModel(this.agent.runtime.config, configMeta, notify); }; + modelCmd.panel = () => createModelPanel(this.agent.runtime.config, configMeta, notify); } // Wire /export diff --git a/src/kimi_cli_ts/ui/components/SlashMenu.tsx b/src/kimi_cli_ts/ui/components/SlashMenu.tsx index 9524b83b1..69efae455 100644 --- a/src/kimi_cli_ts/ui/components/SlashMenu.tsx +++ b/src/kimi_cli_ts/ui/components/SlashMenu.tsx @@ -62,13 +62,44 @@ function filterCommands( if (!filter) return commands; const lower = filter.toLowerCase(); - return commands.filter((cmd) => { - if (cmd.name.toLowerCase().includes(lower)) return true; - if (cmd.aliases) { - return cmd.aliases.some((a) => a.toLowerCase().includes(lower)); + return commands + .map((cmd) => { + const nameScore = fuzzyScore(cmd.name.toLowerCase(), lower); + const aliasScores = (cmd.aliases ?? []).map((a) => + fuzzyScore(a.toLowerCase(), lower), + ); + const best = Math.max(nameScore, ...aliasScores); + return { cmd, score: best }; + }) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .map((r) => r.cmd); +} + +/** + * Fuzzy match: characters of `pattern` must appear in `text` in order. + * Returns a score > 0 on match (higher = tighter), 0 on miss. + * Bonus for consecutive matches and prefix match. + */ +function fuzzyScore(text: string, pattern: string): number { + let ti = 0; + let pi = 0; + let score = 0; + let consecutive = 0; + + while (ti < text.length && pi < pattern.length) { + if (text[ti] === pattern[pi]) { + score += 1 + consecutive; + consecutive++; + // Bonus for matching at start + if (ti === pi) score += 2; + pi++; + } else { + consecutive = 0; } - return false; - }); + ti++; + } + return pi === pattern.length ? score : 0; } /** Get filtered command count (used by parent to know menu size) */ diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index 8202baa98..956c65595 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -100,8 +100,8 @@ export function StatusBar({ const contextUsage = status?.context_usage; const contextPercent = contextUsage != null ? (contextUsage * 100).toFixed(1) : "0.0"; - const contextTokens = (status as Record)?.context_tokens as number | undefined; - const maxContextTokens = (status as Record)?.max_context_tokens as number | undefined; + const contextTokens = status?.context_tokens; + const maxContextTokens = status?.max_context_tokens; const contextDetail = contextTokens != null && maxContextTokens != null ? ` (${formatTokenCount(contextTokens)}/${formatTokenCount(maxContextTokens)})` diff --git a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx index fa2797c8d..b8ff7f7bd 100644 --- a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx +++ b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx @@ -12,6 +12,7 @@ interface WelcomeBoxProps { workDir?: string; sessionId?: string; modelName?: string; + poweredBy?: string; tip?: string; } @@ -19,6 +20,7 @@ export function WelcomeBox({ workDir, sessionId, modelName, + poweredBy, tip, }: WelcomeBoxProps) { // Shorten home directory @@ -35,7 +37,7 @@ export function WelcomeBox({ borderColor={KIMI_BLUE} flexDirection="column" paddingX={1} - paddingY={0} + paddingY={1} > {/* Logo + Welcome */} @@ -70,7 +72,12 @@ export function WelcomeBox({ Model: {modelName ? ( - {modelName} + + {modelName} + {poweredBy && poweredBy !== modelName && ( + (powered by {poweredBy}) + )} + ) : ( not set, send /login to login )} diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index dea4c6a00..97922bd78 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -34,8 +34,52 @@ import type { WireUIEvent } from "./events.ts"; import type { ApprovalResponseKind } from "../../wire/types.ts"; import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + const INPUT_MIN_HEIGHT = 6; +/** + * Open $VISUAL / $EDITOR / vim to compose multi-line input. + * After the editor exits, submit the content. + */ +async function openExternalEditor( + pushNotification: (title: string, body: string) => void, + onSubmit?: (input: string) => void, +): Promise { + const editor = process.env.VISUAL || process.env.EDITOR || "vim"; + const tmpFile = join(tmpdir(), `kimi-input-${Date.now()}.md`); + + try { + await Bun.write(tmpFile, ""); + + const proc = Bun.spawn(editor.split(/\s+/).concat(tmpFile), { + stdio: ["inherit", "inherit", "inherit"], + }); + const code = await proc.exited; + + if (code !== 0) { + pushNotification("Editor", `Editor exited with code ${code}`); + return; + } + + const content = await Bun.file(tmpFile).text(); + const trimmed = content.trim(); + if (trimmed && onSubmit) { + onSubmit(trimmed); + } else if (!trimmed) { + pushNotification("Editor", "Empty input, nothing submitted."); + } + } catch (err: any) { + pushNotification("Editor", `Failed to open editor: ${err?.message ?? err}`); + } finally { + try { + const fs = require("node:fs"); + fs.unlinkSync(tmpFile); + } catch { /* ignore */ } + } +} + /** Deduplicate commands by name, shell commands take priority */ function deduplicateCommands(commands: SlashCommand[]): SlashCommand[] { const seen = new Map(); @@ -167,6 +211,9 @@ export function Shell({ }); } break; + case "open-editor": + openExternalEditor(pushNotification, onSubmit); + break; // "exit" is handled internally by useKeyboard (calls exit()) } }, diff --git a/src/kimi_cli_ts/ui/shell/commands/model.ts b/src/kimi_cli_ts/ui/shell/commands/model.ts index c21ec222c..0aa7416bc 100644 --- a/src/kimi_cli_ts/ui/shell/commands/model.ts +++ b/src/kimi_cli_ts/ui/shell/commands/model.ts @@ -1,31 +1,69 @@ import type { Config, ConfigMeta } from "../../../config.ts"; -import { logger } from "../../../utils/logging.ts"; +import { saveConfig } from "../../../config.ts"; +import type { CommandPanelConfig } from "../../../types.ts"; -export async function handleModel(config: Config, configMeta: ConfigMeta): Promise { - if (!Object.keys(config.models).length) { - logger.info("No models configured. Run /login to set up."); +type Notify = (title: string, body: string) => void; + +/** + * Handle /model when typed directly (no panel) — show current model as notification. + */ +export async function handleModel(config: Config, configMeta: ConfigMeta, notify?: Notify): Promise { + const currentModel = config.default_model; + if (!currentModel) { + notify?.("Model", "No model set. Use /login to configure."); return; } + const modelCfg = config.models[currentModel]; + if (modelCfg) { + notify?.("Model", `Current: ${modelCfg.model} (${modelCfg.provider})`); + } else { + notify?.("Model", `Current: ${currentModel}`); + } +} - if (!configMeta.isFromDefaultLocation) { - logger.info("Model switching requires the default config file."); - return; +/** + * Create a panel for /model that lists all available models for selection. + */ +export function createModelPanel(config: Config, configMeta: ConfigMeta, notify: Notify): CommandPanelConfig { + const modelNames = Object.keys(config.models).sort(); + if (!modelNames.length) { + return { + type: "content", + title: "Model", + content: "No models configured. Run /login to set up.", + }; } const currentModel = config.default_model; - logger.info("Available models:"); - - const modelNames = Object.keys(config.models).sort(); - for (let i = 0; i < modelNames.length; i++) { - const name = modelNames[i]!; + const items = modelNames.map((name) => { const modelCfg = config.models[name]!; const providerName = modelCfg.provider; - const current = name === currentModel ? " (current)" : ""; const capabilities = modelCfg.capabilities?.join(", ") || "none"; - logger.info(` [${i + 1}] ${modelCfg.model} (${providerName})${current} [${capabilities}]`); - } + return { + label: `${modelCfg.model} (${providerName})`, + value: name, + current: name === currentModel, + description: `caps: ${capabilities}`, + }; + }); - logger.info(""); - logger.info("To switch models, use: kimi --model "); - logger.info("Or edit ~/.kimi/config.toml and set default_model"); + return { + type: "choice", + title: "Select Model", + items, + onSelect: async (value: string) => { + if (value === currentModel) { + notify("Model", "Already using this model."); + return; + } + config.default_model = value; + try { + await saveConfig(config, configMeta.sourceFile ?? undefined); + const modelCfg = config.models[value]; + notify("Model", `Switched to: ${modelCfg?.model ?? value}. Restart to apply.`); + } catch (err: any) { + notify("Model", `Failed to save: ${err?.message ?? err}`); + } + }, + }; } diff --git a/src/kimi_cli_ts/ui/shell/keyboard.ts b/src/kimi_cli_ts/ui/shell/keyboard.ts index ccfd76503..afa30d97a 100644 --- a/src/kimi_cli_ts/ui/shell/keyboard.ts +++ b/src/kimi_cli_ts/ui/shell/keyboard.ts @@ -17,7 +17,8 @@ export type KeyAction = | "interrupt" | "exit" | "clear-input" - | "toggle-plan-mode"; + | "toggle-plan-mode" + | "open-editor"; export interface UseKeyboardOptions { onAction: (action: KeyAction) => void; @@ -96,6 +97,12 @@ export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { return; } + // ── Ctrl+O ──────────────────────────────────── + if (input === "o" && key.ctrl) { + onAction("open-editor"); + return; + } + // Any other key resets both counters ctrlCCount.current = 0; escCount.current = 0; diff --git a/src/kimi_cli_ts/ui/shell/slash.ts b/src/kimi_cli_ts/ui/shell/slash.ts index 897261e40..b12db45f2 100644 --- a/src/kimi_cli_ts/ui/shell/slash.ts +++ b/src/kimi_cli_ts/ui/shell/slash.ts @@ -30,7 +30,7 @@ export function createShellSlashCommands( { name: "clear", description: "Clear conversation history", - aliases: ["cls"], + aliases: ["cls", "reset"], handler: async () => { ctx.clearMessages(); }, From e6f62a439d5e6349e46203074e3cdea61f92c671 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Fri, 3 Apr 2026 23:48:11 +0800 Subject: [PATCH 06/28] fix: revert .python-version back to 3.14 Co-Authored-By: Claude Opus 4.6 (1M context) --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 24ee5b1be..6324d401a 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.14 From 5734481d42135165cd6366a6bdc478b2dce47eb8 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 00:10:28 +0800 Subject: [PATCH 07/28] feat(ui): add shell mode, @ file mention, multiline input, clipboard paste - Ctrl+C: show "Press Ctrl-C again to exit" toast with 2s window - Ctrl+X: toggle agent/shell mode ($-prompt, run shell commands) - @ file mention: fuzzy completion with ignore list, 2s cache, 1000 limit - Ctrl+J: multiline input via line buffer - Ctrl+V: clipboard paste (pbpaste/xclip/wl-paste) - StatusBar: show shell mode indicator Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/ui/components/MentionMenu.tsx | 44 ++++ src/kimi_cli_ts/ui/components/StatusBar.tsx | 11 +- src/kimi_cli_ts/ui/hooks/useFileMention.ts | 231 ++++++++++++++++++ src/kimi_cli_ts/ui/shell/Prompt.tsx | 132 ++++++++-- src/kimi_cli_ts/ui/shell/Shell.tsx | 108 +++++++- src/kimi_cli_ts/ui/shell/keyboard.ts | 38 ++- 6 files changed, 532 insertions(+), 32 deletions(-) create mode 100644 src/kimi_cli_ts/ui/components/MentionMenu.tsx create mode 100644 src/kimi_cli_ts/ui/hooks/useFileMention.ts diff --git a/src/kimi_cli_ts/ui/components/MentionMenu.tsx b/src/kimi_cli_ts/ui/components/MentionMenu.tsx new file mode 100644 index 000000000..bc8aebd99 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/MentionMenu.tsx @@ -0,0 +1,44 @@ +/** + * MentionMenu.tsx — File mention completion menu. + * Renders below the input when @ is typed, similar to SlashMenu. + */ + +import React from "react"; +import { Box, Text, useStdout } from "ink"; + +const DIM = "#888888"; +const HIGHLIGHT_BG = "#1e90ff"; + +interface MentionMenuProps { + suggestions: string[]; + selectedIndex: number; +} + +export function MentionMenu({ suggestions, selectedIndex }: MentionMenuProps) { + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + if (suggestions.length === 0) return null; + + const separator = "─".repeat(columns); + + return ( + + {separator} + {suggestions.map((path, i) => { + const isSelected = i === selectedIndex; + const isDir = path.endsWith("/"); + return ( + + + {isSelected ? "▸ " : " "} + + + {path} + + + ); + })} + + ); +} diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index 956c65595..36a9c1f56 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -35,6 +35,7 @@ interface StatusBarProps { planMode?: boolean; yolo?: boolean; thinking?: boolean; + shellMode?: boolean; // Git info gitBranch?: string | null; gitDirty?: boolean; @@ -59,6 +60,7 @@ export function StatusBar({ planMode = false, yolo = false, thinking = false, + shellMode = false, gitBranch, gitDirty = false, gitAhead = 0, @@ -127,10 +129,13 @@ export function StatusBar({ } // Build mode string + const modeName = shellMode ? "shell" : "agent"; const thinkingDot = thinking ? "●" : "○"; - const modeStr = modelName - ? `agent (${modelName} ${thinkingDot})` - : "agent"; + const modeStr = shellMode + ? "shell" + : modelName + ? `${modeName} (${modelName} ${thinkingDot})` + : modeName; // Rotating tips (show 2 tips separated by |) let tipText = ""; diff --git a/src/kimi_cli_ts/ui/hooks/useFileMention.ts b/src/kimi_cli_ts/ui/hooks/useFileMention.ts new file mode 100644 index 000000000..1f5b04d19 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useFileMention.ts @@ -0,0 +1,231 @@ +/** + * useFileMention hook — scans workspace files for @ mention completion. + * Matches Python's LocalFileMentionCompleter in prompt.py. + * + * - Top-level scan when fragment is short (< 3 chars, no /) + * - Deep recursive scan otherwise + * - Caches results for 2s + * - Ignores .git, node_modules, __pycache__, etc. + * - Fuzzy-filters results + * - Limit 1000 entries + */ + +import { useState, useEffect, useRef, useCallback } from "react"; +import { readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const REFRESH_INTERVAL = 2000; // 2s cache +const LIMIT = 1000; + +// ── Ignored names (ported from Python LocalFileMentionCompleter) ── + +const IGNORED_NAMES = new Set([ + // VCS metadata + ".DS_Store", ".bzr", ".git", ".hg", ".svn", + // Tooling caches + ".build", ".cache", ".coverage", ".fleet", ".gradle", ".idea", + ".ipynb_checkpoints", ".pnpm-store", ".pytest_cache", ".pub-cache", + ".ruff_cache", ".swiftpm", ".tox", ".venv", ".vs", ".vscode", + ".yarn", ".yarn-cache", + // JS/frontend + ".next", ".nuxt", ".parcel-cache", ".svelte-kit", ".turbo", ".vercel", + "node_modules", + // Python packaging + "__pycache__", "build", "coverage", "dist", "htmlcov", + "pip-wheel-metadata", "venv", + // Java/JVM + ".mvn", "out", "target", + // Dotnet/native + "bin", "cmake-build-debug", "cmake-build-release", "obj", + // Bazel/Buck + "bazel-bin", "bazel-out", "bazel-testlogs", "buck-out", + // Misc + ".dart_tool", ".serverless", ".stack-work", ".terraform", + ".terragrunt-cache", "DerivedData", "Pods", "deps", "tmp", "vendor", +]); + +const IGNORED_PATTERN = /(?:.*_cache|.*-cache|.*\.egg-info|.*\.dist-info|.*\.py[co]|.*\.class|.*\.sw[po]|.*~|.*\.(?:tmp|bak))$/i; + +function isIgnored(name: string): boolean { + if (!name) return true; + if (IGNORED_NAMES.has(name)) return true; + return IGNORED_PATTERN.test(name); +} + +// ── File scanning ── + +function scanTopLevel(root: string): string[] { + const entries: string[] = []; + try { + const items = readdirSync(root).sort(); + for (const name of items) { + if (isIgnored(name)) continue; + try { + const stat = statSync(join(root, name)); + entries.push(stat.isDirectory() ? `${name}/` : name); + } catch { /* skip */ } + if (entries.length >= LIMIT) break; + } + } catch { /* skip */ } + return entries; +} + +function scanDeep(root: string): string[] { + const paths: string[] = []; + + function walk(dir: string) { + if (paths.length >= LIMIT) return; + let items: string[]; + try { + items = readdirSync(dir).sort(); + } catch { return; } + + const subdirs: string[] = []; + for (const name of items) { + if (paths.length >= LIMIT) break; + if (isIgnored(name)) continue; + const full = join(dir, name); + try { + const stat = statSync(full); + const rel = relative(root, full).split(sep).join("/"); + if (stat.isDirectory()) { + paths.push(rel + "/"); + subdirs.push(full); + } else { + paths.push(rel); + } + } catch { /* skip */ } + } + for (const sub of subdirs) { + if (paths.length >= LIMIT) break; + walk(sub); + } + } + + walk(root); + return paths; +} + +// ── Fuzzy matching (reused from SlashMenu) ── + +function fuzzyScore(text: string, pattern: string): number { + let ti = 0; + let pi = 0; + let score = 0; + let consecutive = 0; + + while (ti < text.length && pi < pattern.length) { + if (text[ti] === pattern[pi]) { + score += 1 + consecutive; + consecutive++; + if (ti === pi) score += 2; + pi++; + } else { + consecutive = 0; + } + ti++; + } + return pi === pattern.length ? score : 0; +} + +function fuzzyFilter(paths: string[], fragment: string, maxResults = 20): string[] { + if (!fragment) return paths.slice(0, maxResults); + const lower = fragment.toLowerCase(); + return paths + .map((p) => { + const nameScore = fuzzyScore(p.toLowerCase(), lower); + // Bonus for basename match + const base = p.replace(/\/$/, "").split("/").pop() ?? ""; + const baseScore = fuzzyScore(base.toLowerCase(), lower); + const best = Math.max(nameScore, baseScore * 1.5); + return { path: p, score: best }; + }) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, maxResults) + .map((r) => r.path); +} + +// ── @ mention extraction ── + +/** Guard chars that prevent @ from triggering (e.g. inside emails) */ +const TRIGGER_GUARDS = new Set([".", "-", "_", "`", "'", '"', ":", "@", "#", "~"]); + +/** + * Extract the @ fragment from input text (from the last @). + * Returns null if @ should not trigger completion. + */ +export function extractMentionFragment(text: string): string | null { + const idx = text.lastIndexOf("@"); + if (idx === -1) return null; + + // Guard: @ must be preceded by whitespace or be at start + if (idx > 0) { + const prev = text[idx - 1]!; + if (prev.match(/[a-zA-Z0-9]/) || TRIGGER_GUARDS.has(prev)) { + return null; + } + } + + const fragment = text.slice(idx + 1); + + // If fragment has whitespace, @ mention is over + if (/\s/.test(fragment)) return null; + + return fragment; +} + +// ── Hook ── + +export interface FileMentionState { + /** Filtered suggestions for current fragment */ + suggestions: string[]; + /** Whether the mention menu should be shown */ + isActive: boolean; + /** Current fragment being typed */ + fragment: string; +} + +export function useFileMention( + inputValue: string, + workDir?: string, +): FileMentionState { + const [topPaths, setTopPaths] = useState([]); + const [deepPaths, setDeepPaths] = useState([]); + const topCacheTime = useRef(0); + const deepCacheTime = useRef(0); + + const fragment = extractMentionFragment(inputValue); + const isActive = fragment !== null; + + // Determine which scan to use + const needsDeep = fragment !== null && (fragment.includes("/") || fragment.length >= 3); + + // Refresh scan when needed + useEffect(() => { + if (!workDir || fragment === null) return; + const now = Date.now(); + + if (needsDeep) { + if (now - deepCacheTime.current > REFRESH_INTERVAL) { + deepCacheTime.current = now; + // Run scan async-ish (sync but in effect) + setDeepPaths(scanDeep(workDir)); + } + } else { + if (now - topCacheTime.current > REFRESH_INTERVAL) { + topCacheTime.current = now; + setTopPaths(scanTopLevel(workDir)); + } + } + }, [workDir, fragment, needsDeep]); + + const basePaths = needsDeep ? deepPaths : topPaths; + const suggestions = fragment !== null ? fuzzyFilter(basePaths, fragment) : []; + + return { + suggestions, + isActive, + fragment: fragment ?? "", + }; +} diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index edc153582..ffa005bc9 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -1,18 +1,20 @@ /** - * Prompt.tsx — Input prompt component with slash command completion. - * Uses ✨ sparkles emoji matching Python version. - * Slash menu renders BELOW the input (pushes up from bottom). + * Prompt.tsx — Input prompt component with slash command + @ file mention completion. + * Prompt symbols: $ (shell), 💫 (streaming), 📋 (plan), ✨ (agent default) + * Slash menu and mention menu render BELOW the input. */ import React, { useState, useCallback } from "react"; import { Box, Text, useInput, useStdout } from "ink"; import TextInput from "ink-text-input"; import { useInputHistory } from "../hooks/useInput.ts"; +import { useFileMention, extractMentionFragment } from "../hooks/useFileMention.ts"; import { SlashMenu, getFilteredCommandCount, getFilteredCommand, } from "../components/SlashMenu.tsx"; +import { MentionMenu } from "../components/MentionMenu.tsx"; import type { SlashCommand } from "../../types.ts"; interface PromptProps { @@ -22,12 +24,18 @@ interface PromptProps { placeholder?: string; isStreaming?: boolean; planMode?: boolean; + shellMode?: boolean; + workDir?: string; commands?: SlashCommand[]; onSlashMenuChange?: (visible: boolean) => void; /** Incremented by parent to signal "clear the input box" */ clearSignal?: number; /** One-shot prefill text for the input (e.g. from /undo) */ prefillText?: string; + /** Signal from parent to insert a newline (Ctrl+J) */ + newlineSignal?: number; + /** Signal from parent to paste clipboard text */ + pasteText?: string; } export function Prompt({ @@ -37,23 +45,50 @@ export function Prompt({ placeholder = "Send a message... (/ for commands)", isStreaming = false, planMode = false, + shellMode = false, + workDir, commands = [], onSlashMenuChange, clearSignal = 0, prefillText, + newlineSignal = 0, + pasteText, }: PromptProps) { const { value, setValue, historyPrev, historyNext, addToHistory } = useInputHistory(); const [slashMenuIndex, setSlashMenuIndex] = useState(0); + const [mentionMenuIndex, setMentionMenuIndex] = useState(0); + // Multiline buffer: lines accumulated via Ctrl+J + const [bufferedLines, setBufferedLines] = useState([]); + + // @ file mention + const mention = useFileMention(value, workDir); + const showMentionMenu = mention.isActive && mention.suggestions.length > 0 && !shellMode; // React to clearSignal from parent (double-Esc) React.useEffect(() => { if (clearSignal > 0) { setValue(""); + setBufferedLines([]); } }, [clearSignal, setValue]); + // React to newlineSignal (Ctrl+J): push current line to buffer + React.useEffect(() => { + if (newlineSignal > 0) { + setBufferedLines((prev) => [...prev, value]); + setValue(""); + } + }, [newlineSignal]); // intentionally omit value — capture at moment of signal + + // React to pasteText (Ctrl+V) + React.useEffect(() => { + if (pasteText) { + setValue((prev) => prev + pasteText); + } + }, [pasteText, setValue]); + // Consume one-shot prefill text React.useEffect(() => { if (prefillText) { @@ -70,16 +105,20 @@ export function Prompt({ : 0; const showSlashMenu = isSlashMode && menuCount > 0; - // Notify parent about slash menu visibility + // Notify parent about menu visibility React.useEffect(() => { - onSlashMenuChange?.(showSlashMenu); - }, [showSlashMenu, onSlashMenuChange]); + onSlashMenuChange?.(showSlashMenu || showMentionMenu); + }, [showSlashMenu, showMentionMenu, onSlashMenuChange]); - // Reset menu index when filter changes + // Reset menu indices when filter changes React.useEffect(() => { setSlashMenuIndex(0); }, [slashFilter]); + React.useEffect(() => { + setMentionMenuIndex(0); + }, [mention.fragment]); + const handleChange = useCallback( (newValue: string) => { setValue(newValue); @@ -87,8 +126,29 @@ export function Prompt({ [setValue], ); + // Apply a mention selection: replace @fragment with @path + const applyMentionSelection = useCallback( + (path: string) => { + const atIdx = value.lastIndexOf("@"); + if (atIdx === -1) return; + const newValue = value.slice(0, atIdx) + "@" + path + " "; + setValue(newValue); + setMentionMenuIndex(0); + }, + [value, setValue], + ); + const handleSubmit = useCallback( (input: string) => { + // If mention menu is open, Tab/Enter selects the item + if (showMentionMenu) { + const selected = mention.suggestions[mentionMenuIndex]; + if (selected) { + applyMentionSelection(selected); + return; + } + } + if (showSlashMenu) { const selected = getFilteredCommand( commands, @@ -99,7 +159,6 @@ export function Prompt({ const cmd = `/${selected.name}`; addToHistory(cmd); setValue(""); - // If the command has a panel, open it instead of submitting if (selected.panel && onOpenPanel) { onOpenPanel(selected); return; @@ -110,10 +169,17 @@ export function Prompt({ } const trimmed = input.trim(); - if (!trimmed) return; - addToHistory(trimmed); + if (!trimmed && bufferedLines.length === 0) return; + // Combine buffered lines with current input + const fullInput = bufferedLines.length > 0 + ? [...bufferedLines, input].join("\n") + : input; + const finalTrimmed = fullInput.trim(); + if (!finalTrimmed) return; + addToHistory(finalTrimmed); setValue(""); - onSubmit(trimmed); + setBufferedLines([]); + onSubmit(finalTrimmed); }, [ onSubmit, @@ -121,16 +187,31 @@ export function Prompt({ addToHistory, setValue, showSlashMenu, + showMentionMenu, commands, slashFilter, slashMenuIndex, + mention.suggestions, + mentionMenuIndex, + applyMentionSelection, ], ); // Handle up/down/tab for navigation useInput( (_input, key) => { - if (showSlashMenu) { + if (showMentionMenu) { + if (key.upArrow) { + setMentionMenuIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow) { + setMentionMenuIndex((i) => Math.min(mention.suggestions.length - 1, i + 1)); + } else if (key.tab && !key.shift) { + const selected = mention.suggestions[mentionMenuIndex]; + if (selected) { + applyMentionSelection(selected); + } + } + } else if (showSlashMenu) { if (key.upArrow) { setSlashMenuIndex((i) => Math.max(0, i - 1)); } else if (key.downArrow) { @@ -156,23 +237,34 @@ export function Prompt({ const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; + // Prompt symbol: $ (shell) > 💫 (streaming) > 📋 (plan) > ✨ (default) + const promptSymbol = shellMode ? "$ " : isStreaming ? "💫 " : planMode ? "📋 " : "✨ "; + return ( {/* Separator line above input */} {"─".repeat(columns)} - {/* Input line — always rendered, always on top */} + {/* Buffered lines (multiline via Ctrl+J) */} + {bufferedLines.map((line, i) => ( + + {i === 0 ? promptSymbol : " "} + {line} + + ))} + + {/* Input line */} - {isStreaming ? "💫 " : planMode ? "📋 " : "✨ "} + {bufferedLines.length > 0 ? " " : promptSymbol} - {/* Slash command menu — renders below input, pushes up from bottom */} + {/* Slash command menu */} {showSlashMenu && ( )} + + {/* @ file mention menu */} + {showMentionMenu && !showSlashMenu && ( + + )} ); } diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 97922bd78..29e6578c1 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -39,6 +39,62 @@ import { join } from "node:path"; const INPUT_MIN_HEIGHT = 6; +/** + * Paste text from clipboard (matches Python Ctrl+V behavior). + * macOS: pbpaste, Linux: xclip/xsel/wl-paste + */ +async function pasteFromClipboard( + setPasteText: (text: string | undefined) => void, + pushNotification: (title: string, body: string) => void, +): Promise { + const commands = process.platform === "darwin" + ? [["pbpaste"]] + : [["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"], ["wl-paste"]]; + + for (const cmd of commands) { + try { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" }); + const text = await new Response(proc.stdout).text(); + const code = await proc.exited; + if (code === 0 && text) { + setPasteText(text); + // Reset after a tick so the effect fires + setTimeout(() => setPasteText(undefined), 0); + return; + } + } catch { /* try next */ } + } + pushNotification("Paste", "Clipboard is empty or not available."); +} + +/** + * Run a shell command in foreground (matches Python _run_shell_command). + */ +async function runShellCommand( + command: string, + pushNotification: (title: string, body: string) => void, +): Promise { + const trimmed = command.trim(); + if (!trimmed) return; + + // Block 'cd' — directory changes don't persist + const parts = trimmed.split(/\s+/); + if (parts[0] === "cd") { + pushNotification("Shell", "Warning: Directory changes are not preserved across command executions."); + return; + } + + try { + const proc = Bun.spawn(["sh", "-c", trimmed], { + stdio: ["inherit", "inherit", "inherit"], + env: process.env, + }); + await proc.exited; + } catch (err: any) { + pushNotification("Shell", `Failed to run command: ${err?.message ?? err}`); + } +} + /** * Open $VISUAL / $EDITOR / vim to compose multi-line input. * After the editor exits, submit the content. @@ -134,6 +190,9 @@ export function Shell({ const [slashMenuVisible, setSlashMenuVisible] = useState(false); const [activePanel, setActivePanel] = useState(null); const [clearInputSignal, setClearInputSignal] = useState(0); + const [shellMode, setShellMode] = useState(false); + const [newlineSignal, setNewlineSignal] = useState(0); + const [pasteText, setPasteText] = useState(undefined); // Wire state const wire = useWire({ onReady: onWireReady }); @@ -179,22 +238,24 @@ export function Shell({ }; }, [stdout]); - // Global keyboard handling: Ctrl+C / Esc / Shift+Tab + // Slash commands allowed in shell mode + const SHELL_MODE_COMMANDS = new Set(["clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?"]); + + // Global keyboard handling useKeyboard({ onAction: (action) => { switch (action) { case "interrupt": if (activePanel) { - // Close command panel on interrupt setActivePanel(null); } else if (wire.isStreaming) { - // Interrupt the running turn: abort the soul + push UI event onInterrupt?.(); wire.pushEvent({ type: "error", message: "Interrupted by user" }); } + // Always show exit hint on Ctrl+C + pushNotification("Ctrl-C", "Press Ctrl-C again to exit"); break; case "clear-input": - // Double-Esc: clear the input box setClearInputSignal((n) => n + 1); break; case "toggle-plan-mode": @@ -211,9 +272,22 @@ export function Shell({ }); } break; + case "toggle-shell-mode": + setShellMode((prev) => { + const next = !prev; + pushNotification("Mode", next ? "Shell mode" : "Agent mode"); + return next; + }); + break; case "open-editor": openExternalEditor(pushNotification, onSubmit); break; + case "newline": + setNewlineSignal((n) => n + 1); + break; + case "paste-clipboard": + pasteFromClipboard(setPasteText, pushNotification); + break; // "exit" is handled internally by useKeyboard (calls exit()) } }, @@ -225,9 +299,19 @@ export function Shell({ (input: string) => { const parsed = parseSlashCommand(input); if (parsed) { + // In shell mode, only allow a subset of slash commands + if (shellMode) { + if (!SHELL_MODE_COMMANDS.has(parsed.name)) { + wire.pushEvent({ + type: "notification", + title: "Shell mode", + body: `/${parsed.name} is not available in shell mode. Press Ctrl-X to switch to agent mode.`, + }); + return; + } + } const cmd = findSlashCommand(allCommands, parsed.name); if (cmd) { - // If command has panel and no args provided, try opening panel if (cmd.panel && !parsed.args) { const panelConfig = cmd.panel(); if (panelConfig) { @@ -245,9 +329,16 @@ export function Shell({ }); return; } + + // Shell mode: run as shell command + if (shellMode) { + runShellCommand(input, pushNotification); + return; + } + onSubmit?.(input); }, - [allCommands, onSubmit, wire], + [allCommands, onSubmit, wire, shellMode, pushNotification], ); // Handle opening a command panel from slash menu @@ -334,10 +425,14 @@ export function Shell({ disabled={false} isStreaming={wire.isStreaming} planMode={wire.status?.plan_mode ?? false} + shellMode={shellMode} + workDir={workDir} commands={allCommands} onSlashMenuChange={setSlashMenuVisible} clearSignal={clearInputSignal} prefillText={prefillText} + newlineSignal={newlineSignal} + pasteText={pasteText} /> )} @@ -351,6 +446,7 @@ export function Shell({ stepCount={wire.stepCount} isCompacting={wire.isCompacting} planMode={wire.status?.plan_mode ?? false} + shellMode={shellMode} thinking={thinking} gitBranch={gitStatus.branch} gitDirty={gitStatus.dirty} diff --git a/src/kimi_cli_ts/ui/shell/keyboard.ts b/src/kimi_cli_ts/ui/shell/keyboard.ts index afa30d97a..ba6b38fab 100644 --- a/src/kimi_cli_ts/ui/shell/keyboard.ts +++ b/src/kimi_cli_ts/ui/shell/keyboard.ts @@ -3,11 +3,13 @@ * Uses Ink's useInput hook for keyboard events in the React tree. * * Behavior: - * - Ctrl+C ×1: interrupt current streaming turn - * - Ctrl+C ×2 (within 500ms): exit the application + * - Ctrl+C ×1: interrupt current streaming turn + show "Press Ctrl-C again to exit" + * - Ctrl+C ×2 (within 2s): exit the application * - Esc ×1: interrupt current streaming turn * - Esc ×2 (within 500ms): clear the input box * - Shift+Tab: toggle plan mode + * - Ctrl+X: toggle agent/shell mode + * - Ctrl+O: open external editor */ import { useInput, useApp } from "ink"; @@ -18,7 +20,10 @@ export type KeyAction = | "exit" | "clear-input" | "toggle-plan-mode" - | "open-editor"; + | "toggle-shell-mode" + | "open-editor" + | "paste-clipboard" + | "newline"; export interface UseKeyboardOptions { onAction: (action: KeyAction) => void; @@ -26,12 +31,13 @@ export interface UseKeyboardOptions { active?: boolean; } -const DOUBLE_PRESS_WINDOW = 500; // ms +const CTRLC_WINDOW = 2000; // 2 seconds for Ctrl+C double press +const ESC_WINDOW = 500; // 500ms for Esc double press /** * Hook that handles global keyboard shortcuts for the shell. * - * Ctrl+C: 1st press = interrupt, 2nd press within 500ms = exit + * Ctrl+C: 1st press = interrupt + toast, 2nd press within 2s = exit * Escape: 1st press = interrupt, 2nd press within 500ms = clear input */ export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { @@ -64,7 +70,7 @@ export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); ctrlCTimer.current = setTimeout(() => { ctrlCCount.current = 0; - }, DOUBLE_PRESS_WINDOW); + }, CTRLC_WINDOW); onAction("interrupt"); return; } @@ -86,7 +92,7 @@ export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { if (escTimer.current) clearTimeout(escTimer.current); escTimer.current = setTimeout(() => { escCount.current = 0; - }, DOUBLE_PRESS_WINDOW); + }, ESC_WINDOW); onAction("interrupt"); return; } @@ -97,12 +103,30 @@ export function useKeyboard({ onAction, active = true }: UseKeyboardOptions) { return; } + // ── Ctrl+X ──────────────────────────────────── + if (input === "x" && key.ctrl) { + onAction("toggle-shell-mode"); + return; + } + // ── Ctrl+O ──────────────────────────────────── if (input === "o" && key.ctrl) { onAction("open-editor"); return; } + // ── Ctrl+V ──────────────────────────────────── + if (input === "v" && key.ctrl) { + onAction("paste-clipboard"); + return; + } + + // ── Ctrl+J ──────────────────────────────────── + if (input === "j" && key.ctrl) { + onAction("newline"); + return; + } + // Any other key resets both counters ctrlCCount.current = 0; escCount.current = 0; From 6443fc1816e59a34d7de7c6be9ec3c6040b606ec Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 00:26:13 +0800 Subject: [PATCH 08/28] fix(ui): limit slash menu to 6 visible items and strip ctrl-key chars from input - SlashMenu: windowed display with max 6 visible items, scroll indicators - Prompt: strip non-printable control chars from TextInput onChange to prevent Ctrl+X/J/V/O from leaking characters into the input buffer Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/ui/components/SlashMenu.tsx | 23 +++++++++++++++++---- src/kimi_cli_ts/ui/shell/Prompt.tsx | 5 ++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/kimi_cli_ts/ui/components/SlashMenu.tsx b/src/kimi_cli_ts/ui/components/SlashMenu.tsx index 69efae455..8e205a7ba 100644 --- a/src/kimi_cli_ts/ui/components/SlashMenu.tsx +++ b/src/kimi_cli_ts/ui/components/SlashMenu.tsx @@ -20,22 +20,36 @@ interface SlashMenuProps { selectedIndex: number; } +const MAX_VISIBLE = 6; + export function SlashMenu({ commands, filter, selectedIndex }: SlashMenuProps) { const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; // Fuzzy filter commands - const filtered = filterCommands(commands, filter); + const allFiltered = filterCommands(commands, filter); - if (filtered.length === 0) return null; + if (allFiltered.length === 0) return null; + + // Windowed display: show MAX_VISIBLE items around selectedIndex + const total = allFiltered.length; + let start = 0; + if (total > MAX_VISIBLE) { + // Keep selected item visible with some context + start = Math.max(0, Math.min(selectedIndex - 2, total - MAX_VISIBLE)); + } + const visible = allFiltered.slice(start, start + MAX_VISIBLE); + const hasMore = total > MAX_VISIBLE; const separator = "─".repeat(columns); return ( {separator} - {filtered.map((cmd, i) => { - const isSelected = i === selectedIndex; + {start > 0 && ↑ {start} more} + {visible.map((cmd, i) => { + const realIndex = start + i; + const isSelected = realIndex === selectedIndex; return ( @@ -50,6 +64,7 @@ export function SlashMenu({ commands, filter, selectedIndex }: SlashMenuProps) { ); })} + {start + MAX_VISIBLE < total && ↓ {total - start - MAX_VISIBLE} more} ); } diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index ffa005bc9..ed8dd82fe 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -121,7 +121,10 @@ export function Prompt({ const handleChange = useCallback( (newValue: string) => { - setValue(newValue); + // Strip non-printable control characters that leak from Ctrl+X/J/V/O shortcuts. + // Keep only printable chars (>= 0x20) and tab (0x09). + const cleaned = newValue.replace(/[^\x09\x20-\uFFFF]/g, ""); + setValue(cleaned); }, [setValue], ); From 995620854f79c00a4f7231e80fea1cddee6ec234 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 00:42:15 +0800 Subject: [PATCH 09/28] fix(ui): align status bar, slash menu, welcome box, and prompt with Python version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusBar: git badge format "main [± ↑1]" matching Python exactly, token count drops trailing .0 (128k not 128.0k) - SlashMenu: show aliases, expanded description for selected item (up to 3 lines) - WelcomeBox: use modelDisplayName for "powered by kimi-k2.5" display - Prompt: remove placeholder text (Python has none), fix slash command double-Enter bug using refs for latest menu state Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/types.ts | 2 + src/kimi_cli_ts/ui/components/SlashMenu.tsx | 59 ++++++++++++-------- src/kimi_cli_ts/ui/components/StatusBar.tsx | 47 +++++++++------- src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 15 ++--- src/kimi_cli_ts/ui/shell/Prompt.tsx | 20 ++++--- 5 files changed, 86 insertions(+), 57 deletions(-) diff --git a/src/kimi_cli_ts/types.ts b/src/kimi_cli_ts/types.ts index 8fa01ab70..5e8d8393d 100644 --- a/src/kimi_cli_ts/types.ts +++ b/src/kimi_cli_ts/types.ts @@ -116,6 +116,8 @@ export type CommandPanelConfig = export interface SlashCommand { name: string; description: string; + /** Extended description shown when selected in menu (up to 3 lines) */ + longDescription?: string; aliases?: string[]; handler: (args: string) => Promise; /** If defined, selecting from menu renders a secondary panel instead of executing handler */ diff --git a/src/kimi_cli_ts/ui/components/SlashMenu.tsx b/src/kimi_cli_ts/ui/components/SlashMenu.tsx index 8e205a7ba..6693c605e 100644 --- a/src/kimi_cli_ts/ui/components/SlashMenu.tsx +++ b/src/kimi_cli_ts/ui/components/SlashMenu.tsx @@ -1,7 +1,10 @@ /** * SlashMenu.tsx — Slash command completion menu. * Renders a list of matching commands when user types '/'. - * Corresponds to Python's SlashCommandCompletionMenu. + * Matches Python's SlashCommandCompletionMenu: + * - Fuzzy matching + * - Max 6 visible items with scroll indicators + * - Selected item shows expanded description (up to 3 lines) */ import React from "react"; @@ -10,6 +13,8 @@ import type { SlashCommand } from "../../types.ts"; const DIM = "#888888"; const HIGHLIGHT_BG = "#1e90ff"; +const MAX_VISIBLE = 6; +const MAX_DESC_LINES = 3; interface SlashMenuProps { /** All available commands */ @@ -20,26 +25,20 @@ interface SlashMenuProps { selectedIndex: number; } -const MAX_VISIBLE = 6; - export function SlashMenu({ commands, filter, selectedIndex }: SlashMenuProps) { const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; - // Fuzzy filter commands const allFiltered = filterCommands(commands, filter); - if (allFiltered.length === 0) return null; - // Windowed display: show MAX_VISIBLE items around selectedIndex + // Windowed display around selectedIndex const total = allFiltered.length; let start = 0; if (total > MAX_VISIBLE) { - // Keep selected item visible with some context start = Math.max(0, Math.min(selectedIndex - 2, total - MAX_VISIBLE)); } const visible = allFiltered.slice(start, start + MAX_VISIBLE); - const hasMore = total > MAX_VISIBLE; const separator = "─".repeat(columns); @@ -50,21 +49,39 @@ export function SlashMenu({ commands, filter, selectedIndex }: SlashMenuProps) { {visible.map((cmd, i) => { const realIndex = start + i; const isSelected = realIndex === selectedIndex; + // Aliases display + const aliasStr = cmd.aliases?.length ? ` (${cmd.aliases.map(a => `/${a}`).join(", ")})` : ""; return ( - - - {isSelected ? "▸ " : " "} - - - /{cmd.name} - - - {" " + cmd.description} - + + + + {isSelected ? "▸ " : " "} + + + /{cmd.name} + + {aliasStr && {aliasStr}} + + {" " + cmd.description} + + + {/* Expanded description for selected item */} + {isSelected && cmd.longDescription && ( + + {cmd.longDescription + .split("\n") + .slice(0, MAX_DESC_LINES) + .map((line, li) => ( + {line} + ))} + + )} ); })} - {start + MAX_VISIBLE < total && ↓ {total - start - MAX_VISIBLE} more} + {start + MAX_VISIBLE < total && ( + ↓ {total - start - MAX_VISIBLE} more + )} ); } @@ -94,7 +111,6 @@ function filterCommands( /** * Fuzzy match: characters of `pattern` must appear in `text` in order. * Returns a score > 0 on match (higher = tighter), 0 on miss. - * Bonus for consecutive matches and prefix match. */ function fuzzyScore(text: string, pattern: string): number { let ti = 0; @@ -106,7 +122,6 @@ function fuzzyScore(text: string, pattern: string): number { if (text[ti] === pattern[pi]) { score += 1 + consecutive; consecutive++; - // Bonus for matching at start if (ti === pi) score += 2; pi++; } else { @@ -117,7 +132,7 @@ function fuzzyScore(text: string, pattern: string): number { return pi === pattern.length ? score : 0; } -/** Get filtered command count (used by parent to know menu size) */ +/** Get filtered command count */ export function getFilteredCommandCount( commands: SlashCommand[], filter: string, diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index 36a9c1f56..a0a03fb01 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -1,13 +1,13 @@ /** - * StatusBar component — 3-line bottom toolbar matching Python's layout. + * StatusBar component — 3-line bottom toolbar matching Python's layout exactly. * * Layout: * ──────────────────────────────────────────────────────────────── - * [yolo] [plan] agent (model ●) ~/cwd main↑1 ⚙2 tip1 | tip2 - * [left toast] context: 45.2% (12k/200k) + * [yolo] [plan] agent (model ●) ~/cwd main [± ↑1] ⚙ bash:2 tip1 | tip2 + * [left toast] context: 45.2% (12k/200k) */ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { Box, Text, useStdout } from "ink"; import type { StatusUpdate } from "../../wire/types.ts"; import type { Toast } from "./NotificationStack.tsx"; @@ -98,14 +98,14 @@ export function StatusBar({ return () => timers.forEach(clearTimeout); }, [toasts, onDismissToast]); - // Context usage + // Context usage — match Python format: "context: 45.3% (28.5k/128k)" const contextUsage = status?.context_usage; const contextPercent = - contextUsage != null ? (contextUsage * 100).toFixed(1) : "0.0"; + contextUsage != null ? `${(contextUsage * 100).toFixed(1)}%` : "0.0%"; const contextTokens = status?.context_tokens; const maxContextTokens = status?.max_context_tokens; const contextDetail = - contextTokens != null && maxContextTokens != null + contextTokens != null && maxContextTokens != null && maxContextTokens > 0 ? ` (${formatTokenCount(contextTokens)}/${formatTokenCount(maxContextTokens)})` : ""; @@ -117,25 +117,26 @@ export function StatusBar({ : workDir : ""; - // Git badge + // Git badge — match Python format: "main [± ↑1]" let gitBadge = ""; if (gitBranch) { - gitBadge = gitBranch; + gitBadge = truncate(gitBranch, 22); const parts: string[] = []; - if (gitDirty) parts.push("*"); + if (gitDirty) parts.push("±"); if (gitAhead > 0) parts.push(`↑${gitAhead}`); if (gitBehind > 0) parts.push(`↓${gitBehind}`); - if (parts.length > 0) gitBadge += parts.join(""); + if (parts.length > 0) { + gitBadge += ` [${parts.join(" ")}]`; + } } - // Build mode string - const modeName = shellMode ? "shell" : "agent"; + // Build mode string — match Python: "agent (kimi-k2.5 ●)" / "shell" const thinkingDot = thinking ? "●" : "○"; const modeStr = shellMode ? "shell" : modelName - ? `${modeName} (${modelName} ${thinkingDot})` - : modeName; + ? `agent (${modelName} ${thinkingDot})` + : "agent"; // Rotating tips (show 2 tips separated by |) let tipText = ""; @@ -156,7 +157,7 @@ export function StatusBar({ : ""; // Right side: context info - const rightText = `context: ${contextPercent}%${contextDetail}`; + const rightText = `context: ${contextPercent}${contextDetail}`; // Separator const separator = "─".repeat(columns); @@ -181,7 +182,7 @@ export function StatusBar({ )} {modeStr} {displayDir && {truncate(displayDir, 30)}} - {gitBadge && {truncate(gitBadge, 22)}} + {gitBadge && {gitBadge}} {bgTaskCount > 0 && ( ⚙ bash: {bgTaskCount} )} @@ -214,10 +215,18 @@ export function StatusBar({ ); } +/** + * Format token count matching Python: 123, 28.5k, 1.2M + * Drops trailing .0 (e.g. "128k" not "128.0k") + */ function formatTokenCount(count: number): string { if (count < 1000) return String(count); - if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`; - return `${(count / 1_000_000).toFixed(1)}M`; + if (count < 1_000_000) { + const k = count / 1000; + return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; + } + const m = count / 1_000_000; + return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`; } function truncate(text: string, maxLen: number): string { diff --git a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx index b8ff7f7bd..612a1386b 100644 --- a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx +++ b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx @@ -5,6 +5,7 @@ import React from "react"; import { Box, Text } from "ink"; +import { modelDisplayName } from "../../llm.ts"; const KIMI_BLUE = "#1e90ff"; @@ -12,7 +13,6 @@ interface WelcomeBoxProps { workDir?: string; sessionId?: string; modelName?: string; - poweredBy?: string; tip?: string; } @@ -20,7 +20,6 @@ export function WelcomeBox({ workDir, sessionId, modelName, - poweredBy, tip, }: WelcomeBoxProps) { // Shorten home directory @@ -31,6 +30,9 @@ export function WelcomeBox({ : workDir : "~"; + // Apply "powered by" logic matching Python + const displayModel = modelDisplayName(modelName ?? null); + return ( Model: - {modelName ? ( - - {modelName} - {poweredBy && poweredBy !== modelName && ( - (powered by {poweredBy}) - )} - + {displayModel ? ( + {displayModel} ) : ( not set, send /login to login )} diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index ed8dd82fe..813596cfa 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -54,13 +54,16 @@ export function Prompt({ newlineSignal = 0, pasteText, }: PromptProps) { - const { value, setValue, historyPrev, historyNext, addToHistory } = + const { value, setValue, historyPrev, historyNext, addToHistory, isFromHistory } = useInputHistory(); const [slashMenuIndex, setSlashMenuIndex] = useState(0); const [mentionMenuIndex, setMentionMenuIndex] = useState(0); // Multiline buffer: lines accumulated via Ctrl+J const [bufferedLines, setBufferedLines] = useState([]); + // Ref to track latest slash menu state for submit handler + const showSlashMenuRef = React.useRef(false); + const showMentionMenuRef = React.useRef(false); // @ file mention const mention = useFileMention(value, workDir); @@ -98,13 +101,17 @@ export function Prompt({ // Detect slash completion mode const isSlashMode = - value.startsWith("/") && !value.includes(" ") && commands.length > 0; + value.startsWith("/") && !value.includes(" ") && commands.length > 0 && !isFromHistory; const slashFilter = isSlashMode ? value.slice(1) : ""; const menuCount = isSlashMode ? getFilteredCommandCount(commands, slashFilter) : 0; const showSlashMenu = isSlashMode && menuCount > 0; + // Keep refs in sync for use in submit handler (avoids stale closure) + showSlashMenuRef.current = showSlashMenu; + showMentionMenuRef.current = showMentionMenu; + // Notify parent about menu visibility React.useEffect(() => { onSlashMenuChange?.(showSlashMenu || showMentionMenu); @@ -143,8 +150,9 @@ export function Prompt({ const handleSubmit = useCallback( (input: string) => { + // Use refs to get latest menu state (avoids stale closure bug) // If mention menu is open, Tab/Enter selects the item - if (showMentionMenu) { + if (showMentionMenuRef.current) { const selected = mention.suggestions[mentionMenuIndex]; if (selected) { applyMentionSelection(selected); @@ -152,7 +160,7 @@ export function Prompt({ } } - if (showSlashMenu) { + if (showSlashMenuRef.current) { const selected = getFilteredCommand( commands, slashFilter, @@ -189,8 +197,6 @@ export function Prompt({ onOpenPanel, addToHistory, setValue, - showSlashMenu, - showMentionMenu, commands, slashFilter, slashMenuIndex, @@ -263,7 +269,7 @@ export function Prompt({ value={value} onChange={handleChange} onSubmit={handleSubmit} - placeholder={isStreaming ? "Type to steer the agent..." : shellMode ? "Enter shell command..." : placeholder} + placeholder="" /> From 86a1632650138a6b421eb75235115a841121c064 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 00:44:05 +0800 Subject: [PATCH 10/28] fix(ui): prevent Ctrl+X/O/V/J from leaking characters into input Use a suppressNextChar ref flag: when Prompt's useInput detects a Ctrl+key combo, it sets the flag. The subsequent TextInput onChange call (with the leaked character) checks the flag and discards the change instead of inserting the character. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/ui/shell/Prompt.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 813596cfa..4a2cdc373 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -64,6 +64,18 @@ export function Prompt({ // Ref to track latest slash menu state for submit handler const showSlashMenuRef = React.useRef(false); const showMentionMenuRef = React.useRef(false); + // Suppress next character from TextInput when a Ctrl shortcut just fired. + // ink's useInput cannot prevent TextInput from also receiving the key, + // so we detect it in handleChange and revert. + const suppressNextChar = React.useRef(false); + + // Detect Ctrl+key presses at Prompt level to set the suppress flag. + // This runs BEFORE TextInput processes the character. + useInput((_input, key) => { + if (key.ctrl && _input && /^[xovjcXOVJC]$/.test(_input)) { + suppressNextChar.current = true; + } + }, { isActive: !disabled }); // @ file mention const mention = useFileMention(value, workDir); @@ -128,8 +140,13 @@ export function Prompt({ const handleChange = useCallback( (newValue: string) => { - // Strip non-printable control characters that leak from Ctrl+X/J/V/O shortcuts. - // Keep only printable chars (>= 0x20) and tab (0x09). + // If a Ctrl shortcut just fired, TextInput still receives the character + // (e.g. Ctrl+X → "x" leaks in). Suppress it by ignoring this onChange. + if (suppressNextChar.current) { + suppressNextChar.current = false; + return; + } + // Also strip any remaining non-printable control characters const cleaned = newValue.replace(/[^\x09\x20-\uFFFF]/g, ""); setValue(cleaned); }, From 549e31ac47d6398a27c5d99c55ccc54dbcb6a9ad Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 00:49:41 +0800 Subject: [PATCH 11/28] refactor(ui): unify input handling into single useInput in Prompt Replace ink-text-input with a custom single-useInput handler in Prompt. All keyboard events (Ctrl shortcuts, arrows, Tab, Enter, printable chars, backspace) are processed in one place, eliminating the multi-useInput conflict where Ctrl+X/O/V/J characters leaked into the text buffer. - Remove ink-text-input dependency from Prompt - Remove useKeyboard hook usage from Shell (keyboard.ts kept for types) - Prompt handles cursor, selection, clipboard paste internally - Shell receives actions via onAction callback - Fake inverse cursor rendering (matching ink-text-input style) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/ui/shell/Prompt.tsx | 348 ++++++++++++++++------------ src/kimi_cli_ts/ui/shell/Shell.tsx | 106 ++++----- 2 files changed, 250 insertions(+), 204 deletions(-) diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 4a2cdc373..15ab645c5 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -1,14 +1,24 @@ /** - * Prompt.tsx — Input prompt component with slash command + @ file mention completion. - * Prompt symbols: $ (shell), 💫 (streaming), 📋 (plan), ✨ (agent default) - * Slash menu and mention menu render BELOW the input. + * Prompt.tsx — Unified input prompt component. + * + * Uses a SINGLE useInput hook to handle ALL keyboard events: + * - Ctrl shortcuts (X/O/V/J/C) → dispatched to parent via onAction + * - Arrow keys → menu navigation or history + * - Tab → menu completion + * - Enter → submit or menu select + * - Printable chars → append to value + * - Backspace/Delete → remove char + * - Left/Right → cursor movement + * + * This eliminates the multi-useInput conflict where ink-text-input's + * internal useInput would receive leaked characters from Ctrl shortcuts. */ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useRef } from "react"; import { Box, Text, useInput, useStdout } from "ink"; -import TextInput from "ink-text-input"; +import chalk from "chalk"; import { useInputHistory } from "../hooks/useInput.ts"; -import { useFileMention, extractMentionFragment } from "../hooks/useFileMention.ts"; +import { useFileMention } from "../hooks/useFileMention.ts"; import { SlashMenu, getFilteredCommandCount, @@ -16,12 +26,13 @@ import { } from "../components/SlashMenu.tsx"; import { MentionMenu } from "../components/MentionMenu.tsx"; import type { SlashCommand } from "../../types.ts"; +import type { KeyAction } from "./keyboard.ts"; interface PromptProps { onSubmit: (input: string) => void; onOpenPanel?: (cmd: SlashCommand) => void; + onAction?: (action: KeyAction) => void; disabled?: boolean; - placeholder?: string; isStreaming?: boolean; planMode?: boolean; shellMode?: boolean; @@ -32,17 +43,13 @@ interface PromptProps { clearSignal?: number; /** One-shot prefill text for the input (e.g. from /undo) */ prefillText?: string; - /** Signal from parent to insert a newline (Ctrl+J) */ - newlineSignal?: number; - /** Signal from parent to paste clipboard text */ - pasteText?: string; } export function Prompt({ onSubmit, onOpenPanel, + onAction, disabled = false, - placeholder = "Send a message... (/ for commands)", isStreaming = false, planMode = false, shellMode = false, @@ -51,31 +58,14 @@ export function Prompt({ onSlashMenuChange, clearSignal = 0, prefillText, - newlineSignal = 0, - pasteText, }: PromptProps) { const { value, setValue, historyPrev, historyNext, addToHistory, isFromHistory } = useInputHistory(); const [slashMenuIndex, setSlashMenuIndex] = useState(0); const [mentionMenuIndex, setMentionMenuIndex] = useState(0); - // Multiline buffer: lines accumulated via Ctrl+J const [bufferedLines, setBufferedLines] = useState([]); - // Ref to track latest slash menu state for submit handler - const showSlashMenuRef = React.useRef(false); - const showMentionMenuRef = React.useRef(false); - // Suppress next character from TextInput when a Ctrl shortcut just fired. - // ink's useInput cannot prevent TextInput from also receiving the key, - // so we detect it in handleChange and revert. - const suppressNextChar = React.useRef(false); - - // Detect Ctrl+key presses at Prompt level to set the suppress flag. - // This runs BEFORE TextInput processes the character. - useInput((_input, key) => { - if (key.ctrl && _input && /^[xovjcXOVJC]$/.test(_input)) { - suppressNextChar.current = true; - } - }, { isActive: !disabled }); + const [cursorOffset, setCursorOffset] = useState(0); // @ file mention const mention = useFileMention(value, workDir); @@ -86,31 +76,23 @@ export function Prompt({ if (clearSignal > 0) { setValue(""); setBufferedLines([]); + setCursorOffset(0); } }, [clearSignal, setValue]); - // React to newlineSignal (Ctrl+J): push current line to buffer - React.useEffect(() => { - if (newlineSignal > 0) { - setBufferedLines((prev) => [...prev, value]); - setValue(""); - } - }, [newlineSignal]); // intentionally omit value — capture at moment of signal - - // React to pasteText (Ctrl+V) - React.useEffect(() => { - if (pasteText) { - setValue((prev) => prev + pasteText); - } - }, [pasteText, setValue]); - // Consume one-shot prefill text React.useEffect(() => { if (prefillText) { setValue(prefillText); + setCursorOffset(prefillText.length); } }, [prefillText, setValue]); + // Keep cursor in bounds when value changes externally + React.useEffect(() => { + setCursorOffset((prev) => Math.min(prev, value.length)); + }, [value]); + // Detect slash completion mode const isSlashMode = value.startsWith("/") && !value.includes(" ") && commands.length > 0 && !isFromHistory; @@ -120,10 +102,6 @@ export function Prompt({ : 0; const showSlashMenu = isSlashMode && menuCount > 0; - // Keep refs in sync for use in submit handler (avoids stale closure) - showSlashMenuRef.current = showSlashMenu; - showMentionMenuRef.current = showMentionMenu; - // Notify parent about menu visibility React.useEffect(() => { onSlashMenuChange?.(showSlashMenu || showMentionMenu); @@ -138,21 +116,6 @@ export function Prompt({ setMentionMenuIndex(0); }, [mention.fragment]); - const handleChange = useCallback( - (newValue: string) => { - // If a Ctrl shortcut just fired, TextInput still receives the character - // (e.g. Ctrl+X → "x" leaks in). Suppress it by ignoring this onChange. - if (suppressNextChar.current) { - suppressNextChar.current = false; - return; - } - // Also strip any remaining non-printable control characters - const cleaned = newValue.replace(/[^\x09\x20-\uFFFF]/g, ""); - setValue(cleaned); - }, - [setValue], - ); - // Apply a mention selection: replace @fragment with @path const applyMentionSelection = useCallback( (path: string) => { @@ -160,101 +123,185 @@ export function Prompt({ if (atIdx === -1) return; const newValue = value.slice(0, atIdx) + "@" + path + " "; setValue(newValue); + setCursorOffset(newValue.length); setMentionMenuIndex(0); }, [value, setValue], ); - const handleSubmit = useCallback( - (input: string) => { - // Use refs to get latest menu state (avoids stale closure bug) - // If mention menu is open, Tab/Enter selects the item - if (showMentionMenuRef.current) { - const selected = mention.suggestions[mentionMenuIndex]; - if (selected) { - applyMentionSelection(selected); + // Submit handler + const doSubmit = useCallback(() => { + // Mention menu: select item + if (showMentionMenu) { + const selected = mention.suggestions[mentionMenuIndex]; + if (selected) { + applyMentionSelection(selected); + return; + } + } + + // Slash menu: select and execute + if (showSlashMenu) { + const selected = getFilteredCommand(commands, slashFilter, slashMenuIndex); + if (selected) { + const cmd = `/${selected.name}`; + addToHistory(cmd); + setValue(""); + setCursorOffset(0); + if (selected.panel && onOpenPanel) { + onOpenPanel(selected); return; } + onSubmit(cmd); + return; } + } + + // Normal submit + const trimmed = value.trim(); + if (!trimmed && bufferedLines.length === 0) return; + const fullInput = bufferedLines.length > 0 + ? [...bufferedLines, value].join("\n") + : value; + const finalTrimmed = fullInput.trim(); + if (!finalTrimmed) return; + addToHistory(finalTrimmed); + setValue(""); + setCursorOffset(0); + setBufferedLines([]); + onSubmit(finalTrimmed); + }, [ + value, onSubmit, onOpenPanel, addToHistory, setValue, + showSlashMenu, showMentionMenu, commands, slashFilter, + slashMenuIndex, mention.suggestions, mentionMenuIndex, + applyMentionSelection, bufferedLines, + ]); + + // Paste clipboard text directly into value + const pasteClipboardIntoValue = useCallback(async () => { + const commands = process.platform === "darwin" + ? [["pbpaste"]] + : [["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"], ["wl-paste"]]; + for (const cmd of commands) { + try { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" }); + const text = await new Response(proc.stdout).text(); + const code = await proc.exited; + if (code === 0 && text) { + // Insert at cursor position + const next = value.slice(0, cursorOffset) + text + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev + text.length); + return; + } + } catch { /* try next */ } + } + }, [value, cursorOffset, setValue]); - if (showSlashMenuRef.current) { - const selected = getFilteredCommand( - commands, - slashFilter, - slashMenuIndex, - ); - if (selected) { - const cmd = `/${selected.name}`; - addToHistory(cmd); + // ── Single unified useInput ────────────────────────────── + useInput( + (input, key) => { + // ── Ctrl shortcuts → dispatch to parent, NO char insertion ── + if (key.ctrl) { + if (input === "c") { onAction?.("interrupt"); return; } + if (input === "x") { onAction?.("toggle-shell-mode"); return; } + if (input === "o") { onAction?.("open-editor"); return; } + if (input === "v") { + // Ctrl+V: paste clipboard directly into value + pasteClipboardIntoValue(); + return; + } + if (input === "j") { + // Ctrl+J: push current line to buffer (multiline) + setBufferedLines((prev) => [...prev, value]); setValue(""); - if (selected.panel && onOpenPanel) { - onOpenPanel(selected); - return; - } - onSubmit(cmd); + setCursorOffset(0); return; } + // Ctrl+D: ignore (or could exit) + return; } - const trimmed = input.trim(); - if (!trimmed && bufferedLines.length === 0) return; - // Combine buffered lines with current input - const fullInput = bufferedLines.length > 0 - ? [...bufferedLines, input].join("\n") - : input; - const finalTrimmed = fullInput.trim(); - if (!finalTrimmed) return; - addToHistory(finalTrimmed); - setValue(""); - setBufferedLines([]); - onSubmit(finalTrimmed); - }, - [ - onSubmit, - onOpenPanel, - addToHistory, - setValue, - commands, - slashFilter, - slashMenuIndex, - mention.suggestions, - mentionMenuIndex, - applyMentionSelection, - ], - ); + // ── Escape ── + if (key.escape) { + onAction?.("interrupt"); + return; + } - // Handle up/down/tab for navigation - useInput( - (_input, key) => { - if (showMentionMenu) { - if (key.upArrow) { - setMentionMenuIndex((i) => Math.max(0, i - 1)); - } else if (key.downArrow) { - setMentionMenuIndex((i) => Math.min(mention.suggestions.length - 1, i + 1)); - } else if (key.tab && !key.shift) { + // ── Shift+Tab → plan mode ── + if (key.shift && key.tab) { + onAction?.("toggle-plan-mode"); + return; + } + + // ── Tab (no shift) → menu completion ── + if (key.tab) { + if (showMentionMenu) { const selected = mention.suggestions[mentionMenuIndex]; + if (selected) applyMentionSelection(selected); + } else if (showSlashMenu) { + const selected = getFilteredCommand(commands, slashFilter, slashMenuIndex); if (selected) { - applyMentionSelection(selected); + setValue(`/${selected.name} `); + setCursorOffset(`/${selected.name} `.length); } } - } else if (showSlashMenu) { - if (key.upArrow) { + return; + } + + // ── Enter → submit ── + if (key.return) { + doSubmit(); + return; + } + + // ── Arrow keys → menu navigation or history ── + if (key.upArrow) { + if (showMentionMenu) { + setMentionMenuIndex((i) => Math.max(0, i - 1)); + } else if (showSlashMenu) { setSlashMenuIndex((i) => Math.max(0, i - 1)); - } else if (key.downArrow) { + } else { + historyPrev(); + } + return; + } + if (key.downArrow) { + if (showMentionMenu) { + setMentionMenuIndex((i) => Math.min(mention.suggestions.length - 1, i + 1)); + } else if (showSlashMenu) { setSlashMenuIndex((i) => Math.min(menuCount - 1, i + 1)); - } else if (key.tab && !key.shift) { - const selected = getFilteredCommand( - commands, - slashFilter, - slashMenuIndex, - ); - if (selected) { - setValue(`/${selected.name} `); - } + } else { + historyNext(); + } + return; + } + + // ── Left/Right arrows → cursor movement ── + if (key.leftArrow) { + setCursorOffset((prev) => Math.max(0, prev - 1)); + return; + } + if (key.rightArrow) { + setCursorOffset((prev) => Math.min(value.length, prev + 1)); + return; + } + + // ── Backspace ── + if (key.backspace || key.delete) { + if (cursorOffset > 0) { + const next = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev - 1); } - } else { - if (key.upArrow) historyPrev(); - else if (key.downArrow) historyNext(); + return; + } + + // ── Printable character → insert at cursor ── + if (input) { + const next = value.slice(0, cursorOffset) + input + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev + input.length); } }, { isActive: !disabled }, @@ -263,12 +310,15 @@ export function Prompt({ const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; - // Prompt symbol: $ (shell) > 💫 (streaming) > 📋 (plan) > ✨ (default) + // Prompt symbol const promptSymbol = shellMode ? "$ " : isStreaming ? "💫 " : planMode ? "📋 " : "✨ "; + // Render value with fake cursor (matching ink-text-input style) + const renderedValue = renderWithCursor(value, cursorOffset); + return ( - {/* Separator line above input */} + {/* Separator */} {"─".repeat(columns)} {/* Buffered lines (multiline via Ctrl+J) */} @@ -279,15 +329,10 @@ export function Prompt({ ))} - {/* Input line */} + {/* Input line with inline cursor */} {bufferedLines.length > 0 ? " " : promptSymbol} - + {renderedValue} {/* Slash command menu */} @@ -309,3 +354,14 @@ export function Prompt({ ); } + +/** Render text with a fake inverse cursor at the given offset. */ +function renderWithCursor(text: string, offset: number): string { + if (text.length === 0) { + return chalk.inverse(" "); + } + const before = text.slice(0, offset); + const cursorChar = offset < text.length ? text[offset]! : " "; + const after = offset < text.length ? text.slice(offset + 1) : ""; + return before + chalk.inverse(cursorChar) + after; +} diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 29e6578c1..8fb1abc8b 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -12,7 +12,7 @@ * - StatusBar: always at bottom */ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState, useRef } from "react"; import { Box, useApp, useStdout } from "ink"; import { MessageList } from "./Visualize.tsx"; import { Prompt } from "./Prompt.tsx"; @@ -23,7 +23,6 @@ import { CommandPanel } from "../components/CommandPanel.tsx"; import { useGitStatus } from "../hooks/useGitStatus.ts"; import { StreamingSpinner, CompactionSpinner } from "../components/Spinner.tsx"; import { useWire } from "../hooks/useWire.ts"; -import { useKeyboard } from "./keyboard.ts"; import { createShellSlashCommands, parseSlashCommand, @@ -31,6 +30,7 @@ import { } from "./slash.ts"; import { setActiveTheme } from "../theme.ts"; import type { WireUIEvent } from "./events.ts"; +import type { KeyAction } from "./keyboard.ts"; import type { ApprovalResponseKind } from "../../wire/types.ts"; import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; @@ -39,34 +39,6 @@ import { join } from "node:path"; const INPUT_MIN_HEIGHT = 6; -/** - * Paste text from clipboard (matches Python Ctrl+V behavior). - * macOS: pbpaste, Linux: xclip/xsel/wl-paste - */ -async function pasteFromClipboard( - setPasteText: (text: string | undefined) => void, - pushNotification: (title: string, body: string) => void, -): Promise { - const commands = process.platform === "darwin" - ? [["pbpaste"]] - : [["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"], ["wl-paste"]]; - - for (const cmd of commands) { - try { - const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" }); - const text = await new Response(proc.stdout).text(); - const code = await proc.exited; - if (code === 0 && text) { - setPasteText(text); - // Reset after a tick so the effect fires - setTimeout(() => setPasteText(undefined), 0); - return; - } - } catch { /* try next */ } - } - pushNotification("Paste", "Clipboard is empty or not available."); -} - /** * Run a shell command in foreground (matches Python _run_shell_command). */ @@ -191,8 +163,14 @@ export function Shell({ const [activePanel, setActivePanel] = useState(null); const [clearInputSignal, setClearInputSignal] = useState(0); const [shellMode, setShellMode] = useState(false); - const [newlineSignal, setNewlineSignal] = useState(0); - const [pasteText, setPasteText] = useState(undefined); + + // Ctrl+C / Esc double-press tracking (moved from keyboard.ts) + const ctrlCCount = useRef(0); + const ctrlCTimer = useRef | null>(null); + const escCount = useRef(0); + const escTimer = useRef | null>(null); + const CTRLC_WINDOW = 2000; + const ESC_WINDOW = 500; // Wire state const wire = useWire({ onReady: onWireReady }); @@ -238,23 +216,40 @@ export function Shell({ }; }, [stdout]); - // Slash commands allowed in shell mode - const SHELL_MODE_COMMANDS = new Set(["clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?"]); + // Handle actions from Prompt's unified useInput + const handleAction = useCallback( + (action: KeyAction) => { + // Reset the OTHER counter on any action + if (action === "interrupt") { + // Could be Ctrl+C or Esc — we track both the same way now. + // Check Ctrl+C double-press for exit + ctrlCCount.current += 1; + if (ctrlCCount.current >= 2) { + ctrlCCount.current = 0; + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + exit(); + return; + } + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + ctrlCTimer.current = setTimeout(() => { + ctrlCCount.current = 0; + }, CTRLC_WINDOW); + + // Do the interrupt + if (activePanel) { + setActivePanel(null); + } else if (wire.isStreaming) { + onInterrupt?.(); + wire.pushEvent({ type: "error", message: "Interrupted by user" }); + } + pushNotification("Ctrl-C", "Press Ctrl-C again to exit"); + return; + } + + // Any non-interrupt action resets Ctrl+C counter + ctrlCCount.current = 0; - // Global keyboard handling - useKeyboard({ - onAction: (action) => { switch (action) { - case "interrupt": - if (activePanel) { - setActivePanel(null); - } else if (wire.isStreaming) { - onInterrupt?.(); - wire.pushEvent({ type: "error", message: "Interrupted by user" }); - } - // Always show exit hint on Ctrl+C - pushNotification("Ctrl-C", "Press Ctrl-C again to exit"); - break; case "clear-input": setClearInputSignal((n) => n + 1); break; @@ -282,17 +277,13 @@ export function Shell({ case "open-editor": openExternalEditor(pushNotification, onSubmit); break; - case "newline": - setNewlineSignal((n) => n + 1); - break; - case "paste-clipboard": - pasteFromClipboard(setPasteText, pushNotification); - break; - // "exit" is handled internally by useKeyboard (calls exit()) } }, - active: true, - }); + [activePanel, wire, onInterrupt, onPlanModeToggle, onSubmit, pushNotification, exit], + ); + + // Slash commands allowed in shell mode + const SHELL_MODE_COMMANDS = new Set(["clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?"]); // Handle user input const handleSubmit = useCallback( @@ -422,6 +413,7 @@ export function Shell({ )} From 15544af0fe1776c6e4aa786f87677e9843d3441f Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 01:13:55 +0800 Subject: [PATCH 12/28] feat(ui): add interactive panels for slash commands, Ctrl+V image paste, and bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add choice panels for /editor, /sessions, /model - Add input panels for /title, /feedback - Add content panels for /changelog, /debug, /hooks, /mcp - Implement Ctrl+V clipboard image paste (macOS) - Fix history ↑↓ triggering slash menu - Add /yolo toggle feedback text - Sort slash menu alphabetically - Wire all panels in kimisoul.ts with onReload callback Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/cli/index.ts | 2 +- src/kimi_cli_ts/soul/kimisoul.ts | 33 +++- src/kimi_cli_ts/ui/components/SlashMenu.tsx | 2 +- src/kimi_cli_ts/ui/shell/commands/editor.ts | 27 ++++ src/kimi_cli_ts/ui/shell/commands/feedback.ts | 21 +++ src/kimi_cli_ts/ui/shell/commands/info.ts | 79 +++++++++- src/kimi_cli_ts/ui/shell/commands/session.ts | 144 ++++++++++++++++++ src/kimi_cli_ts/utils/clipboard.ts | 104 +++++++++++++ 8 files changed, 404 insertions(+), 8 deletions(-) diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index 9e9e65d86..5490792ff 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -436,7 +436,7 @@ program sessionTitle: app.session.title, thinking: app.soul.thinking, prefillText: currentPrefillText, - onSubmit: (input: string) => { + onSubmit: (input: string | ContentPart[]) => { app.soul.run(input).catch((err: Error) => { pushEvent?.({ type: "error", message: err.message }); }); diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index f57e33de9..2ba10dadc 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -19,15 +19,15 @@ import type { DynamicInjection, DynamicInjectionProvider } from "./dynamic_injec import { normalizeHistory } from "./dynamic_injection.ts"; import { PlanModeInjectionProvider } from "./dynamic_injections/plan_mode.ts"; import { YoloModeInjectionProvider } from "./dynamic_injections/yolo_mode.ts"; -import { handleNew, handleSessions, handleTitle } from "../ui/shell/commands/session.ts"; +import { handleNew, handleSessions, handleTitle, createSessionsPanel, createTitlePanel } from "../ui/shell/commands/session.ts"; import { handleModel, createModelPanel } from "../ui/shell/commands/model.ts"; import { handleLogin, handleLogout, createLoginPanel } from "../ui/shell/commands/login.ts"; -import { handleHooks, handleMcp, handleDebug, handleChangelog } from "../ui/shell/commands/info.ts"; +import { handleHooks, handleMcp, handleDebug, handleChangelog, createHooksPanel, createMcpPanel, createDebugPanel, createChangelogPanel } from "../ui/shell/commands/info.ts"; import { handleExport, handleImport } from "../ui/shell/commands/export_import.ts"; import { handleWeb, handleVis, handleReload, handleTask } from "../ui/shell/commands/misc.ts"; import { handleUsage } from "../ui/shell/commands/usage.ts"; -import { handleFeedback } from "../ui/shell/commands/feedback.ts"; -import { handleEditor } from "../ui/shell/commands/editor.ts"; +import { handleFeedback, createFeedbackPanel } from "../ui/shell/commands/feedback.ts"; +import { handleEditor, createEditorPanel } from "../ui/shell/commands/editor.ts"; import { handleInit } from "../ui/shell/commands/init.ts"; import { handleAddDir } from "../ui/shell/commands/add_dir.ts"; import { logger } from "../utils/logging.ts"; @@ -98,6 +98,7 @@ export interface SoulCallbacks { onCompactionEnd?: () => void; onError?: (error: Error) => void; onNotification?: (title: string, body: string) => void; + onReload?: (sessionId: string, prefillText?: string) => void; } // ── Retry helpers ─────────────────────────────────── @@ -904,14 +905,23 @@ export class KimiSoul { this.agent.runtime.config.default_model || undefined, ); }; + feedbackCmd.panel = () => createFeedbackPanel( + this.agent.runtime.config, + this.agent.runtime.session.id, + this.agent.runtime.config.default_model || undefined, + (t, b) => this.notify(t, b), + ); } // Wire /editor const editorCmd = registry.get("editor"); if (editorCmd) { + const editorNotify = (t: string, b: string) => this.notify(t, b); + const editorConfigMeta = { isFromDefaultLocation: true, sourceFile: null }; editorCmd.handler = async (args: string) => { - await handleEditor(this.agent.runtime.config, { isFromDefaultLocation: true, sourceFile: null }, args); + await handleEditor(this.agent.runtime.config, editorConfigMeta, args); }; + editorCmd.panel = () => createEditorPanel(this.agent.runtime.config, editorConfigMeta, editorNotify); } // Wire /hooks @@ -920,6 +930,7 @@ export class KimiSoul { hooksCmd.handler = async () => { handleHooks(this.agent.runtime.hookEngine); }; + hooksCmd.panel = () => createHooksPanel(this.agent.runtime.hookEngine); } // Wire /mcp @@ -928,6 +939,7 @@ export class KimiSoul { mcpCmd.handler = async () => { handleMcp(this.agent.runtime.config); }; + mcpCmd.panel = () => createMcpPanel(this.agent.runtime.config); } // Wire /debug @@ -936,6 +948,7 @@ export class KimiSoul { debugCmd.handler = async () => { handleDebug(this.context); }; + debugCmd.panel = () => createDebugPanel(this.context); } // Wire /changelog @@ -944,6 +957,7 @@ export class KimiSoul { changelogCmd.handler = async () => { handleChangelog(); }; + changelogCmd.panel = () => createChangelogPanel(); } // Wire /new @@ -960,6 +974,11 @@ export class KimiSoul { sessionsCmd.handler = async () => { await handleSessions(this.agent.runtime.session); }; + sessionsCmd.panel = () => createSessionsPanel( + this.agent.runtime.session, + (t, b) => this.notify(t, b), + (id, prefill) => this.callbacks.onReload?.(id, prefill), + ); } // Wire /title @@ -968,6 +987,10 @@ export class KimiSoul { titleCmd.handler = async (args: string) => { await handleTitle(this.agent.runtime.session, args); }; + titleCmd.panel = () => createTitlePanel( + this.agent.runtime.session, + (t, b) => this.notify(t, b), + ); } // Wire /init diff --git a/src/kimi_cli_ts/ui/components/SlashMenu.tsx b/src/kimi_cli_ts/ui/components/SlashMenu.tsx index 6693c605e..171f622e4 100644 --- a/src/kimi_cli_ts/ui/components/SlashMenu.tsx +++ b/src/kimi_cli_ts/ui/components/SlashMenu.tsx @@ -91,7 +91,7 @@ function filterCommands( commands: SlashCommand[], filter: string, ): SlashCommand[] { - if (!filter) return commands; + if (!filter) return [...commands].sort((a, b) => a.name.localeCompare(b.name)); const lower = filter.toLowerCase(); return commands diff --git a/src/kimi_cli_ts/ui/shell/commands/editor.ts b/src/kimi_cli_ts/ui/shell/commands/editor.ts index bb8091deb..b36182c8c 100644 --- a/src/kimi_cli_ts/ui/shell/commands/editor.ts +++ b/src/kimi_cli_ts/ui/shell/commands/editor.ts @@ -1,7 +1,34 @@ import { loadConfig, saveConfig, type Config, type ConfigMeta } from "../../../config.ts"; +import type { CommandPanelConfig } from "../../../types.ts"; import { logger } from "../../../utils/logging.ts"; import { which } from "bun"; +type Notify = (title: string, body: string) => void; + +export function createEditorPanel(config: Config, configMeta: ConfigMeta, notify: Notify): CommandPanelConfig { + const current = config.default_editor || ""; + return { + type: "choice", + title: "Select Editor", + items: [ + { label: "VS Code (code --wait)", value: "code --wait", current: current === "code --wait" }, + { label: "Vim", value: "vim", current: current === "vim" }, + { label: "Nano", value: "nano", current: current === "nano" }, + { label: "Auto-detect ($VISUAL/$EDITOR)", value: "", current: !current }, + ], + onSelect: (value: string) => { + // Save to config + try { + config.default_editor = value; + saveConfig(config, configMeta.sourceFile ?? undefined); + notify("Editor", `Editor set to: ${value || "auto-detect"}`); + } catch (err: any) { + notify("Editor", `Failed to save: ${err?.message ?? err}`); + } + }, + }; +} + export async function handleEditor(config: Config, configMeta: ConfigMeta, args: string): Promise { const currentEditor = config.default_editor; diff --git a/src/kimi_cli_ts/ui/shell/commands/feedback.ts b/src/kimi_cli_ts/ui/shell/commands/feedback.ts index 2df5416ff..4830535d7 100644 --- a/src/kimi_cli_ts/ui/shell/commands/feedback.ts +++ b/src/kimi_cli_ts/ui/shell/commands/feedback.ts @@ -1,5 +1,6 @@ import { loadTokens } from "../../../auth/oauth.ts"; import type { Config } from "../../../config.ts"; +import type { CommandPanelConfig } from "../../../types.ts"; import { logger } from "../../../utils/logging.ts"; import { platform, release } from "node:os"; @@ -65,3 +66,23 @@ export async function handleFeedback( logger.info(`Please submit at: ${ISSUE_URL}`); } } + +type Notify = (title: string, body: string) => void; + +export function createFeedbackPanel( + config: Config, + sessionId: string, + modelKey: string | undefined, + notify: Notify, +): CommandPanelConfig { + return { + type: "input", + title: "Submit Feedback", + placeholder: "Describe your issue or suggestion...", + onSubmit: (value: string) => { + handleFeedback(config, value, sessionId, modelKey).catch(() => { + notify("Feedback", `Please submit at: ${ISSUE_URL}`); + }); + }, + }; +} diff --git a/src/kimi_cli_ts/ui/shell/commands/info.ts b/src/kimi_cli_ts/ui/shell/commands/info.ts index f989d8c20..79e143066 100644 --- a/src/kimi_cli_ts/ui/shell/commands/info.ts +++ b/src/kimi_cli_ts/ui/shell/commands/info.ts @@ -6,7 +6,7 @@ import type { HookEngine } from "../../../hooks/engine.ts"; import type { Config } from "../../../config.ts"; import type { Context } from "../../../soul/context.ts"; -import type { ContentPart } from "../../../types.ts"; +import type { ContentPart, CommandPanelConfig } from "../../../types.ts"; import { CHANGELOG } from "../../../utils/changelog.ts"; import { logger } from "../../../utils/logging.ts"; @@ -79,3 +79,80 @@ export function handleChangelog(): void { logger.info(""); } } + +// ── Panel factory functions ───────────────────────────── + +export function createChangelogPanel(): CommandPanelConfig { + const lines: string[] = [" Release Notes:", ""]; + for (const [version, entry] of Object.entries(CHANGELOG)) { + lines.push(` ${version}: ${entry.description}`); + for (const item of entry.entries) { + lines.push(` \u2022 ${item}`); + } + lines.push(""); + } + return { type: "content", title: "Release Notes", content: lines.join("\n") }; +} + +export function createDebugPanel(context: Context): CommandPanelConfig { + const history = context.history; + if (!history.length) { + return { type: "content", title: "Context Debug", content: "Context is empty - no messages yet." }; + } + + const lines: string[] = []; + lines.push(`=== Context Debug ===`); + lines.push(`Total messages: ${history.length}`); + lines.push(`Token count: ${context.tokenCountWithPending}`); + lines.push(`---`); + + for (let i = 0; i < history.length; i++) { + const msg = history[i]!; + const role = msg.role.toUpperCase(); + + if (typeof msg.content === "string") { + const preview = + msg.content.length > 200 + ? msg.content.slice(0, 200) + "..." + : msg.content; + lines.push(`#${i + 1} [${role}] ${preview}`); + } else if (Array.isArray(msg.content)) { + const parts = msg.content as ContentPart[]; + const summary = parts + .map((p: any) => { + if (p.type === "text") + return p.text.length > 100 ? p.text.slice(0, 100) + "..." : p.text; + if (p.type === "tool_use") return `[tool_use: ${p.name}]`; + if (p.type === "tool_result") return `[tool_result]`; + if (p.type === "image") return `[image]`; + return `[${p.type}]`; + }) + .join(" | "); + lines.push(`#${i + 1} [${role}] ${summary}`); + } + } + lines.push(`=== End Debug ===`); + return { type: "content", title: "Context Debug", content: lines.join("\n") }; +} + +export function createHooksPanel(hookEngine: HookEngine): CommandPanelConfig { + const summary = hookEngine.summary; + if (!Object.keys(summary).length) { + return { type: "content", title: "Hooks", content: "No hooks configured. Add [[hooks]] sections to config.toml." }; + } + const lines: string[] = ["Configured Hooks:"]; + for (const [event, count] of Object.entries(summary)) { + lines.push(` ${event}: ${count} hook(s)`); + } + return { type: "content", title: "Hooks", content: lines.join("\n") }; +} + +export function createMcpPanel(config: Config): CommandPanelConfig { + const lines: string[] = [ + "MCP Configuration:", + ` Client timeout: ${config.mcp.client.tool_call_timeout_ms}ms`, + "", + "Note: MCP server management available via 'kimi mcp' CLI commands.", + ]; + return { type: "content", title: "MCP", content: lines.join("\n") }; +} diff --git a/src/kimi_cli_ts/ui/shell/commands/session.ts b/src/kimi_cli_ts/ui/shell/commands/session.ts index 853d005b1..35337e183 100644 --- a/src/kimi_cli_ts/ui/shell/commands/session.ts +++ b/src/kimi_cli_ts/ui/shell/commands/session.ts @@ -3,7 +3,127 @@ */ import { Session, loadSessionState, saveSessionState } from "../../../session.ts"; +import type { CommandPanelConfig } from "../../../types.ts"; import { logger } from "../../../utils/logging.ts"; +import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { getShareDir } from "../../../config.ts"; + +type Notify = (title: string, body: string) => void; +type TriggerReload = (sessionId: string, prefillText?: string) => void; + +function getSessionsDirSync(workDir: string): string { + const pathMd5 = createHash("md5").update(workDir, "utf-8").digest("hex"); + return join(getShareDir(), "sessions", pathMd5); +} + +function formatRelativeTimeMs(timestampMs: number): string { + if (!timestampMs) return "unknown"; + const diff = (Date.now() - timestampMs) / 1000; + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +export function createSessionsPanel( + session: Session, + notify: Notify, + triggerReload: TriggerReload, +): CommandPanelConfig | null { + const sessionsDir = getSessionsDirSync(session.workDir); + if (!existsSync(sessionsDir)) return null; + + let entries: string[]; + try { + entries = readdirSync(sessionsDir); + } catch { + return null; + } + + interface SessionInfo { + id: string; + title: string; + updatedAt: number; + } + + const sessions: SessionInfo[] = []; + for (const entry of entries) { + const sessionDir = join(sessionsDir, entry); + const contextFile = join(sessionDir, "context.jsonl"); + if (!existsSync(contextFile)) continue; + + let title = "Untitled"; + let updatedAt = 0; + try { + const stat = statSync(contextFile); + updatedAt = stat.mtimeMs; + } catch { /* ignore */ } + + // Try state file for custom_title + const stateFile = join(sessionDir, "state.json"); + if (existsSync(stateFile)) { + try { + const stateData = JSON.parse(readFileSync(stateFile, "utf-8")); + if (stateData.custom_title) { + title = stateData.custom_title; + } + } catch { /* ignore */ } + } + + // If no custom title, try wire file for first user input + if (title === "Untitled") { + const wireFile = join(sessionDir, "wire.jsonl"); + if (existsSync(wireFile)) { + try { + const text = readFileSync(wireFile, "utf-8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed); + if (record.type === "turn_begin" && record.user_input) { + const raw = typeof record.user_input === "string" + ? record.user_input + : JSON.stringify(record.user_input); + title = raw.slice(0, 50); + break; + } + } catch { continue; } + } + } catch { /* ignore */ } + } + } + + sessions.push({ id: entry, title, updatedAt }); + } + + sessions.sort((a, b) => b.updatedAt - a.updatedAt); + + if (sessions.length === 0) return null; + + const items = sessions.map((s) => ({ + label: s.title, + value: s.id, + description: `${formatRelativeTimeMs(s.updatedAt)}${s.id === session.id ? " (current)" : ""}`, + current: s.id === session.id, + })); + + return { + type: "choice", + title: "Switch Session", + items, + onSelect: (value: string) => { + if (value === session.id) { + notify("Sessions", "Already in this session."); + return; + } + triggerReload(value); + notify("Sessions", `Switching to session: ${value}`); + }, + }; +} export async function handleNew(session: Session): Promise { const workDir = session.workDir; @@ -42,6 +162,30 @@ export async function handleTitle(session: Session, args: string): Promise logger.info(`Session title set to: ${newTitle}`); } +export function createTitlePanel( + session: Session, + notify: Notify, +): CommandPanelConfig { + return { + type: "input", + title: "Set Session Title", + placeholder: session.title || "Enter a title...", + onSubmit: (value: string) => { + const newTitle = value.trim().slice(0, 200); + if (!newTitle) return; + loadSessionState(session.dir).then((state) => { + state.custom_title = newTitle; + state.title_generated = true; + saveSessionState(state, session.dir).then(() => { + session.state.custom_title = newTitle; + session.title = newTitle; + notify("Title", `Session title set to: ${newTitle}`); + }); + }); + }, + }; +} + function formatRelativeTime(timestamp: number): string { if (!timestamp) return "unknown"; const now = Date.now() / 1000; diff --git a/src/kimi_cli_ts/utils/clipboard.ts b/src/kimi_cli_ts/utils/clipboard.ts index d9bc13f3c..056b23f5d 100644 --- a/src/kimi_cli_ts/utils/clipboard.ts +++ b/src/kimi_cli_ts/utils/clipboard.ts @@ -3,6 +3,9 @@ * Clipboard access for media (images, files) via system commands. */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; import { logger } from "./logging.ts"; export interface ClipboardResult { @@ -11,6 +14,11 @@ export interface ClipboardResult { readonly text?: string; } +export interface ClipboardMedia { + images: string[]; // paths to saved image files + files: string[]; // paths to clipboard file references +} + /** * Check if clipboard text access is available. */ @@ -92,3 +100,99 @@ export async function writeClipboardText(text: string): Promise { return false; } } + +/** + * Grab media (images) from the clipboard. + * On macOS, uses osascript to check clipboard info and save image data. + * Returns null if no image data is found or on unsupported platforms. + */ +export async function grabMediaFromClipboard(): Promise { + // Only macOS supported for now + if (process.platform !== "darwin") return null; + + try { + // 1. Check clipboard content type via osascript + const infoProc = Bun.spawn(["osascript", "-e", "clipboard info"], { + stdout: "pipe", + stderr: "ignore", + }); + const info = await new Response(infoProc.stdout).text(); + await infoProc.exited; + + // 2. Check for file URLs first (Finder copy) + const hasFileUrl = /«class furl»|public\.file-url/.test(info); + if (hasFileUrl) { + const fileScript = ` + try + set theFiles to the clipboard as «class furl» + return POSIX path of theFiles + on error + return "" + end try + `; + const fileProc = Bun.spawn(["osascript", "-e", fileScript], { + stdout: "pipe", + stderr: "ignore", + }); + const fileResult = (await new Response(fileProc.stdout).text()).trim(); + await fileProc.exited; + + if (fileResult) { + const filePaths = fileResult.split("\n").map((p) => p.trim()).filter(Boolean); + // Classify: images vs other files + const imageExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"]); + const images: string[] = []; + const files: string[] = []; + for (const fp of filePaths) { + const ext = fp.slice(fp.lastIndexOf(".")).toLowerCase(); + if (imageExts.has(ext)) { + images.push(fp); + } else { + files.push(fp); + } + } + if (images.length > 0 || files.length > 0) { + return { images, files }; + } + } + } + + // 3. Check for raw image data (TIFF, PNG) — e.g. from screenshots + const hasImage = /«class PNGf»|«class TIFF»|TIFF|PNG/.test(info); + if (!hasImage) return null; + + // 4. Save clipboard image to a temp file as PNG + const cacheDir = join(homedir(), ".kimi", "prompt-cache", "images"); + mkdirSync(cacheDir, { recursive: true }); + const filename = `clipboard-${Date.now()}.png`; + const filepath = join(cacheDir, filename); + + const script = ` + set filePath to POSIX file "${filepath}" + try + set imgData to the clipboard as «class PNGf» + set fileRef to open for access filePath with write permission + write imgData to fileRef + close access fileRef + return "${filepath}" + on error + try + close access filePath + end try + return "error" + end try + `; + const saveProc = Bun.spawn(["osascript", "-e", script], { + stdout: "pipe", + stderr: "ignore", + }); + const result = (await new Response(saveProc.stdout).text()).trim(); + await saveProc.exited; + + if (result === "error" || result === "") return null; + return { images: [filepath], files: [] }; + } catch (err) { + logger.debug("Failed to grab media from clipboard: %s", err); + return null; + } +} From 580ecc1c236e41e4ffb73eb8277622015c5545b3 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 01:16:09 +0800 Subject: [PATCH 13/28] fix(ui): add windowed scrolling to ChoicePanel for long lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChoicePanel now limits visible items to fit within terminal height, with ↑↓ more indicators and [n/total] counter when list overflows. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/components/CommandPanel.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/kimi_cli_ts/ui/components/CommandPanel.tsx b/src/kimi_cli_ts/ui/components/CommandPanel.tsx index 3de4df9a9..74def3ba9 100644 --- a/src/kimi_cli_ts/ui/components/CommandPanel.tsx +++ b/src/kimi_cli_ts/ui/components/CommandPanel.tsx @@ -109,6 +109,20 @@ function ChoicePanel({ ), ); + const rows = stdout?.rows ?? 24; + // Reserve lines for: separator + title + separator + status bar (~5 lines overhead) + const maxVisible = Math.max(rows - 8, 5); + const total = items.length; + + // Compute visible window centered on selectedIndex + let start = 0; + if (total > maxVisible) { + start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible)); + } + const visibleItems = items.slice(start, start + maxVisible); + const hasMore = start + maxVisible < total; + const hasPrev = start > 0; + return ( {"─".repeat(columns)} @@ -117,9 +131,16 @@ function ChoicePanel({ {title} (↑↓ select, Enter confirm, Esc cancel) + {total > maxVisible && ( + + {` [${selectedIndex + 1}/${total}]`} + + )} {"─".repeat(columns)} - {items.map((item, i) => { + {hasPrev && ↑ more...} + {visibleItems.map((item, vi) => { + const i = start + vi; const isSelected = i === selectedIndex; return ( @@ -138,6 +159,7 @@ function ChoicePanel({ ); })} + {hasMore && ↓ more...} ); } From 10d08f9fa2eae1e42d3699c671ce7a20576e5d54 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 01:51:55 +0800 Subject: [PATCH 14/28] feat(ts): subagent model alias, concurrent tool exec, static message rendering - Add cloneLlmWithModelAlias() to llm.ts for subagent model overrides - Wire model alias through SubagentBuilder and Runtime.copyForSubagent() - Execute tool calls concurrently in kimisoul via Promise.all - Add UILoopFn and wire event forwarding scaffold in subagent runner - Use Ink for completed messages to enable mouse text selection - Add tools/utils.ts with loadDesc() helper, share description.md with Python - Fix subagent context resume to properly override agent system prompt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/llm.ts | 53 ++++++++++++++ src/kimi_cli_ts/soul/agent.ts | 16 ++++- src/kimi_cli_ts/soul/kimisoul.ts | 17 +++-- src/kimi_cli_ts/subagents/builder.ts | 20 ++++-- src/kimi_cli_ts/subagents/core.ts | 16 +++-- src/kimi_cli_ts/subagents/runner.ts | 96 ++++++++++++++++++++++++++ src/kimi_cli_ts/tools/agent/agent.ts | 31 ++++----- src/kimi_cli_ts/tools/utils.ts | 30 ++++++++ src/kimi_cli_ts/ui/shell/Shell.tsx | 22 ++++-- src/kimi_cli_ts/ui/shell/Visualize.tsx | 14 ++++ 10 files changed, 274 insertions(+), 41 deletions(-) create mode 100644 src/kimi_cli_ts/tools/utils.ts diff --git a/src/kimi_cli_ts/llm.ts b/src/kimi_cli_ts/llm.ts index b75460aa7..6b7e07acf 100644 --- a/src/kimi_cli_ts/llm.ts +++ b/src/kimi_cli_ts/llm.ts @@ -295,6 +295,59 @@ export function estimateMessagesTokenCount(messages: Message[]): number { // ── Factory (placeholder providers) ──────────────────────── +/** + * Clone an LLM with a different model alias from the config. + * Returns the original LLM if modelAlias is null/undefined, or creates a new + * LLM with the aliased model/provider settings. + * + * Corresponds to Python clone_llm_with_model_alias(). + */ +export function cloneLlmWithModelAlias( + llm: LLM | null, + config: { + models: Record; + providers: Record; env?: Record }>; + }, + modelAlias: string | null | undefined, + opts?: { sessionId?: string }, +): LLM | null { + if (modelAlias == null) return llm; + + const mc = config.models[modelAlias]; + if (!mc) { + throw new Error(`Unknown model alias: ${modelAlias}`); + } + const pc = config.providers[mc.provider]; + if (!pc) { + throw new Error(`Unknown provider: ${mc.provider} for model alias: ${modelAlias}`); + } + + // Convert snake_case config to camelCase LLM interface + const providerConfig: LLMProviderConfig = { + type: pc.type as ProviderType, + baseUrl: pc.base_url, + apiKey: pc.api_key, + customHeaders: pc.custom_headers, + env: pc.env, + }; + const modelConfig: LLMModelConfig = { + model: mc.model, + provider: mc.provider, + maxContextSize: mc.max_context_size, + capabilities: mc.capabilities as ModelCapability[] | undefined, + }; + + // Inherit thinking setting from the parent LLM if available + let thinking: boolean | null = null; + if (llm != null) { + if (llm.capabilities.has("thinking") || llm.capabilities.has("always_thinking")) { + thinking = true; + } + } + + return createLLM(providerConfig, modelConfig, { thinking, sessionId: opts?.sessionId }); +} + /** * Create an LLM instance from provider and model config. */ diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts index 72d1ded9a..f1b587f7f 100644 --- a/src/kimi_cli_ts/soul/agent.ts +++ b/src/kimi_cli_ts/soul/agent.ts @@ -44,6 +44,8 @@ export class Runtime { laborMarket: LaborMarket | null; subagentStore: SubagentStore | null; approvalRuntime: ApprovalRuntime | null; + subagentId: string | null; + subagentType: string | null; constructor(opts: { config: Config; @@ -57,6 +59,8 @@ export class Runtime { laborMarket?: LaborMarket | null; subagentStore?: SubagentStore | null; approvalRuntime?: ApprovalRuntime | null; + subagentId?: string | null; + subagentType?: string | null; }) { this.config = opts.config; this.llm = opts.llm; @@ -69,6 +73,8 @@ export class Runtime { this.laborMarket = opts.laborMarket ?? null; this.subagentStore = opts.subagentStore ?? null; this.approvalRuntime = opts.approvalRuntime ?? null; + this.subagentId = opts.subagentId ?? null; + this.subagentType = opts.subagentType ?? null; } get loopControl(): LoopControl { @@ -150,10 +156,14 @@ export class Runtime { } /** Create a copy for subagents with shared state. */ - copyForSubagent(): Runtime { + copyForSubagent(opts: { + agentId: string; + subagentType: string; + llmOverride?: LLM | null; + }): Runtime { return new Runtime({ config: this.config, - llm: this.llm, + llm: opts.llmOverride ?? this.llm, session: this.session, approval: this.approval.share(), hookEngine: this.hookEngine, @@ -167,6 +177,8 @@ export class Runtime { laborMarket: this.laborMarket, subagentStore: this.subagentStore, approvalRuntime: this.approvalRuntime, + subagentId: opts.agentId, + subagentType: opts.subagentType, }); } } diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 2ba10dadc..7c7077d95 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -517,15 +517,22 @@ export class KimiSoul { * once we start appending, we finish even if abort fires. */ private async _executeToolsShielded(toolCalls: ToolCall[]): Promise { - for (const tc of toolCalls) { - // Check abort before each tool, but don't interrupt mid-append - if (this.abortController?.signal.aborted) break; + // Execute all tools concurrently + const results = await Promise.all( + toolCalls.map(async (tc) => { + // Check abort before starting, but don't interrupt mid-execution + if (this.abortController?.signal.aborted) return null; + return { tc, result: await this.agent.toolset.handle(tc) }; + }), + ); - const result = await this.agent.toolset.handle(tc); + // Append results sequentially to maintain context order consistency + for (const entry of results) { + if (!entry) continue; + const { tc, result } = entry; // Detect tool rejection without feedback if (result.isError && result.message?.includes("rejected by the user")) { - // If the rejection message is just the standard template, no user feedback if (!result.extras?.userFeedback) { this._toolRejectedNoFeedback = true; } diff --git a/src/kimi_cli_ts/subagents/builder.ts b/src/kimi_cli_ts/subagents/builder.ts index ee5d465dc..ba5b4d285 100644 --- a/src/kimi_cli_ts/subagents/builder.ts +++ b/src/kimi_cli_ts/subagents/builder.ts @@ -4,6 +4,7 @@ */ import { type Runtime, type Agent, loadAgent } from "../soul/agent.ts"; +import { cloneLlmWithModelAlias } from "../llm.ts"; import type { AgentLaunchSpec, AgentTypeDefinition } from "./models.ts"; export class SubagentBuilder { @@ -22,16 +23,25 @@ export class SubagentBuilder { typeDef: AgentTypeDefinition; launchSpec: AgentLaunchSpec; }): Promise { - const _effectiveModel = SubagentBuilder.resolveEffectiveModel({ + const effectiveModel = SubagentBuilder.resolveEffectiveModel({ typeDef: opts.typeDef, launchSpec: opts.launchSpec, }); - // Create a subagent runtime copy - const runtime = this._rootRuntime.copyForSubagent(); + // Clone LLM with model alias if effective model differs from root + const llmOverride = cloneLlmWithModelAlias( + this._rootRuntime.llm, + this._rootRuntime.config, + effectiveModel, + { sessionId: this._rootRuntime.session.id }, + ); - // TODO: If effectiveModel differs from root, clone LLM with model alias. - // For now, subagents inherit the root LLM. + // Create a subagent runtime copy + const runtime = this._rootRuntime.copyForSubagent({ + agentId: opts.agentId, + subagentType: opts.launchSpec.subagentType, + llmOverride, + }); return await loadAgent({ runtime, diff --git a/src/kimi_cli_ts/subagents/core.ts b/src/kimi_cli_ts/subagents/core.ts index 891813f8f..3b5628b7b 100644 --- a/src/kimi_cli_ts/subagents/core.ts +++ b/src/kimi_cli_ts/subagents/core.ts @@ -4,7 +4,8 @@ import { writeFileSync } from "node:fs"; -import type { Runtime, Agent } from "../soul/agent.ts"; +import type { Runtime } from "../soul/agent.ts"; +import { Agent } from "../soul/agent.ts"; import { KimiSoul } from "../soul/kimisoul.ts"; import { Context } from "../soul/context.ts"; import type { AgentLaunchSpec, AgentTypeDefinition } from "./models.ts"; @@ -33,7 +34,7 @@ export async function prepareSoul( onStage?: (name: string) => void, ): Promise<[KimiSoul, string]> { // 1. Build agent from type definition - const agent = await builder.buildBuiltinInstance({ + let agent = await builder.buildBuiltinInstance({ agentId: spec.agentId, typeDef: spec.typeDef, launchSpec: spec.launchSpec, @@ -47,9 +48,14 @@ export async function prepareSoul( // 3. System prompt: reuse persisted prompt on resume, persist on first run if (context.systemPrompt !== null) { - // On resume, the agent's system prompt is overridden by the persisted one. - // The KimiSoul constructor uses agent.systemPrompt for the initial system, - // but context already has the correct system prompt restored from file. + // On resume, override the agent's system prompt with the persisted one. + agent = new Agent({ + name: agent.name, + systemPrompt: context.systemPrompt, + toolset: agent.toolset, + runtime: agent.runtime, + slashCommands: agent.slashCommands, + }); } else { await context.writeSystemPrompt(agent.systemPrompt); } diff --git a/src/kimi_cli_ts/subagents/runner.ts b/src/kimi_cli_ts/subagents/runner.ts index 22a8f6d5b..2b140626e 100644 --- a/src/kimi_cli_ts/subagents/runner.ts +++ b/src/kimi_cli_ts/subagents/runner.ts @@ -16,6 +16,14 @@ import { SubagentBuilder } from "./builder.ts"; import type { AgentInstanceRecord } from "./models.ts"; import { type SubagentRunSpec, prepareSoul } from "./core.ts"; import type { ApprovalSource } from "../approval_runtime/index.ts"; +import type { Wire } from "../wire/wire_core.ts"; +import type { WireMessage } from "../wire/types.ts"; +import { + ApprovalRequest, + ToolCallRequest, + QuestionRequest, + SubagentEvent, +} from "../wire/types.ts"; import * as hookEvents from "../hooks/events.ts"; import { logger } from "../utils/logging.ts"; @@ -143,6 +151,14 @@ export async function runWithSummaryContinuation( return [finalResponse, null]; } +// ── UI Loop Function Type ──────────────────────────────── + +/** + * A function that reads Wire events and forwards them to the parent. + * Corresponds to Python UILoopFn. + */ +export type UILoopFn = (wire: Wire) => Promise; + // ── ForegroundSubagentRunner ───────────────────────────── export class ForegroundSubagentRunner { @@ -207,6 +223,16 @@ export class ForegroundSubagentRunner { const toolCall = getCurrentToolCallOrNull(); const parentToolCallId = toolCall?.id ?? null; + // Build UI loop function for wire event forwarding + // TODO: Pass _uiLoopFn into soul.run() once the TS Wire integration supports it + const _uiLoopFn = ForegroundSubagentRunner._makeUiLoopFn({ + parentToolCallId, + agentId, + subagentType: actualType, + outputWriter, + superWireSoulSide: null, // TODO: get from parent wire when available + }); + // approvalSource is created inside the try block so the finally // clause can safely skip cancellation if we never got that far. let approvalSource: ApprovalSource | null = null; @@ -309,6 +335,76 @@ export class ForegroundSubagentRunner { } } + /** + * Create a UI loop function that forwards subagent wire events to the parent wire. + * Corresponds to Python ForegroundSubagentRunner._make_ui_loop_fn(). + * + * The returned function reads from the subagent's Wire and: + * - Writes all messages to the output file + * - Forwards ApprovalRequest/ToolCallRequest/QuestionRequest directly to parent wire + * - Wraps other events as SubagentEvent before forwarding + * - Skips HookRequest (handled internally) + * + * TODO: Wire the returned UILoopFn into soul.run() once the TS Wire system + * supports get_wire_or_none() and run_soul() accepts a ui_loop_fn parameter. + */ + static _makeUiLoopFn(opts: { + parentToolCallId: string | null; + agentId: string; + subagentType: string; + outputWriter: SubagentOutputWriter; + superWireSoulSide?: { send(msg: WireMessage): void } | null; + }): UILoopFn { + const { parentToolCallId, agentId, subagentType, outputWriter, superWireSoulSide } = opts; + + return async (wire: Wire): Promise => { + const wireUi = wire.uiSide(true); + while (true) { + let msg: WireMessage; + try { + msg = await wireUi.receive(); + } catch { + // Queue shut down — wire was closed + break; + } + + // Always write to output file regardless of wire availability + // TODO: outputWriter.writeWireMessage(msg) — not yet implemented + logger.debug(`[subagent:${agentId}] wire event: ${(msg as any)?.type ?? "unknown"}`); + + if (superWireSoulSide == null || parentToolCallId == null) { + continue; + } + + // Forward approval/tool call/question requests directly to parent + const msgType = (msg as any)?.type; + if ( + msgType === "ApprovalRequest" || + msgType === "ApprovalResponse" || + msgType === "ToolCallRequest" || + msgType === "QuestionRequest" + ) { + superWireSoulSide.send(msg); + continue; + } + + // Skip hook requests — handled internally + if (msgType === "HookRequest") { + continue; + } + + // Wrap all other events as SubagentEvent + superWireSoulSide.send({ + type: "SubagentEvent", + parent_tool_call_id: parentToolCallId, + agent_id: agentId, + subagent_type: subagentType, + event: msg, + } as unknown as WireMessage); + } + }; + } + private async _prepareInstance(req: ForegroundRunRequest): Promise { if (req.resume) { const record = this._store.requireInstance(req.resume); diff --git a/src/kimi_cli_ts/tools/agent/agent.ts b/src/kimi_cli_ts/tools/agent/agent.ts index 705b7655b..3a68a4b37 100644 --- a/src/kimi_cli_ts/tools/agent/agent.ts +++ b/src/kimi_cli_ts/tools/agent/agent.ts @@ -3,10 +3,12 @@ * Corresponds to Python tools/agent/__init__.py */ +import path from "node:path"; import { z } from "zod/v4"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; import { ToolError, ToolOk } from "../types.ts"; +import { loadDesc } from "../utils.ts"; import type { Runtime } from "../../soul/agent.ts"; import type { AgentTypeDefinition } from "../../subagents/models.ts"; import { @@ -15,19 +17,15 @@ import { } from "../../subagents/runner.ts"; import { logger } from "../../utils/logging.ts"; +// Reuse the Python version's description.md — single source of truth +const DESCRIPTION_MD_PATH = path.resolve( + import.meta.dir, + "../../../kimi_cli/tools/agent/description.md", +); + const MAX_FOREGROUND_TIMEOUT = 60 * 60; // 1 hour const MAX_BACKGROUND_TIMEOUT = 60 * 60; // 1 hour -const DESCRIPTION = `Start a subagent instance to work on a focused task. - -**Usage:** -- Always provide a short \`description\` (3-5 words). -- Use \`subagent_type\` to select a built-in agent type. If omitted, \`coder\` is used. -- Use \`model\` when you need to override the default model. -- Default to foreground execution. Use \`run_in_background=true\` only when needed. -- Be explicit about whether the subagent should write code or only do research. -- The subagent result is only visible to you. If the user should see it, summarize it yourself.`; - const ParamsSchema = z.object({ description: z .string() @@ -85,19 +83,18 @@ export class AgentTool extends CallableTool { constructor() { super(); - this._description = DESCRIPTION; + // Placeholder until buildDescription() is called with runtime + this._description = "Start a subagent instance to work on a focused task."; } /** - * Build the full description including available builtin types. + * Build the full description from description.md, including available builtin types. * Should be called after the tool is registered and runtime is available. */ buildDescription(runtime: Runtime): void { - const typeLines = AgentTool._builtinTypeLines(runtime); - if (typeLines) { - this._description = - DESCRIPTION + "\n\n**Available subagent types:**\n" + typeLines; - } + this._description = loadDesc(DESCRIPTION_MD_PATH, { + BUILTIN_AGENT_TYPES_MD: AgentTool._builtinTypeLines(runtime), + }); } private static _builtinTypeLines(runtime: Runtime): string { diff --git a/src/kimi_cli_ts/tools/utils.ts b/src/kimi_cli_ts/tools/utils.ts new file mode 100644 index 000000000..402a41c83 --- /dev/null +++ b/src/kimi_cli_ts/tools/utils.ts @@ -0,0 +1,30 @@ +/** + * Tool utilities — shared helpers for tool implementations. + * Corresponds to Python tools/utils.py (partial). + */ + +/** + * Load a tool description from a file and perform simple `${VAR}` substitution. + * + * Variables in the template use `${VARIABLE_NAME}` syntax. Any variable not + * present in the context map is left as-is (mirrors Python's + * _KeepPlaceholderUndefined behaviour). + */ +export function loadDesc( + path: string, + context?: Record, +): string { + const file = Bun.file(path); + // Bun.file().text() is async; use the sync node:fs fallback for simplicity + // since this is only called at init time. + const fs = require("node:fs") as typeof import("node:fs"); + let text = fs.readFileSync(path, "utf-8"); + + if (context) { + for (const [key, value] of Object.entries(context)) { + text = text.replaceAll(`\${${key}}`, value); + } + } + + return text; +} diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 8fb1abc8b..646fd766d 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -13,8 +13,8 @@ */ import React, { useCallback, useEffect, useState, useRef } from "react"; -import { Box, useApp, useStdout } from "ink"; -import { MessageList } from "./Visualize.tsx"; +import { Box, Static, useApp, useStdout } from "ink"; +import { MessageList, StaticMessageView } from "./Visualize.tsx"; import { Prompt } from "./Prompt.tsx"; import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; @@ -379,12 +379,20 @@ export function Shell({ tip="Spot a bug or have feedback? Type /feedback right in this session — every report makes Kimi better." /> - {/* ═══ ChatList: height follows content ═══ */} + {/* ═══ Static messages: rendered once, never re-drawn ═══ */} + {/* This allows users to select & copy completed messages with the mouse */} + + {(msg) => } + + + {/* ═══ Active (streaming) message + spinners ═══ */} - + {wire.isStreaming && wire.messages.length > 0 && ( + + )} {wire.isStreaming && !wire.isCompacting && ( diff --git a/src/kimi_cli_ts/ui/shell/Visualize.tsx b/src/kimi_cli_ts/ui/shell/Visualize.tsx index 4eaf90fad..5c2c3e22a 100644 --- a/src/kimi_cli_ts/ui/shell/Visualize.tsx +++ b/src/kimi_cli_ts/ui/shell/Visualize.tsx @@ -50,6 +50,20 @@ export function MessageList({ messages, isStreaming, stepCount }: MessageListPro ); } +/** + * StaticMessageView — rendered inside , written once and never re-drawn. + * This makes completed messages selectable/copyable with the mouse. + */ +export function StaticMessageView({ message }: { message: UIMessage }) { + return ( + + ); +} + // ── MessageView ──────────────────────────────────────────── interface MessageViewProps { From af250ec243973d3ec0d9b6a0b312676c69079ba4 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 23:21:00 +0800 Subject: [PATCH 15/28] feat(ts): approval UI, subagent registration, renderer, input refactor - Wire ApprovalRuntime + RootWireHub into Runtime and forward approval requests to Shell UI via onApprovalResponse callback - Register builtin subagent types from agent spec at load time (matches Python load_agent behavior) - Add cell-level diffing renderer that patches Ink's log-update to preserve terminal text selection - Extract input handling into input-state.ts / input-stack.ts with layered focus stack for panels - Add PromptView as a pure rendering component split from Prompt - Redirect logger output to disk (session logs.jsonl) instead of stderr - Pass yolo flag through to Shell and return toggle feedback messages --- src/kimi_cli_ts/app.ts | 4 + src/kimi_cli_ts/cli/index.ts | 55 +- src/kimi_cli_ts/soul/agent.ts | 67 ++- src/kimi_cli_ts/soul/kimisoul.ts | 2 + src/kimi_cli_ts/tools/agent/agent.ts | 16 +- src/kimi_cli_ts/tools/shell/shell.ts | 9 + src/kimi_cli_ts/tools/types.ts | 1 + src/kimi_cli_ts/types.ts | 2 +- src/kimi_cli_ts/ui/CLAUDE.md | 112 +++++ .../ui/components/CommandPanel.tsx | 252 ++-------- src/kimi_cli_ts/ui/components/StatusBar.tsx | 164 ++++-- src/kimi_cli_ts/ui/hooks/useInput.ts | 13 + src/kimi_cli_ts/ui/hooks/useWire.ts | 18 + src/kimi_cli_ts/ui/renderer/ansi-parser.ts | 282 +++++++++++ src/kimi_cli_ts/ui/renderer/csi.ts | 83 +++ src/kimi_cli_ts/ui/renderer/diff.ts | 165 ++++++ src/kimi_cli_ts/ui/renderer/index.ts | 328 ++++++++++++ src/kimi_cli_ts/ui/renderer/patch-writer.ts | 312 ++++++++++++ src/kimi_cli_ts/ui/renderer/screen.ts | 241 +++++++++ .../ui/renderer/terminal-detect.ts | 78 +++ src/kimi_cli_ts/ui/renderer/types.ts | 69 +++ src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx | 144 ++++-- src/kimi_cli_ts/ui/shell/Prompt.tsx | 148 ++++-- src/kimi_cli_ts/ui/shell/PromptView.tsx | 86 ++++ src/kimi_cli_ts/ui/shell/Shell.tsx | 447 ++++++----------- src/kimi_cli_ts/ui/shell/Visualize.tsx | 28 +- src/kimi_cli_ts/ui/shell/events.ts | 1 + src/kimi_cli_ts/ui/shell/index.ts | 3 +- src/kimi_cli_ts/ui/shell/input-stack.ts | 111 ++++ src/kimi_cli_ts/ui/shell/input-state.ts | 472 ++++++++++++++++++ src/kimi_cli_ts/utils/logging.ts | 73 ++- 31 files changed, 3117 insertions(+), 669 deletions(-) create mode 100644 src/kimi_cli_ts/ui/CLAUDE.md create mode 100644 src/kimi_cli_ts/ui/renderer/ansi-parser.ts create mode 100644 src/kimi_cli_ts/ui/renderer/csi.ts create mode 100644 src/kimi_cli_ts/ui/renderer/diff.ts create mode 100644 src/kimi_cli_ts/ui/renderer/index.ts create mode 100644 src/kimi_cli_ts/ui/renderer/patch-writer.ts create mode 100644 src/kimi_cli_ts/ui/renderer/screen.ts create mode 100644 src/kimi_cli_ts/ui/renderer/terminal-detect.ts create mode 100644 src/kimi_cli_ts/ui/renderer/types.ts create mode 100644 src/kimi_cli_ts/ui/shell/PromptView.tsx create mode 100644 src/kimi_cli_ts/ui/shell/input-stack.ts create mode 100644 src/kimi_cli_ts/ui/shell/input-state.ts diff --git a/src/kimi_cli_ts/app.ts b/src/kimi_cli_ts/app.ts index 277f24f2d..0836bacb1 100644 --- a/src/kimi_cli_ts/app.ts +++ b/src/kimi_cli_ts/app.ts @@ -198,6 +198,10 @@ export class KimiCLI { session = await Session.create(workDir); } + // Ensure session directory exists and set up disk logging + await session.ensureDir(); + logger.setLogDir(session.dir); + // Store additional dirs in session state if (opts.additionalDirs && opts.additionalDirs.length > 0) { session.state.additional_dirs = opts.additionalDirs.map((d) => diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index 5490792ff..dfd8d022b 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -10,7 +10,9 @@ import { KimiCLI } from "../app.ts"; import type { SoulCallbacks } from "../soul/kimisoul.ts"; import { Shell } from "../ui/shell/Shell.tsx"; import type { WireUIEvent } from "../ui/shell/events.ts"; +import type { ApprovalResponseKind } from "../wire/types.ts"; import chalk from "chalk"; +import { patchInkLogUpdate } from "../ui/renderer/index.ts"; // ── Re-exports from Python cli/__init__.py ────────────── @@ -427,6 +429,10 @@ program callbacks, }); + // Patch Ink's log-update with our cell-level diffing renderer. + // This must happen before render() creates the Ink instance. + patchInkLogUpdate(); + const { waitUntilExit, unmount: inkUnmount } = render( React.createElement(Shell, { modelName: app.soul.modelName, @@ -435,6 +441,7 @@ program sessionDir: app.session.dir, sessionTitle: app.session.title, thinking: app.soul.thinking, + yolo: options.yolo ?? false, prefillText: currentPrefillText, onSubmit: (input: string | ContentPart[]) => { app.soul.run(input).catch((err: Error) => { @@ -447,22 +454,55 @@ program onPlanModeToggle: async () => { return app.soul.togglePlanModeFromManual(); }, - onWireReady: (push) => { - pushEvent = push; + onApprovalResponse: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => { + if (app.soul.runtime.approvalRuntime) { + app.soul.runtime.approvalRuntime.resolve(requestId, decision, feedback); + } + }, + onWireReady: (pe) => { + pushEvent = pe; }, onReload: (newSessionId: string, prefill?: string) => { - // Print resume hint for the old session before switching - printResumeHint(app.session); - // Store reload info and unmount Ink to break out of waitUntilExit pendingReload = { sessionId: newSessionId, prefillText: prefill }; inkUnmount(); }, extraSlashCommands: app.soul.availableSlashCommands, }), - { exitOnCtrlC: false, incrementalRendering: true }, + { exitOnCtrlC: false }, ); unmount = inkUnmount; + // Start background task to forward RootWireHub events to UI + let rootHubQueue: any = null; + if (app.soul.runtime.rootWireHub) { + rootHubQueue = app.soul.runtime.rootWireHub.subscribe(); + (async () => { + try { + while (true) { + const msg = await rootHubQueue.get(); + if ( + msg && + typeof msg === "object" && + "id" in msg && + "tool_call_id" in msg && + "sender" in msg + ) { + // ApprovalRequest + pushEvent?.({ + type: "approval_request", + request: msg as any, + }); + } + } + } catch (err) { + // Queue shutdown or error + if (rootHubQueue) { + app.soul.runtime.rootWireHub?.unsubscribe(rootHubQueue); + } + } + })(); + } + // Run initial prompt if provided (only on first iteration) if (currentPrompt) { app.soul.run(currentPrompt).catch((err: Error) => { @@ -471,6 +511,9 @@ program } await waitUntilExit(); + if (rootHubQueue && app.soul.runtime.rootWireHub) { + app.soul.runtime.rootWireHub.unsubscribe(rootHubQueue); + } await app.shutdown(); // Check if this was a reload (/undo or /fork) diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts index f1b587f7f..670ebf532 100644 --- a/src/kimi_cli_ts/soul/agent.ts +++ b/src/kimi_cli_ts/soul/agent.ts @@ -13,9 +13,13 @@ import { KimiToolset } from "./toolset.ts"; import { SlashCommandRegistry, createDefaultRegistry } from "./slash.ts"; import { Context } from "./context.ts"; import { logger } from "../utils/logging.ts"; -import type { LaborMarket } from "../subagents/registry.ts"; -import type { SubagentStore } from "../subagents/store.ts"; -import type { ApprovalRuntime } from "../approval_runtime/index.ts"; +import { LaborMarket } from "../subagents/registry.ts"; +import { SubagentStore } from "../subagents/store.ts"; +import { ApprovalRuntime } from "../approval_runtime/index.ts"; +import { RootWireHub } from "../wire/root_hub.ts"; +import { loadAgentSpec, getAgentsDir } from "../agentspec.ts"; +import { defaultToolPolicy, type ToolPolicy } from "../subagents/models.ts"; +import { join } from "node:path"; // ── Built-in system prompt args ────────────────────── @@ -44,6 +48,7 @@ export class Runtime { laborMarket: LaborMarket | null; subagentStore: SubagentStore | null; approvalRuntime: ApprovalRuntime | null; + rootWireHub: RootWireHub | null; subagentId: string | null; subagentType: string | null; @@ -59,6 +64,7 @@ export class Runtime { laborMarket?: LaborMarket | null; subagentStore?: SubagentStore | null; approvalRuntime?: ApprovalRuntime | null; + rootWireHub?: RootWireHub | null; subagentId?: string | null; subagentType?: string | null; }) { @@ -73,6 +79,7 @@ export class Runtime { this.laborMarket = opts.laborMarket ?? null; this.subagentStore = opts.subagentStore ?? null; this.approvalRuntime = opts.approvalRuntime ?? null; + this.rootWireHub = opts.rootWireHub ?? null; this.subagentId = opts.subagentId ?? null; this.subagentType = opts.subagentType ?? null; } @@ -144,6 +151,13 @@ export class Runtime { const approval = new Approval({ state: approvalState }); + // Create RootWireHub and ApprovalRuntime, then bind them + // (matches Python Runtime.__post_init__) + const rootWireHub = new RootWireHub(); + const approvalRuntime = new ApprovalRuntime(); + approvalRuntime.bindRootWireHub(rootWireHub); + approval.setRuntime(approvalRuntime); + return new Runtime({ config: opts.config, llm: opts.llm, @@ -152,6 +166,12 @@ export class Runtime { hookEngine: opts.hookEngine, builtinArgs, additionalDirs, + laborMarket: new LaborMarket(), + subagentStore: new SubagentStore( + join(opts.session.dir, "subagents"), + ), + approvalRuntime, + rootWireHub, }); } @@ -177,6 +197,7 @@ export class Runtime { laborMarket: this.laborMarket, subagentStore: this.subagentStore, approvalRuntime: this.approvalRuntime, + rootWireHub: this.rootWireHub, subagentId: opts.agentId, subagentType: opts.subagentType, }); @@ -238,11 +259,12 @@ export async function loadAgent(opts: { context: { workingDir: runtime.session.workDir, signal: new AbortController().signal, - approval: async (toolName: string, _action: string, description: string) => { + approval: async (toolName: string, action: string, description: string, opts?: { display?: unknown[] }) => { const result = await runtime.approval.request( toolName, - toolName, + action, description, + { display: opts?.display }, ); return result.approved ? "approve" : "reject"; }, @@ -268,6 +290,41 @@ export async function loadAgent(opts: { hookEngine: runtime.hookEngine, }); + // Register built-in subagent types from agent spec before loading tools, + // because AgentTool.buildDescription() reads from the labor market. + // Corresponds to Python load_agent() lines 423-442. + if (runtime.laborMarket) { + try { + const agentsDir = getAgentsDir(); + const agentSpecPath = join(agentsDir, agentName, "agent.yaml"); + const agentSpec = await loadAgentSpec(agentSpecPath); + + for (const [subagentName, subagentInfo] of Object.entries(agentSpec.subagents)) { + logger.info(`Registering builtin subagent type: ${subagentName}`); + try { + const subSpec = await loadAgentSpec(subagentInfo.path); + const toolPolicy: ToolPolicy = subSpec.allowedTools != null + ? { mode: "allowlist" as const, tools: subSpec.allowedTools } + : defaultToolPolicy(); + runtime.laborMarket.addBuiltinType({ + name: subagentName, + description: subagentInfo.description, + agentFile: subagentInfo.path, + whenToUse: subSpec.whenToUse, + defaultModel: subSpec.model ?? undefined, + toolPolicy, + supportsBackground: true, + }); + } catch (err) { + logger.warn(`Failed to load subagent spec for "${subagentName}": ${err}`); + } + } + } catch { + // Agent spec not found — no subagent types to register + logger.debug(`No agent spec found for "${agentName}", skipping subagent registration`); + } + } + // Register built-in tools await registerBuiltinTools(toolset, runtime); diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 7c7077d95..6dd4ecf7d 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -771,9 +771,11 @@ export class KimiSoul { if (this.agent.runtime.approval.isYolo()) { this.agent.runtime.approval.setYolo(false); logger.info("YOLO mode: OFF"); + return "You only die once! Actions will require approval."; } else { this.agent.runtime.approval.setYolo(true); logger.info("YOLO mode: ON"); + return "You only live once! All actions will be auto-approved."; } }; } diff --git a/src/kimi_cli_ts/tools/agent/agent.ts b/src/kimi_cli_ts/tools/agent/agent.ts index 3a68a4b37..60e7973f6 100644 --- a/src/kimi_cli_ts/tools/agent/agent.ts +++ b/src/kimi_cli_ts/tools/agent/agent.ts @@ -127,8 +127,8 @@ export class AgentTool extends CallableTool { private static _uniqueToolNames(toolPaths: readonly string[]): string[] { const names: string[] = []; - for (const path of toolPaths) { - const name = path.split(":").pop() ?? path; + for (const toolPath of toolPaths) { + const name = String(toolPath).split(":").pop() ?? String(toolPath); if (!names.includes(name)) { names.push(name); } @@ -202,12 +202,14 @@ export class AgentTool extends CallableTool { private async _runInBackground( params: Params, - _runtime: Runtime, + runtime: Runtime, ): Promise { - // Background agent execution requires the background task system - // which will be implemented as part of the background runner migration. - return ToolError( - "Background subagent execution is not yet implemented in this version.", + // Background agent execution is not yet implemented. + // Fallback to foreground execution so the LLM's intent is still fulfilled + // rather than returning an error that may cause the turn to stall. + logger.warn( + "Background agent requested but not implemented; falling back to foreground.", ); + return this._runForeground(params, runtime); } } diff --git a/src/kimi_cli_ts/tools/shell/shell.ts b/src/kimi_cli_ts/tools/shell/shell.ts index b01a31a7c..28d500bff 100644 --- a/src/kimi_cli_ts/tools/shell/shell.ts +++ b/src/kimi_cli_ts/tools/shell/shell.ts @@ -130,6 +130,15 @@ export class Shell extends CallableTool { "Shell", "run command", `Run command \`${params.command}\``, + { + display: [ + { + type: "shell" as const, + language: "bash", + command: params.command, + }, + ], + }, ); if (decision === "reject") { return ToolError( diff --git a/src/kimi_cli_ts/tools/types.ts b/src/kimi_cli_ts/tools/types.ts index fc4029ce0..6143cba47 100644 --- a/src/kimi_cli_ts/tools/types.ts +++ b/src/kimi_cli_ts/tools/types.ts @@ -18,6 +18,7 @@ export interface ToolContext { toolName: string, action: string, summary: string, + opts?: { display?: unknown[] }, ) => Promise; /** Emit a wire event (for UI communication). */ wireEmit?: (event: unknown) => void; diff --git a/src/kimi_cli_ts/types.ts b/src/kimi_cli_ts/types.ts index 5e8d8393d..e9684923e 100644 --- a/src/kimi_cli_ts/types.ts +++ b/src/kimi_cli_ts/types.ts @@ -119,7 +119,7 @@ export interface SlashCommand { /** Extended description shown when selected in menu (up to 3 lines) */ longDescription?: string; aliases?: string[]; - handler: (args: string) => Promise; + handler: (args: string) => Promise; /** If defined, selecting from menu renders a secondary panel instead of executing handler */ panel?: () => CommandPanelConfig | null; } diff --git a/src/kimi_cli_ts/ui/CLAUDE.md b/src/kimi_cli_ts/ui/CLAUDE.md new file mode 100644 index 000000000..22aa67db7 --- /dev/null +++ b/src/kimi_cli_ts/ui/CLAUDE.md @@ -0,0 +1,112 @@ +# UI Layer Architecture + +## Rendering: Text Selection Fix + +React Ink destroys terminal mouse text selection when it clears the screen. The fix has two layers: + +**`ui/renderer/index.ts`** wraps `stdout.write` as a safety net to prevent Ink from clearing the screen: +- Strips `\x1b[2J` (erase screen) and `\x1b[3J` (erase scrollback) if they appear +- Rewrites those frames using CUP absolute positioning (`\x1b[row;1H`) per line — zero `\n`, no scroll pollution +- On DEC 2026 terminals, merges BSU/ESU into single atomic `stdout.write()` +- **Content shrink handling**: Tracks max rendered lines and emits `ERASE_BELOW` when content shrinks (e.g., closing a tall help panel), cleaning up orphaned lines at the bottom + +By relying on this safety net, **Shell.tsx** does NOT use a fixed `height` on the root ``. This allows Ink to use incremental line-level diffing without reserving extra vertical space, avoiding excess blank lines and keeping the WelcomeBox visible without being pushed off-screen. + +Debug log: `renderer-debug.log` in CWD. Key markers: `STRIP!` = clearTerminal intercepted, `FRAME#` = BSU/ESU frame, `SHRINK` = content shrink cleanup. + +**Constraints:** +- Bun cannot monkey-patch Ink's ESM `log-update.js` (default exports are read-only) +- tmux does not support DEC 2026 synchronized output +- `renderer/` subdirectory has unused infrastructure files (screen.ts, diff.ts, ansi-parser.ts, patch-writer.ts) for future cell-level diffing + +## Input Architecture + +Single `useInput` in Shell via `useShellInput()` hook (`input-state.ts`). All rendering components are pure (no `useInput`, no keyboard state). All hotkey logic (Ctrl+C double-press exit, shell mode toggle, plan mode, editor) is also inside the hook. + +``` +Shell.tsx (thin orchestrator — no keyboard logic, no hotkey state) +├── useShellInput() ← SINGLE useInput + UI state machine + hotkeys +│ ├── useInputHistory() ← input value, cursor, history (persisted per-cwd) +│ ├── useFileMention() ← @ file mention suggestions +│ ├── UIMode state machine ← routes keys by current mode +│ ├── Hotkeys ← Ctrl+C double-press, Ctrl+X shell mode, Shift+Tab plan, Ctrl+O editor +│ ├── shellMode state ← owned here, exposed to Shell as inputState.shellMode +│ └── Input Stack ← dispatches to top layer if any (see input-stack.ts) +│ +├── ← scrollback (WelcomeBox + completed messages) +├── ← current message + spinners + approval +├── ← pure render: separator + input line with cursor +└── Bottom slot (conditional): + ChoicePanel ← when mode = panel_choice + | ContentPanel ← when mode = panel_content + | SlashMenu ← when mode = slash_menu + | MentionMenu ← when mode = mention_menu + | StatusBar ← when mode = normal (or panel_input) +``` + +### UI State Machine (`UIMode`) + +``` +normal ──── "/" typed ──────→ slash_menu +normal ──── "@" typed ──────→ mention_menu +normal ──── cmd with panel ─→ panel_choice / panel_input / panel_content + +slash_menu ── Esc/clear ────→ normal +slash_menu ── Enter (panel) → panel_* +slash_menu ── Enter (exec) ─→ normal + +mention_menu ── Esc/clear ──→ normal +mention_menu ── Enter/Tab ──→ normal (apply selection) + +panel_choice ── Esc ────────→ normal +panel_choice ── Enter ──────→ normal or panel_* (chain) + +panel_input ── Esc ─────────→ normal +panel_input ── Enter ───────→ normal or panel_* (chain) + +panel_content ── Esc ───────→ normal +``` + +### Key Files + +| File | Role | +|------|------| +| `shell/input-state.ts` | `useShellInput` hook: useInput singleton, state machine, key dispatcher, hotkeys, shellMode | +| `shell/input-stack.ts` | `useInputLayer` hook: input focus stack for layered keyboard capture | +| `shell/PromptView.tsx` | Pure render: separator + panel title + buffered lines + input with cursor | +| `shell/Shell.tsx` | Thin orchestrator: wires callbacks, renders layout. No keyboard logic. | +| `components/CommandPanel.tsx` | Controlled `ChoicePanel` + `ContentPanel` (no useInput) | +| `components/SlashMenu.tsx` | Pure render slash command menu | +| `components/MentionMenu.tsx` | Pure render @ mention menu | +| `components/StatusBar.tsx` | Pure render status bar (3 lines) | + +### Rules + +- **Never add `useInput` to rendering components.** All keyboard handling goes through `useShellInput` in `input-state.ts`. +- **Never add hotkey/shortcut logic to Shell.tsx.** Shell passes external callbacks (`onExit`, `onInterrupt`, `onPlanModeToggle`, `onOpenEditor`, `onNotify`) to `useShellInput`, which handles the logic internally. +- **PromptView, ChoicePanel, ContentPanel, SlashMenu, MentionMenu, StatusBar** are pure — they receive all data via props. +- **Bottom slot is mutually exclusive**: only one of the 5 components renders at a time based on `UIMode`. +- **`` for completed content**: WelcomeBox and finished messages go into `` so they enter scrollback and are never re-drawn. +- Old `Prompt.tsx` is deprecated — `PromptView.tsx` + `input-state.ts` replace it. + +### Input Stack (`input-stack.ts`) + +Components that need temporary keyboard capture (e.g., ApprovalPanel) use `useInputLayer(handler)` instead of Ink's `useInput`. This pushes a handler onto a global stack. The central `useInput` in `input-state.ts` checks the stack on every keypress: + +- **Ctrl+C**: Always handled globally (interrupt / double-press exit), never routed to stack +- **Esc**: Closes panels first (global), then routes to top layer if any, then falls through to interrupt +- **All other keys**: If a layer exists on the stack, routed to the top layer. Otherwise, routed to the default handler (UIMode state machine). + +When the component unmounts, `useInputLayer`'s cleanup effect automatically pops the layer, restoring keyboard focus to the previous handler or the default prompt input. + +```typescript +// Example: ApprovalPanel captures keyboard while mounted +export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { + useInputLayer((input, key) => { + if (key.return) { onRespond("approve"); return; } + if (key.upArrow) { /* navigate */ } + // ... + }); + return ...; +} +``` diff --git a/src/kimi_cli_ts/ui/components/CommandPanel.tsx b/src/kimi_cli_ts/ui/components/CommandPanel.tsx index 74def3ba9..30f735ff6 100644 --- a/src/kimi_cli_ts/ui/components/CommandPanel.tsx +++ b/src/kimi_cli_ts/ui/components/CommandPanel.tsx @@ -1,123 +1,45 @@ /** - * CommandPanel.tsx — Secondary interactive panel for slash commands. - * Renders below the input area when a command needs a secondary menu. + * CommandPanel.tsx — Controlled panel components for slash commands. * - * Supports three modes: - * - "choice": selectable list (↑↓ navigate, Enter select, Esc close) - * - "content": scrollable text (↑↓ scroll, Esc close) - * - "input": text input field (type, Enter submit, Esc close) + * Two modes: + * - ChoicePanel: selectable list (controlled via selectedIndex prop) + * - ContentPanel: scrollable text (controlled via scrollOffset prop) * - * Panels can chain: onSelect/onSubmit may return a new CommandPanelConfig - * to transition to the next step (wizard pattern). + * No useInput inside — keyboard is handled by Shell's useShellInput dispatcher. + * "input" type panels are handled by Prompt + useShellInput directly. */ -import React, { useState, useCallback } from "react"; -import { Box, Text, useInput, useStdout } from "ink"; -import TextInput from "ink-text-input"; +import React from "react"; +import { Box, Text, useStdout } from "ink"; import type { CommandPanelConfig } from "../../types.ts"; const DIM = "#888888"; const HIGHLIGHT = "#1e90ff"; const BORDER_COLOR = "#555555"; -interface CommandPanelProps { - config: CommandPanelConfig; - onClose: () => void; -} - -export function CommandPanel({ config, onClose }: CommandPanelProps) { - // Support panel transitions: when a child panel returns a new config, - // we replace the current config with the new one. - const [currentConfig, setCurrentConfig] = useState(config); - - // Reset when external config changes (e.g. opening a different command panel) - React.useEffect(() => { - setCurrentConfig(config); - }, [config]); +// ── Choice Panel (controlled) ─────────────────────────── - const handleTransition = useCallback( - (result: CommandPanelConfig | Promise | void) => { - if (!result) return; - if (result instanceof Promise) { - result.then((next) => { - if (next) setCurrentConfig(next); - }); - } else { - setCurrentConfig(result); - } - }, - [], - ); - - if (currentConfig.type === "choice") { - return ; - } - if (currentConfig.type === "input") { - return ; - } - return ; +interface ChoicePanelProps { + config: Extract; + selectedIndex: number; } -// ── Choice Panel ──────────────────────────────────────── - -function ChoicePanel({ - config, - onClose, - onTransition, -}: { - config: Extract; - onClose: () => void; - onTransition: (result: CommandPanelConfig | Promise | void) => void; -}) { - const { items, onSelect, title } = config; - const defaultIndex = items.findIndex((item) => item.current); - const [selectedIndex, setSelectedIndex] = useState( - defaultIndex >= 0 ? defaultIndex : 0, - ); +export function ChoicePanel({ config, selectedIndex }: ChoicePanelProps) { + const { items, title } = config; const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; - - useInput( - useCallback( - (_input: string, key: { upArrow?: boolean; downArrow?: boolean; return?: boolean; escape?: boolean }) => { - if (key.escape) { - onClose(); - return; - } - if (key.upArrow) { - setSelectedIndex((i) => Math.max(0, i - 1)); - return; - } - if (key.downArrow) { - setSelectedIndex((i) => Math.min(items.length - 1, i + 1)); - return; - } - if (key.return) { - const item = items[selectedIndex]; - if (item) { - const result = onSelect(item.value); - if (result) { - onTransition(result); - } else { - onClose(); - } - } - return; - } - }, - [items, selectedIndex, onSelect, onClose, onTransition], - ), - ); - const rows = stdout?.rows ?? 24; - // Reserve lines for: separator + title + separator + status bar (~5 lines overhead) + const maxVisible = Math.max(rows - 8, 5); const total = items.length; // Compute visible window centered on selectedIndex let start = 0; if (total > maxVisible) { - start = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible)); + start = Math.max( + 0, + Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible), + ); } const visibleItems = items.slice(start, start + maxVisible); const hasMore = start + maxVisible < total; @@ -132,9 +54,7 @@ function ChoicePanel({ (↑↓ select, Enter confirm, Esc cancel) {total > maxVisible && ( - - {` [${selectedIndex + 1}/${total}]`} - + {` [${selectedIndex + 1}/${total}]`} )} {"─".repeat(columns)} @@ -147,15 +67,16 @@ function ChoicePanel({ {isSelected ? "▸ " : " "} - + {item.label} {item.description && ( {" " + item.description} )} - {item.current && ( - (current) - )} + {item.current && (current)} ); })} @@ -164,123 +85,24 @@ function ChoicePanel({ ); } -// ── Input Panel ───────────────────────────────────────── +// ── Content Panel (controlled) ────────────────────────── -function InputPanel({ - config, - onClose, - onTransition, -}: { - config: Extract; - onClose: () => void; - onTransition: (result: CommandPanelConfig | Promise | void) => void; -}) { - const { title, placeholder, password, onSubmit } = config; - const [value, setValue] = useState(""); - const { stdout } = useStdout(); - const columns = stdout?.columns ?? 80; - - useInput( - useCallback( - (_input: string, key: { escape?: boolean }) => { - if (key.escape) { - onClose(); - } - }, - [onClose], - ), - ); - - const handleSubmit = useCallback( - (input: string) => { - const trimmed = input.trim(); - if (!trimmed) return; - const result = onSubmit(trimmed); - if (result) { - onTransition(result); - } else { - onClose(); - } - }, - [onSubmit, onClose, onTransition], - ); - - // For password fields, mask the input - const displayValue = password ? "•".repeat(value.length) : value; - - return ( - - {"─".repeat(columns)} - - - {title} - - (Enter submit, Esc cancel) - - {"─".repeat(columns)} - - {"▸ "} - {password ? ( - - ) : ( - - )} - - - ); +interface ContentPanelProps { + config: Extract; + scrollOffset: number; } -// ── Content Panel ─────────────────────────────────────── - -function ContentPanel({ - config, - onClose, -}: { - config: Extract; - onClose: () => void; -}) { +export function ContentPanel({ config, scrollOffset }: ContentPanelProps) { const { content, title } = config; const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; const maxVisibleLines = Math.max((stdout?.rows ?? 24) - 8, 10); const lines = content.split("\n"); - const [scrollOffset, setScrollOffset] = useState(0); const maxScroll = Math.max(0, lines.length - maxVisibleLines); - - useInput( - useCallback( - (_input: string, key: { upArrow?: boolean; downArrow?: boolean; escape?: boolean }) => { - if (key.escape) { - onClose(); - return; - } - if (key.upArrow) { - setScrollOffset((o) => Math.max(0, o - 1)); - return; - } - if (key.downArrow) { - setScrollOffset((o) => Math.min(maxScroll, o + 1)); - return; - } - }, - [maxScroll, onClose], - ), - ); - - const visibleLines = lines.slice(scrollOffset, scrollOffset + maxVisibleLines); - const hasMore = scrollOffset < maxScroll; + const clampedOffset = Math.min(scrollOffset, maxScroll); + const visibleLines = lines.slice(clampedOffset, clampedOffset + maxVisibleLines); + const hasMore = clampedOffset < maxScroll; return ( @@ -292,19 +114,17 @@ function ContentPanel({ (↑↓ scroll, Esc close) {maxScroll > 0 && ( - {` [${scrollOffset + 1}-${Math.min(scrollOffset + maxVisibleLines, lines.length)}/${lines.length}]`} + {` [${clampedOffset + 1}-${Math.min(clampedOffset + maxVisibleLines, lines.length)}/${lines.length}]`} )} {"─".repeat(columns)} {visibleLines.map((line, i) => ( - {line || " "} + {line || " "} ))} - {hasMore && ( - ↓ more... - )} + {hasMore && ↓ more...} ); } diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index a0a03fb01..aa9ebba5a 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -138,18 +138,132 @@ export function StatusBar({ ? `agent (${modelName} ${thinkingDot})` : "agent"; - // Rotating tips (show 2 tips separated by |) - let tipText = ""; + // Rotating tips: build both 2-tip and 1-tip variants for progressive shrinking. + let tip1Text = ""; + let tip2Text = ""; if (tips.length > 0) { - const tip1 = tips[tipIndex % tips.length]!; + tip1Text = tips[tipIndex % tips.length]!; if (tips.length > 1) { const tip2 = tips[(tipIndex + 1) % tips.length]!; - tipText = `${tip1} | ${tip2}`; - } else { - tipText = tip1; + tip2Text = `${tip1Text} | ${tip2}`; } } + // --- Line 1: fit all elements into one row, dropping from the end --- + // Build an ordered list of segments. Each segment has a text representation + // and a render function. We measure total width and drop segments from the + // *end* (lowest priority) until the line fits within `columns`. + // + // Priority (highest first, i.e. dropped last): + // plan/yolo > modeStr > workDir > gitBadge > compacting > bgTask > tips + // + // The gap between left-side segments is 2 chars (" "). + + type Segment = { + key: string; + text: string; + render: () => React.ReactNode; + }; + + const leftSegments: Segment[] = []; + + // plan and yolo display first (and are highest priority — dropped last) + if (planMode) { + leftSegments.push({ + key: "plan", + text: "plan", + render: () => plan, + }); + } + + if (yolo) { + leftSegments.push({ + key: "yolo", + text: "yolo", + render: () => yolo, + }); + } + + leftSegments.push({ + key: "mode", + text: modeStr, + render: () => {modeStr}, + }); + + if (displayDir) { + const dir = truncate(displayDir, 30); + leftSegments.push({ + key: "dir", + text: dir, + render: () => {dir}, + }); + } + + if (gitBadge) { + leftSegments.push({ + key: "git", + text: gitBadge, + render: () => {gitBadge}, + }); + } + + if (isCompacting) { + leftSegments.push({ + key: "compact", + text: "compacting...", + render: () => compacting..., + }); + } + + if (bgTaskCount > 0) { + const t = `⚙ bash: ${bgTaskCount}`; + leftSegments.push({ + key: "bg", + text: t, + render: () => {t}, + }); + } + + // Tips go on the right side and are the first to be dropped. + const GAP = 2; // gap between left segments rendered by Ink's gap={2} + const LEFT_RIGHT_GAP = 2; // minimum gap between left group and right tips + + const calcLeftWidth = (segs: Segment[]) => + segs.reduce((w, s, i) => w + s.text.length + (i > 0 ? GAP : 0), 0); + + let visibleLeft = [...leftSegments]; + + // Try 2-tip → 1-tip → no tip + const calcTotal = (tip: string) => { + const lw = calcLeftWidth(visibleLeft); + return tip ? lw + LEFT_RIGHT_GAP + tip.length : lw; + }; + + let visibleTip = ""; + if (tip2Text && calcTotal(tip2Text) <= columns) { + visibleTip = tip2Text; + } else if (tip1Text && calcTotal(tip1Text) <= columns) { + visibleTip = tip1Text; + } + + // Phase 2: drop left segments from the end (lowest priority first), + // but never drop the very first segment (plan/yolo/modeStr). + while (calcTotal(visibleTip) > columns && visibleLeft.length > 1) { + visibleLeft.pop(); + } + + // Phase 3: if still too wide, truncate the last remaining segment + if (calcTotal(visibleTip) > columns && visibleLeft.length === 1) { + const lastSeg = visibleLeft[0]!; + const maxLen = Math.max(5, columns - (visibleTip ? visibleTip.length + LEFT_RIGHT_GAP : 0)); + const truncated = truncate(lastSeg.text, maxLen); + visibleLeft[0] = { + key: lastSeg.key, + text: truncated, + render: () => {truncated}, + }; + } + // Left toast (first unexpired toast with position=left) const leftToast = toasts.find((t) => (t.position ?? "left") === "left"); const leftToastText = leftToast @@ -164,36 +278,16 @@ export function StatusBar({ return ( - {/* Line 0: separator */} - {separator} - - {/* Line 1: status indicators */} - - - {yolo && ( - - yolo - - )} - {planMode && ( - - plan - - )} - {modeStr} - {displayDir && {truncate(displayDir, 30)}} - {gitBadge && {gitBadge}} - {bgTaskCount > 0 && ( - ⚙ bash: {bgTaskCount} - )} - {isStreaming && ( - step {stepCount} - )} - {isCompacting && compacting...} - - - {tipText} + {/* Line 1: status indicators — guaranteed single row */} + + + {visibleLeft.map((seg) => seg.render())} + {visibleTip && ( + + {visibleTip} + + )} {/* Line 2: left toast + right context */} diff --git a/src/kimi_cli_ts/ui/hooks/useInput.ts b/src/kimi_cli_ts/ui/hooks/useInput.ts index d0e37269c..50f15d007 100644 --- a/src/kimi_cli_ts/ui/hooks/useInput.ts +++ b/src/kimi_cli_ts/ui/hooks/useInput.ts @@ -81,6 +81,10 @@ export interface InputHistoryState { historyNext: () => void; /** Add current value to history */ addToHistory: (entry: string) => void; + /** True when navigating history (arrow up/down) */ + isBrowsingHistory: boolean; + /** Exit history browsing mode (call on any edit) */ + exitHistory: () => void; /** Check if current input is a slash command */ isSlashCommand: boolean; /** Parse slash command name and args */ @@ -159,6 +163,13 @@ export function useInputHistory(maxHistory = 100): InputHistoryState { } }, []); + const exitHistory = useCallback(() => { + if (historyIndex.current !== -1) { + historyIndex.current = -1; + savedInput.current = ""; + } + }, []); + const isSlashCommand = value.startsWith("/"); const parseSlashCommand = useCallback(() => { @@ -180,6 +191,8 @@ export function useInputHistory(maxHistory = 100): InputHistoryState { historyPrev, historyNext, addToHistory, + isBrowsingHistory: historyIndex.current !== -1, + exitHistory, isSlashCommand, parseSlashCommand, }; diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts index 644c9d5f1..a90482c54 100644 --- a/src/kimi_cli_ts/ui/hooks/useWire.ts +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -210,6 +210,24 @@ export function useWire(options?: UseWireOptions): WireState & { break; } + case "slash_result": { + // Atomically insert a user+assistant message pair (for slash command feedback) + const userMsg: UIMessage = { + id: nanoid(), + role: "user", + segments: [{ type: "text", text: event.userInput }], + timestamp: Date.now(), + }; + const assistantMsg: UIMessage = { + id: nanoid(), + role: "assistant", + segments: [{ type: "text", text: event.text }], + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, userMsg, assistantMsg]); + break; + } + case "error": { // Errors are also shown as notifications with longer duration const toast: Toast = { diff --git a/src/kimi_cli_ts/ui/renderer/ansi-parser.ts b/src/kimi_cli_ts/ui/renderer/ansi-parser.ts new file mode 100644 index 000000000..9fac29044 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/ansi-parser.ts @@ -0,0 +1,282 @@ +/** + * ANSI string → Screen buffer parser. + * + * Takes an ANSI-formatted string (as produced by Ink's renderer) and populates + * a Screen buffer at the cell level. Handles: + * + * - SGR sequences (colors, bold, italic, etc.) + * - Cursor movement within the string (newlines, carriage returns) + * - Wide characters (CJK, emoji) via string-width detection + * - Hyperlink OSC sequences (stripped, not stored) + * + * This is the bridge between Ink's string-based output and our cell-level + * screen buffer, enabling cell-level diffing without forking Ink's internals. + */ + +import { type Screen, CellWidth } from "./types.ts"; +import { setCellAt, internChar, internStyle } from "./screen.ts"; + +// ── Character Width ───────────────────────────────────── + +/** + * Fast character width detection. + * Returns 2 for wide chars (CJK, certain emoji), 1 for normal, 0 for zero-width. + */ +function charWidth(codePoint: number): number { + // Zero-width characters + if (codePoint === 0x200b || codePoint === 0xfeff) return 0; + // Combining marks (general range) + if (codePoint >= 0x0300 && codePoint <= 0x036f) return 0; + + // East Asian Wide ranges (CJK Unified Ideographs, etc.) + if ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo + (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals, Kangxi + (codePoint >= 0x3040 && codePoint <= 0x33bf) || // Hiragana, Katakana, CJK Compat + (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Ext A + (codePoint >= 0x4e00 && codePoint <= 0xa4cf) || // CJK Unified Ideographs + (codePoint >= 0xa960 && codePoint <= 0xa97c) || // Hangul Jamo Extended-A + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables + (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compat Ideographs + (codePoint >= 0xfe10 && codePoint <= 0xfe6f) || // CJK Compat Forms, Small Form Variants + (codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs + (codePoint >= 0x1f000 && codePoint <= 0x1fbff) || // Misc Symbols, Emoticons, etc. + (codePoint >= 0x20000 && codePoint <= 0x2ffff) || // CJK Unified Ideographs Ext B-F + (codePoint >= 0x30000 && codePoint <= 0x3ffff) // CJK Unified Ideographs Ext G+ + ) { + return 2; + } + + // Regional indicators (flag emoji) — each indicator is width 1, + // but a pair of them forms a flag emoji that is typically width 2. + // For simplicity, treat individual indicators as width 1. + // Full emoji width calculation would need grapheme cluster analysis. + + return 1; +} + +// ── SGR Parser ────────────────────────────────────────── + +/** + * State machine for tracking accumulated SGR (Select Graphic Rendition) state. + * + * Instead of parsing SGR into semantic objects, we track the raw ANSI string + * that represents the current style. This is simpler and sufficient for our + * use case: we just need to know if two cells have the same style. + */ + +// ── Main Parser ───────────────────────────────────────── + +/** + * Parse an ANSI-formatted string into a Screen buffer. + * + * @param ansi The full ANSI string output from Ink + * @param screen Target screen buffer (must be created with sufficient dimensions) + * @param width Terminal width for line wrapping + */ +export function parseAnsiToScreen( + ansi: string, + screen: Screen, + width: number, +): void { + let col = 0; + let row = 0; + // Accumulated SGR state as raw ANSI string. We track the last SGR + // sequence(s) emitted so cells get the correct style ID. + let currentStyle = ""; + let currentStyleId = 0; // Always starts as 0 (empty style) + + const len = ansi.length; + let i = 0; + + while (i < len) { + const ch = ansi.charCodeAt(i); + + // ── ESC sequence ── + if (ch === 0x1b) { + // CSI: ESC [ + if (i + 1 < len && ansi.charCodeAt(i + 1) === 0x5b) { + // Parse CSI parameters + let j = i + 2; + const paramStart = j; + // Collect digits, semicolons, and intermediate bytes (0x20-0x3f) + while (j < len) { + const b = ansi.charCodeAt(j); + if (b >= 0x40 && b <= 0x7e) break; // Final byte + j++; + } + + if (j < len) { + const finalByte = ansi.charCodeAt(j); + const paramStr = ansi.slice(paramStart, j); + + if (finalByte === 0x6d) { + // 'm' — SGR (Select Graphic Rendition) + const sgrSeq = `\x1b[${paramStr}m`; + if (paramStr === "" || paramStr === "0") { + // Reset + currentStyle = ""; + currentStyleId = 0; + } else { + // Accumulate style. For simplicity, we concatenate SGR sequences. + // This produces a unique string per visual style combination. + currentStyle = currentStyle + sgrSeq; + currentStyleId = internStyle(screen, currentStyle); + } + i = j + 1; + continue; + } + + // Other CSI sequences (cursor movement, etc.) — skip + // We don't need to handle cursor CSI here because Ink's renderer + // output uses newlines for positioning, not CSI cursor moves. + i = j + 1; + continue; + } + // Incomplete CSI — skip ESC + i++; + continue; + } + + // OSC: ESC ] + if (i + 1 < len && ansi.charCodeAt(i + 1) === 0x5d) { + // Skip until ST (ESC \ or BEL) + let j = i + 2; + while (j < len) { + if (ansi.charCodeAt(j) === 0x07) { + // BEL + j++; + break; + } + if ( + ansi.charCodeAt(j) === 0x1b && + j + 1 < len && + ansi.charCodeAt(j + 1) === 0x5c + ) { + // ESC backslash (ST) + j += 2; + break; + } + j++; + } + i = j; + continue; + } + + // Other ESC sequences — skip 2 bytes + i += 2; + continue; + } + + // ── Newline ── + if (ch === 0x0a) { + row++; + col = 0; + i++; + continue; + } + + // ── Carriage return ── + if (ch === 0x0d) { + col = 0; + i++; + continue; + } + + // ── Tab ── + if (ch === 0x09) { + const nextTab = ((col >> 3) + 1) << 3; // Next 8-column tab stop + while (col < nextTab && col < width) { + setCellAt( + screen, + col, + row, + internChar(screen, " "), + currentStyleId, + CellWidth.Narrow, + ); + col++; + } + i++; + continue; + } + + // ── Other control chars ── + if (ch < 0x20) { + i++; + continue; + } + + // ── Printable character ── + // Extract the full character (handle surrogate pairs) + let codePoint: number; + let charLen: number; + if (ch >= 0xd800 && ch <= 0xdbff && i + 1 < len) { + // Surrogate pair + const lo = ansi.charCodeAt(i + 1); + codePoint = (ch - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000; + charLen = 2; + } else { + codePoint = ch; + charLen = 1; + } + + const char = ansi.slice(i, i + charLen); + const w = charWidth(codePoint); + + if (w === 0) { + // Zero-width character — skip (combining mark, ZWS, etc.) + i += charLen; + continue; + } + + // Soft wrap: if we'd overflow, move to next line + if (col >= width) { + row++; + col = 0; + } + + // Wide char that would split across line boundary + if (w === 2 && col + 1 >= width) { + // Leave a space at end of line, wrap to next line + setCellAt( + screen, + col, + row, + internChar(screen, " "), + currentStyleId, + CellWidth.Narrow, + ); + row++; + col = 0; + } + + if (row >= screen.height) { + // Beyond screen buffer — stop + break; + } + + // Write the character cell + const charId = internChar(screen, char); + if (w === 2) { + setCellAt(screen, col, row, charId, currentStyleId, CellWidth.Wide); + col++; + // Write spacer tail for the second column + setCellAt( + screen, + col, + row, + 0, // space charId + currentStyleId, + CellWidth.SpacerTail, + ); + col++; + } else { + setCellAt(screen, col, row, charId, currentStyleId, CellWidth.Narrow); + col++; + } + + i += charLen; + } +} diff --git a/src/kimi_cli_ts/ui/renderer/csi.ts b/src/kimi_cli_ts/ui/renderer/csi.ts new file mode 100644 index 000000000..f4caf9fe0 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/csi.ts @@ -0,0 +1,83 @@ +/** + * CSI (Control Sequence Introducer) escape sequence builders. + * + * Provides minimal ANSI/DEC sequence helpers for cursor movement, + * line erasure, and cursor visibility. Ported from Claude Code's termio/csi.ts. + */ + +const ESC = "\x1b"; +const CSI = `${ESC}[`; + +// ── Cursor Movement ───────────────────────────────────── + +/** + * Relative cursor move by (dx, dy). + * Emits CUU/CUD for vertical, CUF/CUB for horizontal. + * Returns empty string if no movement needed. + */ +export function cursorMove(dx: number, dy: number): string { + let seq = ""; + if (dy < 0) seq += `${CSI}${-dy}A`; // CUU — cursor up + else if (dy > 0) seq += `${CSI}${dy}B`; // CUD — cursor down + if (dx > 0) seq += `${CSI}${dx}C`; // CUF — cursor forward + else if (dx < 0) seq += `${CSI}${-dx}D`; // CUB — cursor back + return seq; +} + +/** Move cursor to column `col` (0-indexed). Uses CHA (CSI n G), 1-indexed. */ +export function cursorTo(col: number): string { + return `${CSI}${col + 1}G`; +} + +/** Absolute cursor position (1-indexed row, col). CUP: CSI row;col H. */ +export function cursorPosition(row: number, col: number): string { + return `${CSI}${row};${col}H`; +} + +// ── Erase ─────────────────────────────────────────────── + +/** Erase from cursor to end of line. CSI 0 K. */ +export const ERASE_END_LINE = `${CSI}0K`; + +/** Erase entire line. CSI 2 K. */ +export const ERASE_LINE = `${CSI}2K`; + +/** Erase entire screen. CSI 2 J. */ +export const ERASE_SCREEN = `${CSI}2J`; + +/** + * Erase `count` lines: erase current line, then (move up + erase) × (count-1), + * then move cursor to column 0. Matches ansi-escapes.eraseLines() exactly. + */ +export function eraseLines(count: number): string { + if (count <= 0) return ""; + let seq = ERASE_LINE; // Erase current line + for (let i = 1; i < count; i++) { + seq += `${CSI}1A${ERASE_LINE}`; // Move up 1 + erase line + } + seq += `${CSI}G`; // CHA — move to column 1 (leftmost) + return seq; +} + +// ── Cursor Visibility ─────────────────────────────────── + +/** DECTCEM: hide cursor. */ +export const HIDE_CURSOR = `${CSI}?25l`; + +/** DECTCEM: show cursor. */ +export const SHOW_CURSOR = `${CSI}?25h`; + +// ── Home ──────────────────────────────────────────────── + +/** Move cursor to (1,1). CSI H. */ +export const CURSOR_HOME = `${CSI}H`; + +// ── SGR (Select Graphic Rendition) ────────────────────── + +/** Reset all SGR attributes. */ +export const SGR_RESET = `${CSI}0m`; + +// ── Carriage Return / Newline ─────────────────────────── + +export const CR = "\r"; +export const LF = "\n"; diff --git a/src/kimi_cli_ts/ui/renderer/diff.ts b/src/kimi_cli_ts/ui/renderer/diff.ts new file mode 100644 index 000000000..4a42bb1c6 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/diff.ts @@ -0,0 +1,165 @@ +/** + * Cell-level diff engine. + * + * Compares two Screen buffers at the cell level and produces a list of + * changed cell coordinates. The patch-writer then converts these into + * an optimized ANSI sequence. + * + * Key insight from Claude Code: by comparing packed Int32 values, we can + * detect cell changes with 2 integer comparisons per cell (charId + packed + * styleId|width) — no string comparison or object allocation needed. + */ + +import { type Screen, STYLE_SHIFT, WIDTH_MASK, CellWidth } from "./types.ts"; + +// ── Changed Cell ──────────────────────────────────────── + +export type ChangedCell = { + x: number; + y: number; + /** charId in the NEXT screen's pool. */ + charId: number; + /** styleId in the NEXT screen's pool. */ + styleId: number; + /** Cell width. */ + width: number; + /** True if this cell should be cleared (exists in prev but not next). */ + clear: boolean; +}; + +// ── Diff ──────────────────────────────────────────────── + +/** + * Compute the diff between two screens. + * + * Returns an array of changed cells, sorted in reading order (row-major). + * Cells that exist in prev but not in next (due to shrinking) are marked + * with `clear: true`. The caller must handle erasing those cells. + * + * For performance, both screens share the same char/style pools (via copyPools), + * so charId and styleId can be compared directly as integers. + */ +export function computeDiff(prev: Screen, next: Screen): ChangedCell[] { + const changes: ChangedCell[] = []; + + const pw = prev.width; + const nw = next.width; + const ph = prev.height; + const nh = next.height; + const minH = Math.min(ph, nh); + const minW = Math.min(pw, nw); + + const pc = prev.cells; + const nc = next.cells; + + // ── Compare overlapping region ── + for (let y = 0; y < minH; y++) { + const pRowBase = y * pw * 2; + const nRowBase = y * nw * 2; + + for (let x = 0; x < minW; x++) { + const pi = pRowBase + x * 2; + const ni = nRowBase + x * 2; + + if (pc[pi] !== nc[ni] || pc[pi + 1] !== nc[ni + 1]) { + const w1 = nc[ni + 1]!; + changes.push({ + x, + y, + charId: nc[ni]!, + styleId: w1 >>> STYLE_SHIFT, + width: w1 & WIDTH_MASK, + clear: false, + }); + } + } + + // ── Cells added (next wider than prev) ── + for (let x = minW; x < nw; x++) { + const ni = nRowBase + x * 2; + if (nc[ni] !== 0 || nc[ni + 1] !== 0) { + const w1 = nc[ni + 1]!; + changes.push({ + x, + y, + charId: nc[ni]!, + styleId: w1 >>> STYLE_SHIFT, + width: w1 & WIDTH_MASK, + clear: false, + }); + } + } + + // ── Cells removed (prev wider than next) ── + for (let x = minW; x < pw; x++) { + const pi = pRowBase + x * 2; + if (pc[pi] !== 0 || pc[pi + 1] !== 0) { + changes.push({ + x, + y, + charId: 0, + styleId: 0, + width: CellWidth.Narrow, + clear: true, + }); + } + } + } + + // ── Rows only in next (added) ── + for (let y = minH; y < nh; y++) { + const nRowBase = y * nw * 2; + for (let x = 0; x < nw; x++) { + const ni = nRowBase + x * 2; + if (nc[ni] !== 0 || nc[ni + 1] !== 0) { + const w1 = nc[ni + 1]!; + changes.push({ + x, + y, + charId: nc[ni]!, + styleId: w1 >>> STYLE_SHIFT, + width: w1 & WIDTH_MASK, + clear: false, + }); + } + } + } + + // ── Rows only in prev (removed) ── + for (let y = minH; y < ph; y++) { + const pRowBase = y * pw * 2; + for (let x = 0; x < pw; x++) { + const pi = pRowBase + x * 2; + if (pc[pi] !== 0 || pc[pi + 1] !== 0) { + changes.push({ + x, + y, + charId: 0, + styleId: 0, + width: CellWidth.Narrow, + clear: true, + }); + } + } + } + + return changes; +} + +/** + * Quick check: are the two screens identical? + * Returns true if no cells differ. Used to short-circuit rendering. + */ +export function screensEqual(prev: Screen, next: Screen): boolean { + if (prev.width !== next.width || prev.height !== next.height) return false; + + const len = prev.cells.length; + const pc = prev.cells; + const nc = next.cells; + + for (let i = 0; i < len; i++) { + if (pc[i] !== nc[i]) return false; + } + + return true; +} diff --git a/src/kimi_cli_ts/ui/renderer/index.ts b/src/kimi_cli_ts/ui/renderer/index.ts new file mode 100644 index 000000000..dc68fd800 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/index.ts @@ -0,0 +1,328 @@ +/** + * Optimized Ink renderer. + * + * Two layers of protection for text selection: + * + * 1. Shell removes minHeight={termHeight} so Ink uses incremental diff + * (eraseLines + overwrite changed lines) instead of clearTerminal. + * Static messages stay in scrollback untouched. + * + * 2. As a safety net, if \x1b[2J (erase screen) still appears in output + * (e.g., resize, edge cases), strip it to prevent selection destruction. + * + * 3. On terminals supporting DEC 2026, buffer BSU/ESU sequences into + * a single atomic stdout.write() call. + * + * Usage: + * import { patchInkLogUpdate } from '../ui/renderer'; + * patchInkLogUpdate(); // Call before ink.render() + */ + +import { BSU, ESU, SYNC_SUPPORTED } from "./terminal-detect.ts"; + +import { writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; + +// ── Debug File Logger ─────────────────────────────────── + +const LOG_FILE = join(process.cwd(), "renderer-debug.log"); +let logReady = false; +let writeCounter = 0; +let frameCounter = 0; + +function initLog(): void { + if (logReady) return; + logReady = true; + try { + writeFileSync(LOG_FILE, [ + `=== Renderer Debug Log ===`, + `pid: ${process.pid}`, + `cwd: ${process.cwd()}`, + `TERM_PROGRAM: ${process.env.TERM_PROGRAM ?? "(unset)"}`, + `TERM: ${process.env.TERM ?? "(unset)"}`, + `TMUX: ${process.env.TMUX ?? "(unset)"}`, + `SYNC_SUPPORTED: ${SYNC_SUPPORTED}`, + `stdout.columns: ${process.stdout.columns}`, + `stdout.rows: ${process.stdout.rows}`, + `stdout.isTTY: ${process.stdout.isTTY}`, + ``, + ].join("\n") + "\n"); + } catch { /* ignore */ } +} + +function log(msg: string): void { + if (!logReady) initLog(); + try { + appendFileSync(LOG_FILE, `[${Date.now()}] ${msg}\n`); + } catch { /* ignore */ } +} + +function esc(s: string, maxLen = 300): string { + return s.slice(0, maxLen) + .replace(/\x1b\[(\??)(\d+(?:;\d+)*)([A-Za-z])/g, (_, q, p, c) => ``) + .replace(/\x1b\]([^\x07\x1b]*)\x07/g, (_, p) => ``) + .replace(/\x1b/g, "") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); +} + +function analyzeAnsi(s: string): string { + const f: string[] = []; + if (s.includes("\x1b[2J")) f.push("ERASE_SCREEN!"); + if (s.includes("\x1b[3J")) f.push("ERASE_SCROLLBACK!"); + if (s.includes("\x1b[J")) f.push("ERASE_BELOW"); + if (s.includes("\x1b[H")) f.push("HOME"); + if (s.includes("\x1b[?2026h")) f.push("BSU"); + if (s.includes("\x1b[?2026l")) f.push("ESU"); + if (s.includes("\x1b[?25l")) f.push("HIDE"); + if (s.includes("\x1b[?25h")) f.push("SHOW"); + const el = (s.match(/\x1b\[2K/g) || []).length; + if (el) f.push(`eraseLn*${el}`); + const eol = (s.match(/\x1b\[K/g) || []).length; + if (eol) f.push(`eraseEol*${eol}`); + const cu = (s.match(/\x1b\[\d+A/g) || []).length; + if (cu) f.push(`up*${cu}`); + const nl = (s.match(/\n/g) || []).length; + f.push(`nl*${nl}`); + f.push(`${s.length}b`); + return f.join("|"); +} + +// ── Safety: strip erase-screen sequences ──────────────── + +const ERASE_SCREEN = "\x1b[2J"; +const ERASE_SCROLLBACK = "\x1b[3J"; + +const ERASE_EOL = "\x1b[K"; +const ERASE_BELOW = "\x1b[J"; +const CURSOR_HOME = "\x1b[H"; + +/** + * When we strip clearTerminal, Ink's log.sync() sets previousLineCount to the + * current content height. On the next frame with smaller content, eraseLines() + * won't erase enough lines. We track the max height we've rendered and emit + * extra ERASE_BELOW when content shrinks. + */ +let maxRenderedLines = 0; + +/** + * Rewrite a clearTerminal frame into CUP-positioned lines. + * + * When Ink hits the clearTerminal path, it emits: + * \x1b[2J \x1b[3J \x1b[H fullStaticOutput + output + * + * fullStaticOutput = completed messages (already in scrollback via ) + * output = dynamic part (streaming msg, spinner, prompt, statusbar) + * + * We rewrite this as CUP-positioned lines showing only the LAST `rows` lines + * (the dynamic viewport). The static history is already in scrollback from + * earlier writes — no need to re-emit it. + * + * This means: no \x1b[2J (no erase), no \x1b[3J (scrollback preserved), + * no \n (no scroll pollution). Just CUP overwrite of the visible viewport. + */ +function rewriteClearFrame(s: string, termRows: number): string { + // Strip all destructive/positioning sequences + let body = s; + body = body.replaceAll(ERASE_SCREEN, ""); + body = body.replaceAll(ERASE_SCROLLBACK, ""); + body = body.replaceAll(CURSOR_HOME, ""); + + const lines = body.split("\n"); + // Remove trailing empty line from split + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + const totalLines = lines.length; + const rows = termRows || 24; + + // Show the LAST `rows` lines — this keeps statusbar/input visible + // and drops the static history (which is already in scrollback) + const startLine = totalLines > rows ? totalLines - rows : 0; + const visibleCount = Math.min(totalLines - startLine, rows); + + const parts: string[] = []; + for (let i = 0; i < visibleCount; i++) { + parts.push(`\x1b[${i + 1};1H`); + parts.push(lines[startLine + i]!); + parts.push(ERASE_EOL); + } + // Clear any lines below content (in case previous frame was taller) + if (visibleCount < rows) { + parts.push(ERASE_BELOW); + } + + return parts.join(""); +} + +function hasEraseScreen(s: string): boolean { + return s.includes(ERASE_SCREEN); +} + +// ── stdout.write Wrapper ──────────────────────────────── + +function installWrapper(stream: NodeJS.WriteStream): void { + if (!stream.isTTY) { + log("wrapper: skip (not TTY)"); + return; + } + + const originalWrite = stream.write.bind(stream) as typeof stream.write; + let buffer: string[] = []; + let inSyncBlock = false; + let syncWriteCount = 0; + let lastFlushTime = 0; + + log(`wrapper: installing (SYNC_SUPPORTED=${SYNC_SUPPORTED})`); + + const wrappedWrite: typeof stream.write = function ( + this: NodeJS.WriteStream, + chunk: any, + encodingOrCb?: BufferEncoding | ((error?: Error | null) => void), + cb?: (error?: Error | null) => void, + ): boolean { + const str = typeof chunk === "string" ? chunk : chunk.toString(); + const encoding = typeof encodingOrCb === "string" ? encodingOrCb : undefined; + const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb; + + writeCounter++; + const wn = writeCounter; + const now = Date.now(); + + // ── BSU/ESU buffering (only when DEC 2026 is supported) ── + + if (SYNC_SUPPORTED) { + if (str === BSU) { + inSyncBlock = true; + syncWriteCount = 0; + buffer.push(str); + callback?.(); + return true; + } + + if (str === ESU) { + buffer.push(str); + let merged = buffer.join(""); + buffer = []; + inSyncBlock = false; + frameCounter++; + + // Safety: strip erase-screen if present + const hadClear = hasEraseScreen(merged); + if (hadClear) { + // Unwrap BSU/ESU, rewrite as CUP, re-wrap + let inner = merged; + const hadBSU = inner.startsWith(BSU); + const hadESU = inner.endsWith(ESU); + if (hadBSU) inner = inner.slice(BSU.length); + if (hadESU) inner = inner.slice(0, -ESU.length); + inner = rewriteClearFrame(inner, stream.rows || 24); + merged = (hadBSU ? BSU : "") + inner + (hadESU ? ESU : ""); + // Update maxRenderedLines + const lines = inner.split("\n").length; + maxRenderedLines = Math.min(lines, stream.rows || 24); + } else { + // Check for content shrinking and add ERASE_BELOW if needed + const nlCount = (merged.match(/\n/g) || []).length; + if (maxRenderedLines > 0 && nlCount + 1 < maxRenderedLines) { + // Unwrap ESU, add ERASE_BELOW, re-add ESU + const hasTrailingESU = merged.endsWith(ESU); + if (hasTrailingESU) { + merged = merged.slice(0, -ESU.length) + ERASE_BELOW + ESU; + } else { + merged = merged + ERASE_BELOW; + } + log(`FRAME#${frameCounter}: SHRINK ${nlCount + 1}<${maxRenderedLines} +ERASE_BELOW`); + } else if (nlCount + 1 > maxRenderedLines) { + maxRenderedLines = nlCount + 1; + } + } + + const gap = lastFlushTime ? now - lastFlushTime : 0; + lastFlushTime = now; + + log(`FRAME#${frameCounter} w#${wn}: ${syncWriteCount}inner ${merged.length}b gap=${gap}ms${hadClear ? " STRIP!" : ""} | ${analyzeAnsi(merged)}`); + + if (frameCounter <= 10 || frameCounter % 100 === 0) { + log(` content: ${esc(merged, 500)}`); + } + + return originalWrite(merged, encoding as any, callback as any); + } + + if (inSyncBlock) { + buffer.push(str); + syncWriteCount++; + if (frameCounter < 5) { + log(` inner#${syncWriteCount} w#${wn}: +${str.length}b | ${analyzeAnsi(str)}`); + } + callback?.(); + return true; + } + } + + // ── Non-sync writes ── + + let output = str; + const hadClear = hasEraseScreen(output); + if (hadClear) { + output = rewriteClearFrame(output, stream.rows || 24); + // rewriteClearFrame already includes ERASE_BELOW, and positions content + // at top of screen. Update maxRenderedLines to the visible count. + const lines = str.split("\n").length; + maxRenderedLines = Math.min(lines, stream.rows || 24); + log(`NOSYNC w#${wn}: STRIP! ${str.length}b→${output.length}b maxLines=${maxRenderedLines} | ${analyzeAnsi(output)}`); + } else { + // Count newlines in this frame + const nlCount = (str.match(/\n/g) || []).length; + + // Check if content shrank below our max rendered height + // If so, we need to erase the orphaned lines at the bottom + if (maxRenderedLines > 0 && nlCount + 1 < maxRenderedLines) { + // After Ink writes, cursor is at bottom of new content. + // Add ERASE_BELOW to clear any orphaned lines below it. + output = str + ERASE_BELOW; + log(`NOSYNC w#${wn}: SHRINK ${nlCount + 1}<${maxRenderedLines} +ERASE_BELOW | ${analyzeAnsi(output)}`); + // Keep maxRenderedLines at current level until next clearTerminal + } else { + // Update max if we rendered more lines + if (nlCount + 1 > maxRenderedLines) { + maxRenderedLines = nlCount + 1; + } + if (wn <= 20 || wn % 200 === 0) { + log(`NOSYNC w#${wn}: ${str.length}b maxLines=${maxRenderedLines} | ${analyzeAnsi(str)}`); + } + } + } + + if (output !== str) { + return originalWrite(output, encoding as any, callback as any); + } + return originalWrite(chunk, encoding as any, callback as any); + } as typeof stream.write; + + stream.write = wrappedWrite; + log("wrapper: installed"); +} + +// ── Public API ────────────────────────────────────────── + +export function patchInkLogUpdate(): void { + initLog(); + log("patch: starting"); + + installWrapper(process.stdout); + + log(`patch: done — log at ${LOG_FILE}`); + process.stderr.write(`[renderer] patched, log: ${LOG_FILE}\n`); +} + +// ── Re-exports ────────────────────────────────────────── + +export { BSU, ESU, SYNC_SUPPORTED } from "./terminal-detect.ts"; +export { createScreen, resetScreen } from "./screen.ts"; +export { parseAnsiToScreen } from "./ansi-parser.ts"; +export { computeDiff, screensEqual } from "./diff.ts"; +export { buildPatch, buildFrameOutput } from "./patch-writer.ts"; +export type { Screen, Viewport, CursorPosition } from "./types.ts"; diff --git a/src/kimi_cli_ts/ui/renderer/patch-writer.ts b/src/kimi_cli_ts/ui/renderer/patch-writer.ts new file mode 100644 index 000000000..3e20db292 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/patch-writer.ts @@ -0,0 +1,312 @@ +/** + * Patch writer — converts cell-level diff into optimized ANSI output. + * + * Given two screens and the viewport, produces a single string containing + * all the ANSI sequences needed to update the terminal from prev → next. + * + * Optimizations (inspired by Claude Code's log-update.ts): + * - Only writes changed cells (not entire changed lines) + * - Uses relative cursor movement between changed cells + * - Caches style transitions (from → to) to minimize SGR bytes + * - Uses carriage return (\r) when cheaper than CUB + * - Skips SpacerTail cells (terminal auto-advances for wide chars) + * - Wraps entire output in BSU/ESU for atomic terminal paint + */ + +import { type Screen, type Viewport, CellWidth, STYLE_SHIFT, WIDTH_MASK } from "./types.ts"; +import { type ChangedCell, computeDiff } from "./diff.ts"; +import { cursorMove, cursorTo, ERASE_END_LINE, SGR_RESET, CR, LF, HIDE_CURSOR, SHOW_CURSOR } from "./csi.ts"; +import { BSU, ESU, SYNC_SUPPORTED } from "./terminal-detect.ts"; + +// ── Style Transition Cache ────────────────────────────── + +/** + * Cache for style transition strings. Key = (fromId << 16 | toId). + * Avoids recomputing SGR diffs for the same style pair every frame. + */ +const transitionCache = new Map(); + +/** + * Compute the SGR sequence to transition from one style to another. + * + * Strategy: We use the simplest approach — if styles differ, emit a reset + * followed by the target style. This is slightly more bytes than a minimal + * SGR diff but is always correct and avoids complex SGR state tracking. + */ +function styleTransition( + screen: Screen, + fromId: number, + toId: number, +): string { + if (fromId === toId) return ""; + + const key = (fromId << 16) | toId; + let cached = transitionCache.get(key); + if (cached !== undefined) return cached; + + const toStyle = screen.stylePool[toId] ?? ""; + + if (toId === 0) { + // Target is no style — just reset + cached = SGR_RESET; + } else if (fromId === 0) { + // From no style — just emit target + cached = toStyle; + } else { + // From one style to another — reset then apply target + // This is conservative; a smarter approach would diff the SGR attributes. + cached = SGR_RESET + toStyle; + } + + transitionCache.set(key, cached); + return cached; +} + +// ── Patch Builder ─────────────────────────────────────── + +/** + * Build the optimized ANSI patch string to update the terminal from prev → next. + * + * @param prev Previous frame's screen buffer + * @param next Current frame's screen buffer + * @param viewport Terminal viewport dimensions + * @param prevCursorY The row where the cursor was left after the previous render + * @returns Object with the ANSI string to write and the new cursor Y position + */ +export function buildPatch( + prev: Screen, + next: Screen, + viewport: Viewport, + prevCursorY: number, +): { output: string; cursorY: number } { + const changes = computeDiff(prev, next); + + if (changes.length === 0) { + return { output: "", cursorY: prevCursorY }; + } + + // Cursor starts at (0, prevCursorY) — the position left by the previous frame. + // In main-screen mode, cursor is at the line AFTER content (content height). + let curX = 0; + let curY = prevCursorY; + let curStyleId = 0; // Tracks the active SGR state + + const parts: string[] = []; + + // ── Handle height changes ── + + const heightDelta = next.height - prev.height; + const shrinking = heightDelta < 0; + const growing = heightDelta > 0; + + // For growing: we'll need to emit newlines later to create new rows. + // For shrinking: we need to erase extra lines. + if (shrinking) { + // Move cursor to the first line that needs to be erased + // prev.height lines exist, next.height lines will remain + // Erase from next.height to prev.height-1 + const linesToClear = prev.height - next.height; + // Move to the last content line (prev.height - 1), then erase downward + // Actually, simpler: after writing all changes, erase the extra lines. + // We'll handle this at the end. + } + + // ── Write changed cells ── + + // Changes are already in reading order (row-major from the diff). + // We need to track whether we've "reached" each row. For rows in the + // overlapping region, we use cursor movement. For new rows (growing), + // we emit newlines. + + let lastEmittedRow = -1; + + for (const change of changes) { + const { x, y, charId, styleId, width, clear } = change; + + // Skip SpacerTail — the terminal auto-advances when writing a wide char + if (width === CellWidth.SpacerTail) continue; + + // For cells beyond the viewport, we can't reach them with cursor moves. + // In main-screen mode, rows above viewport.height from the bottom are + // in scrollback. We skip these and accept the flicker for edge cases. + // (Claude Code also does this with fullResetSequence_CAUSES_FLICKER.) + + // ── Position cursor at (x, y) ── + + if (y !== curY || x !== curX) { + // Vertical movement + const dy = y - curY; + + if (dy > 0 && y >= prev.height) { + // Need to create new rows — can only do this from the bottom of content + // First, go to end of current content + if (curY < prev.height) { + const moveToEnd = prev.height - curY; + if (moveToEnd > 0) { + parts.push(CR); + if (moveToEnd > 1) parts.push(cursorMove(0, moveToEnd - 1)); + curX = 0; + curY = prev.height - 1; + } + } + // Emit newlines to create rows up to y + while (curY < y) { + parts.push(LF); + curY++; + } + curX = 0; + } else if (dy !== 0) { + // Normal cursor movement (up or down within existing content) + parts.push(cursorMove(0, dy)); + curY = y; + // Horizontal position is unknown after vertical move in some terminals, + // but cursorMove only emits vertical codes. curX stays the same. + } + + // Horizontal movement + if (x !== curX) { + if (x === 0) { + parts.push(CR); + } else if (curY !== y) { + // After vertical move, use absolute column + parts.push(cursorTo(x)); + } else { + // Same row, use relative + const dx = x - curX; + parts.push(cursorMove(dx, 0)); + } + curX = x; + } + } + + // ── Write the cell content ── + + if (clear) { + // This cell needs to be cleared + if (curStyleId !== 0) { + parts.push(SGR_RESET); + curStyleId = 0; + } + parts.push(" "); + curX++; + } else { + // Apply style transition + const transition = styleTransition(next, curStyleId, styleId); + if (transition) { + parts.push(transition); + curStyleId = styleId; + } + + // Write the character + const char = next.charPool[charId] ?? " "; + parts.push(char); + + // Advance cursor + if (width === CellWidth.Wide) { + curX += 2; // Wide char advances 2 columns + } else { + curX += 1; + } + } + } + + // ── Handle shrinking: erase extra rows ── + + if (shrinking) { + // Reset styles first + if (curStyleId !== 0) { + parts.push(SGR_RESET); + curStyleId = 0; + } + + // Move to the first row that needs clearing + const firstClearRow = next.height; + if (curY !== firstClearRow) { + const dy = firstClearRow - curY; + parts.push(cursorMove(0, dy)); + curY = firstClearRow; + } + + // Erase each extra row + for (let r = firstClearRow; r < prev.height; r++) { + parts.push(CR + ERASE_END_LINE); + if (r < prev.height - 1) { + parts.push(cursorMove(0, 1)); + curY++; + } + } + } + + // ── Reset style at end ── + + if (curStyleId !== 0) { + parts.push(SGR_RESET); + curStyleId = 0; + } + + // ── Position cursor below content ── + // In main-screen mode, cursor should be at (0, next.height) — one line + // below the last content line. This is where Ink expects it. + + const targetCursorY = next.height; + if (curY !== targetCursorY || curX !== 0) { + if (curY < targetCursorY) { + // Need to go down. If we're at or past the last content row, + // use newlines to potentially create the row. + parts.push(CR); + const dy = targetCursorY - curY; + for (let i = 0; i < dy; i++) { + parts.push(LF); + } + } else if (curY > targetCursorY) { + parts.push(CR); + parts.push(cursorMove(0, targetCursorY - curY)); + } else { + parts.push(CR); + } + curX = 0; + curY = targetCursorY; + } + + const output = parts.join(""); + return { output, cursorY: targetCursorY }; +} + +/** + * Build the complete output for a frame, including BSU/ESU wrapping and + * cursor hide/show. + */ +export function buildFrameOutput( + prev: Screen, + next: Screen, + viewport: Viewport, + prevCursorY: number, + showCursor: boolean, +): { output: string; cursorY: number } { + const { output: patch, cursorY } = buildPatch( + prev, + next, + viewport, + prevCursorY, + ); + + if (patch.length === 0) { + return { output: "", cursorY }; + } + + let output = ""; + + // Wrap in synchronized update if supported + if (SYNC_SUPPORTED) output += BSU; + + // Hide cursor during update to prevent flicker + if (!showCursor) output += HIDE_CURSOR; + + output += patch; + + if (!showCursor) output += SHOW_CURSOR; + + if (SYNC_SUPPORTED) output += ESU; + + return { output, cursorY }; +} diff --git a/src/kimi_cli_ts/ui/renderer/screen.ts b/src/kimi_cli_ts/ui/renderer/screen.ts new file mode 100644 index 000000000..ab471cdac --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/screen.ts @@ -0,0 +1,241 @@ +/** + * Screen buffer with packed Int32Array cell storage. + * + * Inspired by Claude Code's screen.ts — eliminates GC pressure by storing + * cell data as packed integers instead of objects. Each cell uses 2 Int32: + * word0: charId (index into charPool) + * word1: (styleId << 2) | width + * + * CharPool and StylePool intern strings so diffing reduces to integer comparison. + */ + +import { + type Screen, + type CellWidthType, + CellWidth, + STYLE_SHIFT, + WIDTH_MASK, +} from "./types.ts"; + +// ── CharPool Defaults ─────────────────────────────────── + +const DEFAULT_CHAR = " "; +const DEFAULT_CHAR_ID = 0; + +// ── Screen Factory ────────────────────────────────────── + +/** Create a new screen buffer. All cells are initialized to empty (space, no style). */ +export function createScreen(width: number, height: number): Screen { + const size = width * height; + return { + width, + height, + // Zero-filled ArrayBuffer: word0=0 (space charId), word1=0 (styleId=0, width=Narrow) + cells: new Int32Array(size * 2), + charPool: [DEFAULT_CHAR], // index 0 = space + charMap: new Map([[DEFAULT_CHAR, DEFAULT_CHAR_ID]]), + stylePool: [""], // index 0 = no style (reset) + styleMap: new Map([["", 0]]), + }; +} + +/** + * Reset an existing screen for reuse. Reallocates cells if dimensions changed, + * otherwise zero-fills. Preserves pools across frames for stable IDs. + */ +export function resetScreen( + screen: Screen, + width: number, + height: number, +): void { + const size = width * height; + const needed = size * 2; + + screen.width = width; + screen.height = height; + + if (screen.cells.length !== needed) { + screen.cells = new Int32Array(needed); + } else { + screen.cells.fill(0); + } +} + +// ── String Interning ──────────────────────────────────── + +/** Intern a character string and return its pool ID. */ +export function internChar(screen: Screen, char: string): number { + let id = screen.charMap.get(char); + if (id !== undefined) return id; + + id = screen.charPool.length; + screen.charPool.push(char); + screen.charMap.set(char, id); + return id; +} + +/** Intern a SGR style string and return its pool ID. */ +export function internStyle(screen: Screen, style: string): number { + let id = screen.styleMap.get(style); + if (id !== undefined) return id; + + id = screen.stylePool.length; + screen.stylePool.push(style); + screen.styleMap.set(style, id); + return id; +} + +// ── Cell Access ───────────────────────────────────────── + +/** Pack styleId and width into word1. */ +export function packWord1(styleId: number, width: CellWidthType): number { + return (styleId << STYLE_SHIFT) | width; +} + +/** Write a cell at (x, y). Bounds-checked; out-of-bounds writes are silently dropped. */ +export function setCellAt( + screen: Screen, + x: number, + y: number, + charId: number, + styleId: number, + width: CellWidthType, +): void { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return; + + const ci = (y * screen.width + x) * 2; + screen.cells[ci] = charId; + screen.cells[ci + 1] = packWord1(styleId, width); +} + +/** Read a cell at (x, y). Returns charId, styleId, width. */ +export function cellAt( + screen: Screen, + x: number, + y: number, +): { charId: number; styleId: number; width: CellWidthType } { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return { charId: DEFAULT_CHAR_ID, styleId: 0, width: CellWidth.Narrow }; + } + + const ci = (y * screen.width + x) * 2; + const w0 = screen.cells[ci]!; + const w1 = screen.cells[ci + 1]!; + return { + charId: w0, + styleId: w1 >>> STYLE_SHIFT, + width: (w1 & WIDTH_MASK) as CellWidthType, + }; +} + +/** Check if a cell is empty (space, no style, narrow). */ +export function isEmptyCell(screen: Screen, x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return true; + const ci = (y * screen.width + x) * 2; + return screen.cells[ci] === 0 && screen.cells[ci + 1] === 0; +} + +/** Resolve a charId to its string. */ +export function getChar(screen: Screen, charId: number): string { + return screen.charPool[charId] ?? DEFAULT_CHAR; +} + +/** Resolve a styleId to its SGR string. */ +export function getStyle(screen: Screen, styleId: number): string { + return screen.stylePool[styleId] ?? ""; +} + +// ── Diff Helper ───────────────────────────────────────── + +/** + * Iterate over cells that differ between prev and next screen. + * + * Callback receives (x, y) coordinates of each changed cell. + * Both screens must have the same dimensions for meaningful comparison; + * if dimensions differ, all cells in the overlapping region are compared + * and cells outside the overlap are treated as "added" or "removed". + * + * Comparison is pure integer: two Int32 per cell, skip if both match. + */ +export function diffCells( + prev: Screen, + next: Screen, + cb: (x: number, y: number) => boolean | void, +): void { + const pw = prev.width; + const nw = next.width; + const ph = prev.height; + const nh = next.height; + const maxH = Math.max(ph, nh); + const maxW = Math.max(pw, nw); + const minH = Math.min(ph, nh); + const minW = Math.min(pw, nw); + + const pc = prev.cells; + const nc = next.cells; + + // Compare overlapping region + for (let y = 0; y < minH; y++) { + const pRowBase = y * pw * 2; + const nRowBase = y * nw * 2; + + for (let x = 0; x < minW; x++) { + const pi = pRowBase + x * 2; + const ni = nRowBase + x * 2; + // Fast: compare both int32 words + if (pc[pi] !== nc[ni] || pc[pi + 1] !== nc[ni + 1]) { + if (cb(x, y) === true) return; + } + } + + // Cells in next that extend beyond prev width (added) + for (let x = minW; x < nw; x++) { + const ni = nRowBase + x * 2; + // Only emit if non-empty + if (nc[ni] !== 0 || nc[ni + 1] !== 0) { + if (cb(x, y) === true) return; + } + } + + // Cells in prev that extend beyond next width (removed) + for (let x = minW; x < pw; x++) { + const pi = pRowBase + x * 2; + if (pc[pi] !== 0 || pc[pi + 1] !== 0) { + if (cb(x, y) === true) return; + } + } + } + + // Rows only in next (added) + for (let y = minH; y < nh; y++) { + const nRowBase = y * nw * 2; + for (let x = 0; x < nw; x++) { + const ni = nRowBase + x * 2; + if (nc[ni] !== 0 || nc[ni + 1] !== 0) { + if (cb(x, y) === true) return; + } + } + } + + // Rows only in prev (removed) — these cells need to be cleared + for (let y = minH; y < ph; y++) { + const pRowBase = y * pw * 2; + for (let x = 0; x < pw; x++) { + const pi = pRowBase + x * 2; + if (pc[pi] !== 0 || pc[pi + 1] !== 0) { + if (cb(x, y) === true) return; + } + } + } +} + +/** + * Copy pools from source screen to target screen. + * Used so that charId/styleId from source are valid in target. + */ +export function copyPools(from: Screen, to: Screen): void { + to.charPool = from.charPool; + to.charMap = from.charMap; + to.stylePool = from.stylePool; + to.styleMap = from.styleMap; +} diff --git a/src/kimi_cli_ts/ui/renderer/terminal-detect.ts b/src/kimi_cli_ts/ui/renderer/terminal-detect.ts new file mode 100644 index 000000000..76f1e1b7a --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/terminal-detect.ts @@ -0,0 +1,78 @@ +/** + * Terminal capability detection for DEC 2026 (Synchronized Output). + * + * When supported, BSU/ESU sequences tell the terminal to buffer all output + * between them and paint atomically — preventing visible flicker and + * preserving text selection during re-renders. + * + * Ported from Claude Code's terminal.ts with the same detection logic. + */ + +// ── DEC 2026 Sequences ────────────────────────────────── + +/** Begin Synchronized Update — terminal buffers subsequent output. */ +export const BSU = "\x1b[?2026h"; + +/** End Synchronized Update — terminal paints buffered output atomically. */ +export const ESU = "\x1b[?2026l"; + +// ── Detection ─────────────────────────────────────────── + +/** + * Check if the terminal supports DEC mode 2026 (synchronized output). + * + * Detection is based on known terminal programs that implement the protocol. + * tmux is excluded because it breaks atomicity by chunking writes. + */ +export function isSyncOutputSupported(): boolean { + // tmux parses and proxies every byte but doesn't implement DEC 2026. + // BSU/ESU pass through to the outer terminal but tmux has already + // broken atomicity by chunking. Skip. + if (process.env.TMUX) return false; + + const termProgram = process.env.TERM_PROGRAM; + const term = process.env.TERM; + + // Modern terminals with known DEC 2026 support + if ( + termProgram === "iTerm.app" || + termProgram === "WezTerm" || + termProgram === "WarpTerminal" || + termProgram === "ghostty" || + termProgram === "contour" || + termProgram === "vscode" || + termProgram === "alacritty" + ) { + return true; + } + + // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID + if (term?.includes("kitty") || process.env.KITTY_WINDOW_ID) return true; + + // Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM + if (term === "xterm-ghostty") return true; + + // foot sets TERM=foot or TERM=foot-extra + if (term?.startsWith("foot")) return true; + + // Alacritty may set TERM containing 'alacritty' + if (term?.includes("alacritty")) return true; + + // Zed uses the alacritty_terminal crate which supports DEC 2026 + if (process.env.ZED_TERM) return true; + + // Windows Terminal + if (process.env.WT_SESSION) return true; + + // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68 + const vteVersion = process.env.VTE_VERSION; + if (vteVersion) { + const version = parseInt(vteVersion, 10); + if (!isNaN(version) && version >= 6800) return true; + } + + return false; +} + +/** Computed once at module load — terminal capabilities don't change mid-session. */ +export const SYNC_SUPPORTED = isSyncOutputSupported(); diff --git a/src/kimi_cli_ts/ui/renderer/types.ts b/src/kimi_cli_ts/ui/renderer/types.ts new file mode 100644 index 000000000..bd1001382 --- /dev/null +++ b/src/kimi_cli_ts/ui/renderer/types.ts @@ -0,0 +1,69 @@ +/** + * Shared type definitions for the optimized renderer. + * + * Inspired by Claude Code's screen.ts — uses packed Int32Array for cell storage + * to eliminate GC pressure. Each cell is 2 Int32 elements: + * word0: charId (index into CharPool) + * word1: styleId << 2 | width + */ + +// ── Cell Width ────────────────────────────────────────── + +/** Cell width classification for double-wide characters (CJK, emoji). */ +export const CellWidth = { + /** Normal single-width character. */ + Narrow: 0, + /** Wide character (occupies 2 columns). This cell holds the actual char. */ + Wide: 1, + /** Second column of a wide character. Skip during rendering. */ + SpacerTail: 2, +} as const; + +export type CellWidthType = (typeof CellWidth)[keyof typeof CellWidth]; + +// ── Screen ────────────────────────────────────────────── + +/** + * Screen buffer using packed Int32Array. + * + * Cell layout (2 × Int32 per cell): + * word0 (cells[i*2]): charId — index into charPool + * word1 (cells[i*2 + 1]): styleId << 2 | width + * + * This layout avoids allocating Cell objects (zero GC pressure) and enables + * fast integer comparison in diffing. + */ +export type Screen = { + width: number; + height: number; + /** Packed cell data: 2 Int32 per cell. Length = width * height * 2. */ + cells: Int32Array; + /** Interned character strings. Index 0 = ' ' (space). */ + charPool: string[]; + /** Map from char string → charPool index (for fast intern). */ + charMap: Map; + /** Interned SGR style strings. Index 0 = '' (no style / reset). */ + stylePool: string[]; + /** Map from style string → stylePool index. */ + styleMap: Map; +}; + +// ── Packed Cell Access ────────────────────────────────── + +/** Bit layout for word1: styleId occupies bits [31:2], width occupies bits [1:0]. */ +export const STYLE_SHIFT = 2; +export const WIDTH_MASK = 0x3; + +// ── Viewport ──────────────────────────────────────────── + +export type Viewport = { + width: number; + height: number; +}; + +// ── Cursor ────────────────────────────────────────────── + +export type CursorPosition = { + x: number; + y: number; +}; diff --git a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx index 39badd006..83604e92b 100644 --- a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx @@ -3,15 +3,15 @@ * Corresponds to Python's ui/shell/approval_panel.py. * * Features: - * - 4 options: approve once (y), approve for session (a), reject (n), reject with feedback (f) - * - Diff preview panel - * - Inline feedback input - * - Truncation with expand hint + * - 4 options: approve once, approve for session, reject, reject with feedback + * - Content preview with line-budget truncation (diff, shell, brief) + * - Inline feedback input with draft persistence * - Keyboard navigation (↑↓ or 1-4 number keys) */ -import React, { useState, useCallback } from "react"; -import { Box, Text, useInput } from "ink"; +import React, { useState, useCallback, useRef } from "react"; +import { Box, Text } from "ink"; +import { useInputLayer } from "./input-stack.ts"; import type { ApprovalRequest, ApprovalResponseKind, @@ -22,6 +22,7 @@ import type { } from "../../wire/types"; const MAX_PREVIEW_LINES = 4; +const FEEDBACK_OPTION_INDEX = 3; interface ApprovalOption { label: string; @@ -35,8 +36,6 @@ const OPTIONS: ApprovalOption[] = [ { label: "Reject, tell the model what to do instead", response: "reject" }, ]; -const FEEDBACK_OPTION_INDEX = 3; - // ── DiffPreview ────────────────────────────────────────── function DiffPreview({ blocks }: { blocks: DisplayBlock[] }) { @@ -88,7 +87,13 @@ function DiffPreview({ blocks }: { blocks: DisplayBlock[] }) { // ── ContentPreview ─────────────────────────────────────── -function ContentPreview({ blocks }: { blocks: DisplayBlock[] }) { +function ContentPreview({ + blocks, + truncatedRef, +}: { + blocks: DisplayBlock[]; + truncatedRef: React.MutableRefObject; +}) { let budget = MAX_PREVIEW_LINES; let truncated = false; const elements: React.ReactNode[] = []; @@ -119,18 +124,20 @@ function ContentPreview({ blocks }: { blocks: DisplayBlock[] }) { if (lines.length > budget) truncated = true; budget -= showLines.length; elements.push( - + {showLines.join("\n")} , ); } } + truncatedRef.current = truncated; + return ( {elements} {truncated && ( - + ... (truncated, ctrl-e to expand) )} @@ -152,29 +159,42 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { const [selectedIndex, setSelectedIndex] = useState(0); const [feedbackMode, setFeedbackMode] = useState(false); const [feedbackText, setFeedbackText] = useState(""); + // Feedback draft: persisted when navigating away from option 4 + const feedbackDraftRef = useRef(""); + const nonDiffTruncatedRef = useRef(false); - const isFeedbackSelected = selectedIndex === FEEDBACK_OPTION_INDEX; + const hasDiff = request.display.some((b) => b.type === "diff"); + const hasExpandableContent = hasDiff || nonDiffTruncatedRef.current; const submit = useCallback( (index: number) => { if (index === FEEDBACK_OPTION_INDEX) { setFeedbackMode(true); + // Restore draft if available + if (feedbackDraftRef.current) { + setFeedbackText(feedbackDraftRef.current); + } return; } + feedbackDraftRef.current = ""; onRespond(OPTIONS[index]!.response); }, [onRespond], ); - useInput((input, key) => { + useInputLayer((input, key) => { if (feedbackMode) { if (key.return) { + // Only submit if non-empty (matches Python: empty enter does nothing) if (feedbackText.trim()) { + feedbackDraftRef.current = ""; onRespond("reject", feedbackText.trim()); } return; } if (key.escape) { + // Esc in feedback mode: reject with empty feedback + feedbackDraftRef.current = ""; onRespond("reject", ""); return; } @@ -182,6 +202,23 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { setFeedbackText((t) => t.slice(0, -1)); return; } + if (key.upArrow) { + // Save draft, navigate away + feedbackDraftRef.current = feedbackText; + setFeedbackMode(false); + setFeedbackText(""); + setSelectedIndex( + (i) => (i - 1 + OPTIONS.length) % OPTIONS.length, + ); + return; + } + if (key.downArrow) { + feedbackDraftRef.current = feedbackText; + setFeedbackMode(false); + setFeedbackText(""); + setSelectedIndex((i) => (i + 1) % OPTIONS.length); + return; + } if (input && !key.ctrl && !key.meta) { setFeedbackText((t) => t + input); } @@ -190,9 +227,23 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { // Normal navigation if (key.upArrow) { - setSelectedIndex((i) => (i - 1 + OPTIONS.length) % OPTIONS.length); + setSelectedIndex((prev) => { + const next = (prev - 1 + OPTIONS.length) % OPTIONS.length; + if (next === FEEDBACK_OPTION_INDEX && feedbackDraftRef.current) { + setFeedbackMode(true); + setFeedbackText(feedbackDraftRef.current); + } + return next; + }); } else if (key.downArrow) { - setSelectedIndex((i) => (i + 1) % OPTIONS.length); + setSelectedIndex((prev) => { + const next = (prev + 1) % OPTIONS.length; + if (next === FEEDBACK_OPTION_INDEX && feedbackDraftRef.current) { + setFeedbackMode(true); + setFeedbackText(feedbackDraftRef.current); + } + return next; + }); } else if (key.return) { submit(selectedIndex); } else if (key.escape) { @@ -201,20 +252,22 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { const idx = parseInt(input) - 1; if (idx < OPTIONS.length) { setSelectedIndex(idx); - if (idx !== FEEDBACK_OPTION_INDEX) { - submit(idx); - } else { + if (idx === FEEDBACK_OPTION_INDEX) { setFeedbackMode(true); + if (feedbackDraftRef.current) { + setFeedbackText(feedbackDraftRef.current); + } + } else { + submit(idx); } } } }); - const hasDiff = request.display.some((b) => b.type === "diff"); - const hasContent = - hasDiff || - !!request.description || - request.display.some((b) => b.type === "shell" || b.type === "brief"); + // Check whether we have non-diff content blocks + const hasNonDiffBlocks = request.display.some( + (b) => b.type === "shell" || b.type === "brief", + ); return ( - {/* Title */} + {/* Title — matches Python Panel(title="⚠ ACTION REQUIRED", border_style="bold yellow") */} ⚠ ACTION REQUIRED - + {" "} - {/* Request header */} + {/* Request header — matches Python render() content_lines */} {request.sender} is requesting approval to {request.action}: - {/* Source metadata */} + {/* Source metadata — matches Python _render_source_metadata_lines() */} {(request.subagent_type || request.agent_id) && ( Subagent:{" "} @@ -249,9 +302,9 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { )} - + {" "} - {/* Description */} + {/* Description (only if no display blocks) — matches Python line 74-83 */} {request.description && !request.display.length && ( {truncateLines(request.description, MAX_PREVIEW_LINES)} @@ -265,24 +318,26 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { )} - {/* Non-diff content preview */} - {request.display.some( - (b) => b.type === "shell" || b.type === "brief", - ) && ( + {/* Non-diff content preview (shell/brief) with line budget */} + {hasNonDiffBlocks && ( - + )} - + {" "} - {/* Options */} + {/* Options — matches Python render() menu section */} {OPTIONS.map((option, i) => { const num = i + 1; const isSelected = i === selectedIndex; - const isFeedback = i === FEEDBACK_OPTION_INDEX; + const isFeedbackOption = i === FEEDBACK_OPTION_INDEX; - if (isFeedback && feedbackMode && isSelected) { + // Feedback input line: → [4] Reject: {text}█ + if (isFeedbackOption && feedbackMode && isSelected) { return ( → [{num}] Reject: {feedbackText}█ @@ -291,26 +346,23 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { } return ( - + {isSelected ? "→" : " "} [{num}] {option.label} ); })} - + {" "} - {/* Keyboard hints */} + {/* Keyboard hints — matches Python render() hint lines */} {feedbackMode ? ( {" "}Type your feedback, then press Enter to submit. ) : ( - {" "}▲/▼ select {" "}1/2/3/4 choose {" "}↵ confirm - {hasContent ? " ctrl-e expand" : ""} + {" "}▲/▼ select{" "}1/2/3/4 choose{" "}↵ confirm + {hasExpandableContent ? " ctrl-e expand" : ""} )} diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 15ab645c5..639edc21f 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -10,8 +10,9 @@ * - Backspace/Delete → remove char * - Left/Right → cursor movement * - * This eliminates the multi-useInput conflict where ink-text-input's - * internal useInput would receive leaked characters from Ctrl shortcuts. + * Also supports panelInput mode: when a CommandPanel config with type="input" + * is active, Prompt acts as that panel's input field (title shown, password + * masking, Enter submits to panel callback). */ import React, { useState, useCallback, useRef } from "react"; @@ -25,9 +26,11 @@ import { getFilteredCommand, } from "../components/SlashMenu.tsx"; import { MentionMenu } from "../components/MentionMenu.tsx"; -import type { SlashCommand } from "../../types.ts"; +import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; import type { KeyAction } from "./keyboard.ts"; +type PanelInputConfig = Extract; + interface PromptProps { onSubmit: (input: string) => void; onOpenPanel?: (cmd: SlashCommand) => void; @@ -39,10 +42,16 @@ interface PromptProps { workDir?: string; commands?: SlashCommand[]; onSlashMenuChange?: (visible: boolean) => void; + /** Called with the menu ReactNode to render outside Prompt (in the bottom slot) */ + onMenuPortal?: (menu: React.ReactNode) => void; /** Incremented by parent to signal "clear the input box" */ clearSignal?: number; /** One-shot prefill text for the input (e.g. from /undo) */ prefillText?: string; + /** When set, Prompt acts as input for this panel config */ + panelInput?: PanelInputConfig | null; + /** Called when panel input is submitted */ + onPanelInputSubmit?: (value: string) => void; } export function Prompt({ @@ -56,8 +65,11 @@ export function Prompt({ workDir, commands = [], onSlashMenuChange, + onMenuPortal, clearSignal = 0, prefillText, + panelInput = null, + onPanelInputSubmit, }: PromptProps) { const { value, setValue, historyPrev, historyNext, addToHistory, isFromHistory } = useInputHistory(); @@ -67,9 +79,24 @@ export function Prompt({ const [bufferedLines, setBufferedLines] = useState([]); const [cursorOffset, setCursorOffset] = useState(0); - // @ file mention - const mention = useFileMention(value, workDir); - const showMentionMenu = mention.isActive && mention.suggestions.length > 0 && !shellMode; + // Track panel mode transitions: clear input when entering/leaving panel mode + const prevPanelRef = useRef(null); + React.useEffect(() => { + const entering = panelInput && !prevPanelRef.current; + const leaving = !panelInput && prevPanelRef.current; + if (entering || leaving) { + setValue(""); + setCursorOffset(0); + setBufferedLines([]); + } + prevPanelRef.current = panelInput; + }, [panelInput, setValue]); + + const inPanelMode = panelInput !== null; + + // @ file mention — disabled in panel mode + const mention = useFileMention(inPanelMode ? "" : value, workDir); + const showMentionMenu = !inPanelMode && mention.isActive && mention.suggestions.length > 0 && !shellMode; // React to clearSignal from parent (double-Esc) React.useEffect(() => { @@ -93,8 +120,9 @@ export function Prompt({ setCursorOffset((prev) => Math.min(prev, value.length)); }, [value]); - // Detect slash completion mode + // Detect slash completion mode — disabled in panel mode const isSlashMode = + !inPanelMode && value.startsWith("/") && !value.includes(" ") && commands.length > 0 && !isFromHistory; const slashFilter = isSlashMode ? value.slice(1) : ""; const menuCount = isSlashMode @@ -102,10 +130,44 @@ export function Prompt({ : 0; const showSlashMenu = isSlashMode && menuCount > 0; - // Notify parent about menu visibility + // Stable ref for menu portal callback to avoid re-render loops + const menuPortalRef = useRef(onMenuPortal); + menuPortalRef.current = onMenuPortal; + + // Track what menu state we last sent to portal to avoid unnecessary updates + const lastMenuKeyRef = useRef(""); + + // Notify parent about menu visibility and provide menu content React.useEffect(() => { - onSlashMenuChange?.(showSlashMenu || showMentionMenu); - }, [showSlashMenu, showMentionMenu, onSlashMenuChange]); + const hasMenu = showSlashMenu || showMentionMenu; + onSlashMenuChange?.(hasMenu); + + // Build a stable key to detect actual changes + const key = showSlashMenu + ? `slash:${slashFilter}:${slashMenuIndex}` + : showMentionMenu + ? `mention:${mention.fragment}:${mentionMenuIndex}` + : "none"; + + if (key !== lastMenuKeyRef.current) { + lastMenuKeyRef.current = key; + const portal = menuPortalRef.current; + if (portal) { + if (showSlashMenu) { + portal( + + ); + } else if (showMentionMenu) { + portal( + + ); + } else { + portal(null); + } + } + } + }, [showSlashMenu, showMentionMenu, onSlashMenuChange, + commands, slashFilter, slashMenuIndex, mention.suggestions, mention.fragment, mentionMenuIndex]); // Reset menu indices when filter changes React.useEffect(() => { @@ -131,6 +193,16 @@ export function Prompt({ // Submit handler const doSubmit = useCallback(() => { + // ── Panel input mode: submit to panel callback ── + if (inPanelMode && onPanelInputSubmit) { + const trimmed = value.trim(); + if (!trimmed) return; + onPanelInputSubmit(trimmed); + setValue(""); + setCursorOffset(0); + return; + } + // Mention menu: select item if (showMentionMenu) { const selected = mention.suggestions[mentionMenuIndex]; @@ -174,7 +246,7 @@ export function Prompt({ value, onSubmit, onOpenPanel, addToHistory, setValue, showSlashMenu, showMentionMenu, commands, slashFilter, slashMenuIndex, mention.suggestions, mentionMenuIndex, - applyMentionSelection, bufferedLines, + applyMentionSelection, bufferedLines, inPanelMode, onPanelInputSubmit, ]); // Paste clipboard text directly into value @@ -204,14 +276,14 @@ export function Prompt({ // ── Ctrl shortcuts → dispatch to parent, NO char insertion ── if (key.ctrl) { if (input === "c") { onAction?.("interrupt"); return; } - if (input === "x") { onAction?.("toggle-shell-mode"); return; } - if (input === "o") { onAction?.("open-editor"); return; } + if (input === "x" && !inPanelMode) { onAction?.("toggle-shell-mode"); return; } + if (input === "o" && !inPanelMode) { onAction?.("open-editor"); return; } if (input === "v") { // Ctrl+V: paste clipboard directly into value pasteClipboardIntoValue(); return; } - if (input === "j") { + if (input === "j" && !inPanelMode) { // Ctrl+J: push current line to buffer (multiline) setBufferedLines((prev) => [...prev, value]); setValue(""); @@ -228,14 +300,14 @@ export function Prompt({ return; } - // ── Shift+Tab → plan mode ── - if (key.shift && key.tab) { + // ── Shift+Tab → plan mode (not in panel mode) ── + if (key.shift && key.tab && !inPanelMode) { onAction?.("toggle-plan-mode"); return; } - // ── Tab (no shift) → menu completion ── - if (key.tab) { + // ── Tab (no shift) → menu completion (not in panel mode) ── + if (key.tab && !inPanelMode) { if (showMentionMenu) { const selected = mention.suggestions[mentionMenuIndex]; if (selected) applyMentionSelection(selected); @@ -261,7 +333,7 @@ export function Prompt({ setMentionMenuIndex((i) => Math.max(0, i - 1)); } else if (showSlashMenu) { setSlashMenuIndex((i) => Math.max(0, i - 1)); - } else { + } else if (!inPanelMode) { historyPrev(); } return; @@ -271,7 +343,7 @@ export function Prompt({ setMentionMenuIndex((i) => Math.min(mention.suggestions.length - 1, i + 1)); } else if (showSlashMenu) { setSlashMenuIndex((i) => Math.min(menuCount - 1, i + 1)); - } else { + } else if (!inPanelMode) { historyNext(); } return; @@ -310,19 +382,35 @@ export function Prompt({ const { stdout } = useStdout(); const columns = stdout?.columns ?? 80; - // Prompt symbol - const promptSymbol = shellMode ? "$ " : isStreaming ? "💫 " : planMode ? "📋 " : "✨ "; + // Prompt symbol — panel mode uses ▸, normal mode uses context-dependent emoji + const promptSymbol = inPanelMode + ? "▸ " + : shellMode ? "$ " : isStreaming ? "💫 " : planMode ? "📋 " : "✨ "; // Render value with fake cursor (matching ink-text-input style) - const renderedValue = renderWithCursor(value, cursorOffset); + // In password mode, mask characters + const displayValue = inPanelMode && panelInput?.password + ? "•".repeat(value.length) + : value; + const renderedValue = renderWithCursor(displayValue, Math.min(cursorOffset, displayValue.length)); return ( {/* Separator */} {"─".repeat(columns)} - {/* Buffered lines (multiline via Ctrl+J) */} - {bufferedLines.map((line, i) => ( + {/* Panel input title (when in panel mode) */} + {inPanelMode && ( + + + {panelInput!.title} + + (Enter submit, Esc cancel) + + )} + + {/* Buffered lines (multiline via Ctrl+J) — not in panel mode */} + {!inPanelMode && bufferedLines.map((line, i) => ( {i === 0 ? promptSymbol : " "} {line} @@ -331,21 +419,19 @@ export function Prompt({ {/* Input line with inline cursor */} - {bufferedLines.length > 0 ? " " : promptSymbol} + {!inPanelMode && bufferedLines.length > 0 ? " " : promptSymbol} {renderedValue} - {/* Slash command menu */} - {showSlashMenu && ( + {/* Menus rendered outside via onMenuPortal — only render inline if no portal */} + {!onMenuPortal && !inPanelMode && showSlashMenu && ( )} - - {/* @ file mention menu */} - {showMentionMenu && !showSlashMenu && ( + {!onMenuPortal && !inPanelMode && showMentionMenu && !showSlashMenu && ( + {/* Panel input title */} + {panelTitle && ( + + + {panelTitle} + + (Enter submit, Esc cancel) + + )} + + {/* Buffered lines (multiline via Ctrl+J) */} + {!panelTitle && + bufferedLines.map((line, i) => ( + + {i === 0 ? promptSymbol : " "} + {line} + + ))} + + {/* Input line with inline cursor */} + + + {!panelTitle && bufferedLines.length > 0 ? " " : promptSymbol} + + {renderedValue} + + + ); +} + +/** Render text with a fake inverse cursor at the given offset. */ +function renderWithCursor(text: string, offset: number): string { + if (text.length === 0) { + return chalk.inverse(" "); + } + const before = text.slice(0, offset); + const cursorChar = offset < text.length ? text[offset]! : " "; + const after = offset < text.length ? text.slice(offset + 1) : ""; + return before + chalk.inverse(cursorChar) + after; +} diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 646fd766d..458751c7f 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -1,28 +1,26 @@ /** * Shell.tsx — Main REPL component. - * Corresponds to Python's ui/shell/__init__.py. * - * Layout logic: - * - WelcomeBox: fixed at top (will scroll off when content grows) - * - ChatList: height = content lines (grows as messages added) - * - InputBox: flexGrow=1 + minHeight=6, fills remaining space - * - text starts from top (row 0) - * - when ChatList grows, InputBox shrinks down to minHeight - * - when InputBox is at minHeight, total layout exceeds screen → scrollable - * - StatusBar: always at bottom + * Shell is a thin orchestrator: + * - Owns useShellInput (all keyboard + UI state) + * - Wires external callbacks (submit, interrupt, plan mode, etc.) + * - Renders layout: Static → streaming → PromptView → bottom slot */ -import React, { useCallback, useEffect, useState, useRef } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Box, Static, useApp, useStdout } from "ink"; import { MessageList, StaticMessageView } from "./Visualize.tsx"; -import { Prompt } from "./Prompt.tsx"; +import { PromptView } from "./PromptView.tsx"; import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; import { ApprovalPrompt } from "../components/ApprovalPrompt.tsx"; -import { CommandPanel } from "../components/CommandPanel.tsx"; +import { ChoicePanel, ContentPanel } from "../components/CommandPanel.tsx"; +import { SlashMenu } from "../components/SlashMenu.tsx"; +import { MentionMenu } from "../components/MentionMenu.tsx"; import { useGitStatus } from "../hooks/useGitStatus.ts"; import { StreamingSpinner, CompactionSpinner } from "../components/Spinner.tsx"; import { useWire } from "../hooks/useWire.ts"; +import { useShellInput } from "./input-state.ts"; import { createShellSlashCommands, parseSlashCommand, @@ -30,92 +28,53 @@ import { } from "./slash.ts"; import { setActiveTheme } from "../theme.ts"; import type { WireUIEvent } from "./events.ts"; -import type { KeyAction } from "./keyboard.ts"; import type { ApprovalResponseKind } from "../../wire/types.ts"; -import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; +import type { SlashCommand } from "../../types.ts"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const INPUT_MIN_HEIGHT = 6; - -/** - * Run a shell command in foreground (matches Python _run_shell_command). - */ async function runShellCommand( command: string, - pushNotification: (title: string, body: string) => void, + notify: (title: string, body: string) => void, ): Promise { const trimmed = command.trim(); if (!trimmed) return; - - // Block 'cd' — directory changes don't persist - const parts = trimmed.split(/\s+/); - if (parts[0] === "cd") { - pushNotification("Shell", "Warning: Directory changes are not preserved across command executions."); + if (trimmed.split(/\s+/)[0] === "cd") { + notify("Shell", "Warning: Directory changes are not preserved."); return; } - try { - const proc = Bun.spawn(["sh", "-c", trimmed], { - stdio: ["inherit", "inherit", "inherit"], - env: process.env, - }); + const proc = Bun.spawn(["sh", "-c", trimmed], { stdio: ["inherit", "inherit", "inherit"], env: process.env }); await proc.exited; } catch (err: any) { - pushNotification("Shell", `Failed to run command: ${err?.message ?? err}`); + notify("Shell", `Failed: ${err?.message ?? err}`); } } -/** - * Open $VISUAL / $EDITOR / vim to compose multi-line input. - * After the editor exits, submit the content. - */ async function openExternalEditor( - pushNotification: (title: string, body: string) => void, + notify: (title: string, body: string) => void, onSubmit?: (input: string) => void, ): Promise { const editor = process.env.VISUAL || process.env.EDITOR || "vim"; const tmpFile = join(tmpdir(), `kimi-input-${Date.now()}.md`); - try { await Bun.write(tmpFile, ""); - - const proc = Bun.spawn(editor.split(/\s+/).concat(tmpFile), { - stdio: ["inherit", "inherit", "inherit"], - }); - const code = await proc.exited; - - if (code !== 0) { - pushNotification("Editor", `Editor exited with code ${code}`); - return; - } - - const content = await Bun.file(tmpFile).text(); - const trimmed = content.trim(); - if (trimmed && onSubmit) { - onSubmit(trimmed); - } else if (!trimmed) { - pushNotification("Editor", "Empty input, nothing submitted."); - } + const proc = Bun.spawn(editor.split(/\s+/).concat(tmpFile), { stdio: ["inherit", "inherit", "inherit"] }); + if ((await proc.exited) !== 0) { notify("Editor", "Editor exited with error"); return; } + const content = (await Bun.file(tmpFile).text()).trim(); + if (content && onSubmit) onSubmit(content); + else if (!content) notify("Editor", "Empty input, nothing submitted."); } catch (err: any) { - pushNotification("Editor", `Failed to open editor: ${err?.message ?? err}`); + notify("Editor", `Failed: ${err?.message ?? err}`); } finally { - try { - const fs = require("node:fs"); - fs.unlinkSync(tmpFile); - } catch { /* ignore */ } + try { require("node:fs").unlinkSync(tmpFile); } catch { /* ignore */ } } } -/** Deduplicate commands by name, shell commands take priority */ function deduplicateCommands(commands: SlashCommand[]): SlashCommand[] { const seen = new Map(); - for (const cmd of commands) { - if (!seen.has(cmd.name)) { - seen.set(cmd.name, cmd); - } - } + for (const cmd of commands) if (!seen.has(cmd.name)) seen.set(cmd.name, cmd); return [...seen.values()]; } @@ -126,15 +85,12 @@ export interface ShellProps { sessionDir?: string; sessionTitle?: string; thinking?: boolean; + yolo?: boolean; prefillText?: string; onSubmit?: (input: string) => void; onInterrupt?: () => void; onPlanModeToggle?: () => Promise; - onApprovalResponse?: ( - requestId: string, - decision: ApprovalResponseKind, - feedback?: string, - ) => void; + onApprovalResponse?: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => void; onWireReady?: (pushEvent: (event: WireUIEvent) => void) => void; onReload?: (sessionId: string, prefillText?: string) => void; extraSlashCommands?: SlashCommand[]; @@ -147,6 +103,7 @@ export function Shell({ sessionDir, sessionTitle, thinking = false, + yolo = false, prefillText, onSubmit, onInterrupt, @@ -159,34 +116,15 @@ export function Shell({ const { exit } = useApp(); const { stdout } = useStdout(); const [termHeight, setTermHeight] = useState(stdout?.rows || 24); - const [slashMenuVisible, setSlashMenuVisible] = useState(false); - const [activePanel, setActivePanel] = useState(null); - const [clearInputSignal, setClearInputSignal] = useState(0); - const [shellMode, setShellMode] = useState(false); - // Ctrl+C / Esc double-press tracking (moved from keyboard.ts) - const ctrlCCount = useRef(0); - const ctrlCTimer = useRef | null>(null); - const escCount = useRef(0); - const escTimer = useRef | null>(null); - const CTRLC_WINDOW = 2000; - const ESC_WINDOW = 500; - - // Wire state const wire = useWire({ onReady: onWireReady }); - - // Git status const gitStatus = useGitStatus(); - // Helper to push notifications to notification stack const pushNotification = useCallback( - (title: string, body: string) => { - wire.pushEvent({ type: "notification", title, body }); - }, + (title: string, body: string) => wire.pushEvent({ type: "notification", title, body }), [wire], ); - // Shell slash commands const shellCommands = createShellSlashCommands({ clearMessages: wire.clearMessages, exit: () => exit(), @@ -197,262 +135,157 @@ export function Shell({ if (!sessionDir || !workDir) return null; return { sessionDir, workDir, title: sessionTitle ?? "Untitled" }; }, - triggerReload: (newSessionId: string, prefill?: string) => { - onReload?.(newSessionId, prefill); - }, + triggerReload: (newSessionId: string, prefill?: string) => onReload?.(newSessionId, prefill), }); - const allCommands = deduplicateCommands([ - ...shellCommands, - ...extraSlashCommands, - ]); + const allCommands = deduplicateCommands([...shellCommands, ...extraSlashCommands]); - // Handle terminal resize useEffect(() => { const onResize = () => setTermHeight(stdout?.rows || 24); stdout?.on("resize", onResize); - return () => { - stdout?.off("resize", onResize); - }; + return () => { stdout?.off("resize", onResize); }; }, [stdout]); - // Handle actions from Prompt's unified useInput - const handleAction = useCallback( - (action: KeyAction) => { - // Reset the OTHER counter on any action - if (action === "interrupt") { - // Could be Ctrl+C or Esc — we track both the same way now. - // Check Ctrl+C double-press for exit - ctrlCCount.current += 1; - if (ctrlCCount.current >= 2) { - ctrlCCount.current = 0; - if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); - exit(); - return; - } - if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); - ctrlCTimer.current = setTimeout(() => { - ctrlCCount.current = 0; - }, CTRLC_WINDOW); - - // Do the interrupt - if (activePanel) { - setActivePanel(null); - } else if (wire.isStreaming) { - onInterrupt?.(); - wire.pushEvent({ type: "error", message: "Interrupted by user" }); - } - pushNotification("Ctrl-C", "Press Ctrl-C again to exit"); - return; - } - - // Any non-interrupt action resets Ctrl+C counter - ctrlCCount.current = 0; - - switch (action) { - case "clear-input": - setClearInputSignal((n) => n + 1); - break; - case "toggle-plan-mode": - if (onPlanModeToggle) { - onPlanModeToggle() - .then((newState) => { - pushNotification( - "Plan mode", - newState ? "Plan mode ON" : "Plan mode OFF", - ); - }) - .catch((err: unknown) => { - pushNotification("Plan mode", `Error: ${String(err)}`); - }); - } - break; - case "toggle-shell-mode": - setShellMode((prev) => { - const next = !prev; - pushNotification("Mode", next ? "Shell mode" : "Agent mode"); - return next; - }); - break; - case "open-editor": - openExternalEditor(pushNotification, onSubmit); - break; - } - }, - [activePanel, wire, onInterrupt, onPlanModeToggle, onSubmit, pushNotification, exit], - ); - - // Slash commands allowed in shell mode + // ── Input state machine (owns ALL keyboard handling) ── const SHELL_MODE_COMMANDS = new Set(["clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?"]); - // Handle user input - const handleSubmit = useCallback( - (input: string) => { - const parsed = parseSlashCommand(input); - if (parsed) { - // In shell mode, only allow a subset of slash commands - if (shellMode) { - if (!SHELL_MODE_COMMANDS.has(parsed.name)) { - wire.pushEvent({ - type: "notification", - title: "Shell mode", - body: `/${parsed.name} is not available in shell mode. Press Ctrl-X to switch to agent mode.`, - }); + const inputState = useShellInput({ + commands: allCommands, + workDir, + onSubmit: useCallback( + (input: string) => { + const parsed = parseSlashCommand(input); + if (parsed) { + if (inputState.shellMode && !SHELL_MODE_COMMANDS.has(parsed.name)) { + wire.pushEvent({ type: "notification", title: "Shell mode", body: `/${parsed.name} is not available in shell mode.` }); return; } - } - const cmd = findSlashCommand(allCommands, parsed.name); - if (cmd) { - if (cmd.panel && !parsed.args) { - const panelConfig = cmd.panel(); - if (panelConfig) { - setActivePanel(panelConfig); - return; + const cmd = findSlashCommand(allCommands, parsed.name); + if (cmd) { + if (cmd.panel && !parsed.args) { + const pc = cmd.panel(); + if (pc) { inputState.openPanel(pc); return; } } + const result = cmd.handler(parsed.args); + if (result && typeof result.then === "function") { + result.then((feedback: void | string) => { + if (typeof feedback === "string") { + wire.pushEvent({ type: "slash_result", userInput: input, text: feedback }); + } + }); + } + return; } - cmd.handler(parsed.args); + wire.pushEvent({ type: "notification", title: "Unknown command", body: `/${parsed.name} is not recognized. Type /help.` }); return; } - wire.pushEvent({ - type: "notification", - title: "Unknown command", - body: `/${parsed.name} is not a recognized command. Type /help for available commands.`, + if (inputState.shellMode) { runShellCommand(input, pushNotification); return; } + onSubmit?.(input); + }, + [allCommands, onSubmit, wire, pushNotification], + ), + onSlashExecute: useCallback((cmd: SlashCommand) => { + const result = cmd.handler(""); + if (result && typeof result.then === "function") { + result.then((feedback: void | string) => { + if (typeof feedback === "string") { + wire.pushEvent({ type: "slash_result", userInput: `/${cmd.name}`, text: feedback }); + } }); - return; } - - // Shell mode: run as shell command - if (shellMode) { - runShellCommand(input, pushNotification); - return; + }, [wire]), + onExit: useCallback(() => exit(), [exit]), + onInterrupt: useCallback(() => { + if (wire.isStreaming) { + onInterrupt?.(); + wire.pushEvent({ type: "error", message: "Interrupted by user" }); } + }, [wire, onInterrupt]), + onPlanModeToggle: useCallback(() => { + onPlanModeToggle?.() + .then((s) => pushNotification("Plan mode", s ? "ON" : "OFF")) + .catch((e: unknown) => pushNotification("Plan mode", `Error: ${String(e)}`)); + }, [onPlanModeToggle, pushNotification]), + onOpenEditor: useCallback(() => openExternalEditor(pushNotification, onSubmit), [pushNotification, onSubmit]), + onNotify: pushNotification, + }); - onSubmit?.(input); - }, - [allCommands, onSubmit, wire, shellMode, pushNotification], - ); - - // Handle opening a command panel from slash menu - const handleOpenPanel = useCallback( - (cmd: SlashCommand) => { - if (cmd.panel) { - const panelConfig = cmd.panel(); - if (panelConfig) { - setActivePanel(panelConfig); - return; - } - } - // Fallback: execute handler directly - cmd.handler(""); - }, - [], - ); - - // Close command panel - const handleClosePanel = useCallback(() => { - setActivePanel(null); - }, []); - - // Handle approval response const handleApprovalResponse = useCallback( (decision: ApprovalResponseKind, feedback?: string) => { if (wire.pendingApproval) { onApprovalResponse?.(wire.pendingApproval.id, decision, feedback); - wire.pushEvent({ - type: "approval_response", - requestId: wire.pendingApproval.id, - response: decision, - }); + wire.pushEvent({ type: "approval_response", requestId: wire.pendingApproval.id, response: decision }); } }, [wire.pendingApproval, onApprovalResponse, wire], ); + // ── Prompt symbol ── + const mode = inputState.mode; + const promptSymbol = + mode.type === "panel_input" ? "▸ " + : inputState.shellMode ? "$ " + : wire.isStreaming ? "💫 " + : (wire.status?.plan_mode ?? false) ? "📋 " + : "✨ "; + + // ── Static items ── + const staticItems = React.useMemo(() => { + const welcome = { id: "__welcome__", _isWelcome: true as const }; + const msgs = wire.isStreaming ? wire.messages.slice(0, -1) : wire.messages; + return [welcome, ...msgs]; + }, [wire.isStreaming, wire.messages]); return ( - - {/* ═══ Top: Welcome box ═══ */} - - - {/* ═══ Static messages: rendered once, never re-drawn ═══ */} - {/* This allows users to select & copy completed messages with the mouse */} - - {(msg) => } + + + {(item: any) => + item._isWelcome ? ( + + ) : ( + + ) + } - {/* ═══ Active (streaming) message + spinners ═══ */} {wire.isStreaming && wire.messages.length > 0 && ( - - )} - - {wire.isStreaming && !wire.isCompacting && ( - + )} - + {wire.isStreaming && !wire.isCompacting && } - - {wire.pendingApproval && ( - - )} + {wire.pendingApproval && } - {/* ═══ InputBox: fills remaining, min 6 lines, text at top ═══ */} - - {activePanel ? ( - - ) : ( - - )} - - - {/* ═══ Bottom: Status bar (always visible) ═══ */} - + + {mode.type === "panel_choice" ? ( + + ) : mode.type === "panel_content" ? ( + + ) : inputState.showSlashMenu ? ( + + ) : inputState.showMentionMenu ? ( + + ) : ( + + )} ); } diff --git a/src/kimi_cli_ts/ui/shell/Visualize.tsx b/src/kimi_cli_ts/ui/shell/Visualize.tsx index 5c2c3e22a..d16f23df6 100644 --- a/src/kimi_cli_ts/ui/shell/Visualize.tsx +++ b/src/kimi_cli_ts/ui/shell/Visualize.tsx @@ -79,6 +79,23 @@ function MessageView({ message, isLast, isStreaming, stepCount }: MessageViewPro const roleLabel = getRoleLabel(message.role); const roleColor = getRoleColor(message.role, colors); + // For user messages, render inline with emoji prefix + if (message.role === "user") { + const userText = message.segments + .filter((s): s is TextSegment => s.type === "text") + .map((s) => s.text) + .join(""); + return ( + + + {roleLabel} + {userText} + + + ); + } + + // For assistant messages, no role label return ( {/* Step count header for assistant messages */} @@ -87,9 +104,6 @@ function MessageView({ message, isLast, isStreaming, stepCount }: MessageViewPro ─── Step {stepCount} ─── )} - - {roleLabel} - {message.segments.map((segment, idx) => ( { ... handle keys ... }); + * + * When the component unmounts, the layer is automatically removed. + * The previous layer resumes receiving events. + * + * The central useInput in input-state.ts calls dispatchToStack() which + * routes events to the top handler, or falls through to the default + * handler if the stack is empty. + */ + +import { useEffect, useRef, useCallback } from "react"; + +// ── Types ─────────────────────────────────────────────── + +export type InputKey = { + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + return?: boolean; + escape?: boolean; + ctrl?: boolean; + shift?: boolean; + tab?: boolean; + backspace?: boolean; + delete?: boolean; + meta?: boolean; +}; + +export type InputHandler = (input: string, key: InputKey) => void; + +// ── Stack ─────────────────────────────────────────────── + +type StackEntry = { + id: number; + handler: InputHandler; +}; + +let nextId = 0; +const stack: StackEntry[] = []; + +/** + * Push a handler onto the input stack. Returns an id for removal. + */ +function pushLayer(handler: InputHandler): number { + const id = nextId++; + stack.push({ id, handler }); + return id; +} + +/** + * Remove a handler from the stack by id. + */ +function popLayer(id: number): void { + const idx = stack.findIndex((e) => e.id === id); + if (idx !== -1) stack.splice(idx, 1); +} + +/** + * Get the current top handler, or null if stack is empty. + */ +export function getTopHandler(): InputHandler | null { + return stack.length > 0 ? stack[stack.length - 1]!.handler : null; +} + +/** + * Check if any layers are on the stack. + */ +export function hasLayers(): boolean { + return stack.length > 0; +} + +// ── Hook ──────────────────────────────────────────────── + +/** + * Push an input handler layer for the lifetime of the calling component. + * While this layer is active, it receives all non-global key events. + * When the component unmounts, the layer is automatically removed. + * + * The handler is kept in a ref so it can be updated without + * removing/re-adding the layer. + */ +export function useInputLayer(handler: InputHandler): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + const idRef = useRef(null); + + useEffect(() => { + // Stable wrapper that delegates to the latest handler ref + const stableHandler: InputHandler = (input, key) => { + handlerRef.current(input, key); + }; + idRef.current = pushLayer(stableHandler); + + return () => { + if (idRef.current !== null) { + popLayer(idRef.current); + idRef.current = null; + } + }; + }, []); // Mount/unmount only +} diff --git a/src/kimi_cli_ts/ui/shell/input-state.ts b/src/kimi_cli_ts/ui/shell/input-state.ts new file mode 100644 index 000000000..21d1ce2af --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/input-state.ts @@ -0,0 +1,472 @@ +/** + * input-state.ts — Shell-level UI state machine + input dispatcher + hotkeys. + * + * Single `useInput` hook that: + * - Routes keyboard events based on UIMode (normal, slash, mention, panel) + * - Handles all hotkeys (Ctrl+C double-press, shell mode, plan mode, editor) + * - Manages input value, cursor, history, mention suggestions + * - Manages UI mode transitions (menus, panels) + * + * Shell passes external callbacks; all keyboard logic lives here. + * Rendering components receive pure props from the returned state. + * + * Input Stack: Components can push input layers via useInputLayer() + * (from input-stack.ts). When a layer is active, normal keys are + * routed to it instead of the default handler. Global keys (Ctrl+C, + * Esc when no panel is open) always fire regardless of stack depth. + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import { useInput } from "ink"; +import { useInputHistory } from "../hooks/useInput.ts"; +import { useFileMention } from "../hooks/useFileMention.ts"; +import { getTopHandler, hasLayers } from "./input-stack.ts"; +import { + getFilteredCommandCount, + getFilteredCommand, +} from "../components/SlashMenu.tsx"; +import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; + +// ── UI Mode ───────────────────────────────────────────── + +type ChoiceConfig = Extract; +type InputConfig = Extract; +type ContentConfig = Extract; + +export type UIMode = + | { type: "normal" } + | { type: "slash_menu" } + | { type: "mention_menu" } + | { type: "panel_choice"; config: ChoiceConfig; index: number } + | { type: "panel_input"; config: InputConfig } + | { type: "panel_content"; config: ContentConfig; scrollOffset: number }; + +// ── Hook Return ───────────────────────────────────────── + +export interface ShellInputState { + value: string; + cursorOffset: number; + bufferedLines: string[]; + mode: UIMode; + shellMode: boolean; + slashFilter: string; + slashMenuIndex: number; + slashMenuCount: number; + mentionSuggestions: string[]; + mentionMenuIndex: number; + showSlashMenu: boolean; + showMentionMenu: boolean; + openPanel: (config: CommandPanelConfig) => void; +} + +// ── Hook Options ──────────────────────────────────────── + +interface UseShellInputOptions { + commands: SlashCommand[]; + workDir?: string; + disabled?: boolean; + /** Called when user submits text input (normal or slash command text) */ + onSubmit: (input: string) => void; + /** Called when a slash command is selected from menu and has no panel */ + onSlashExecute: (cmd: SlashCommand) => void; + /** Called on Ctrl+C double-press — should exit the app */ + onExit: () => void; + /** Called on single interrupt (Ctrl+C or Esc in normal mode) — should abort streaming */ + onInterrupt: () => void; + /** Called to toggle plan mode */ + onPlanModeToggle: () => void; + /** Called to open external editor */ + onOpenEditor: () => void; + /** Called to push a notification to the UI */ + onNotify: (title: string, body: string) => void; +} + +// ── Hotkey Constants ──────────────────────────────────── + +const CTRLC_WINDOW_MS = 2000; + +// ── Hook ──────────────────────────────────────────────── + +export function useShellInput({ + commands, + workDir, + disabled = false, + onSubmit, + onSlashExecute, + onExit, + onInterrupt, + onPlanModeToggle, + onOpenEditor, + onNotify, +}: UseShellInputOptions): ShellInputState { + // ── Input value + history ── + const { value, setValue, historyPrev, historyNext, addToHistory, isBrowsingHistory, exitHistory } = + useInputHistory(); + const [cursorOffset, setCursorOffset] = useState(0); + const [bufferedLines, setBufferedLines] = useState([]); + + // ── Shell mode (toggled by Ctrl+X) ── + const [shellMode, setShellMode] = useState(false); + + // ── UI mode ── + const [mode, setMode] = useState({ type: "normal" }); + + // ── Slash menu state ── + const [slashMenuIndex, setSlashMenuIndex] = useState(0); + + // ── Mention menu state ── + const [mentionMenuIndex, setMentionMenuIndex] = useState(0); + const mention = useFileMention( + mode.type === "normal" || mode.type === "slash_menu" || mode.type === "mention_menu" + ? value + : "", + workDir, + ); + + // ── Hotkey: Ctrl+C double-press tracking ── + const ctrlCCount = useRef(0); + const ctrlCTimer = useRef | null>(null); + + // ── Stable callback refs (avoid stale closures in useInput) ── + const onExitRef = useRef(onExit); + onExitRef.current = onExit; + const onInterruptRef = useRef(onInterrupt); + onInterruptRef.current = onInterrupt; + const onPlanModeToggleRef = useRef(onPlanModeToggle); + onPlanModeToggleRef.current = onPlanModeToggle; + const onOpenEditorRef = useRef(onOpenEditor); + onOpenEditorRef.current = onOpenEditor; + const onNotifyRef = useRef(onNotify); + onNotifyRef.current = onNotify; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const onSlashExecuteRef = useRef(onSlashExecute); + onSlashExecuteRef.current = onSlashExecute; + + // ── Derived: slash menu (suppressed when browsing history) ── + const isSlashMode = + !isBrowsingHistory && + (mode.type === "normal" || mode.type === "slash_menu") && + value.startsWith("/") && !value.includes(" ") && commands.length > 0; + const slashFilter = isSlashMode ? value.slice(1) : ""; + const slashMenuCount = isSlashMode ? getFilteredCommandCount(commands, slashFilter) : 0; + const showSlashMenu = isSlashMode && slashMenuCount > 0; + + // ── Derived: mention menu ── + const showMentionMenu = + !showSlashMenu && + (mode.type === "normal" || mode.type === "mention_menu") && + mention.isActive && mention.suggestions.length > 0 && !shellMode; + + // ── Auto mode transitions ── + useEffect(() => { + if (showSlashMenu && mode.type === "normal") { + setMode({ type: "slash_menu" }); + setSlashMenuIndex(0); + } else if (!showSlashMenu && mode.type === "slash_menu") { + setMode({ type: "normal" }); + } + }, [showSlashMenu, mode.type]); + + useEffect(() => { + if (showMentionMenu && mode.type === "normal") { + setMode({ type: "mention_menu" }); + setMentionMenuIndex(0); + } else if (!showMentionMenu && mode.type === "mention_menu") { + setMode({ type: "normal" }); + } + }, [showMentionMenu, mode.type]); + + useEffect(() => { setSlashMenuIndex(0); }, [slashFilter]); + useEffect(() => { setMentionMenuIndex(0); }, [mention.fragment]); + // When value changes: if browsing history, move cursor to end; otherwise clamp + useEffect(() => { + if (isBrowsingHistory) { + setCursorOffset(value.length); + } else { + setCursorOffset((prev) => Math.min(prev, value.length)); + } + }, [value, isBrowsingHistory]); + + // ── Apply mention selection ── + const applyMention = useCallback( + (path: string) => { + const atIdx = value.lastIndexOf("@"); + if (atIdx === -1) return; + const newValue = value.slice(0, atIdx) + "@" + path + " "; + setValue(newValue); + setCursorOffset(newValue.length); + setMode({ type: "normal" }); + }, + [value, setValue], + ); + + // ── Panel transition ── + const openPanelConfig = useCallback((config: CommandPanelConfig) => { + setValue(""); + setCursorOffset(0); + setBufferedLines([]); + if (config.type === "choice") { + const idx = config.items.findIndex((i) => i.current); + setMode({ type: "panel_choice", config, index: idx >= 0 ? idx : 0 }); + } else if (config.type === "input") { + setMode({ type: "panel_input", config }); + } else if (config.type === "content") { + setMode({ type: "panel_content", config, scrollOffset: 0 }); + } + }, [setValue]); + + const handlePanelResult = useCallback( + (result: CommandPanelConfig | Promise | void) => { + if (!result) { setMode({ type: "normal" }); return; } + if (result instanceof Promise) { + result.then((next) => next ? openPanelConfig(next) : setMode({ type: "normal" })); + } else { + openPanelConfig(result); + } + }, + [openPanelConfig], + ); + + // ── Paste clipboard ── + const pasteClipboard = useCallback(async () => { + const cmds = process.platform === "darwin" + ? [["pbpaste"]] + : [["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"], ["wl-paste"]]; + for (const cmd of cmds) { + try { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" }); + const text = await new Response(proc.stdout).text(); + if ((await proc.exited) === 0 && text) { + const next = value.slice(0, cursorOffset) + text + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev + text.length); + return; + } + } catch { /* try next */ } + } + }, [value, cursorOffset, setValue]); + + // ── Text editing (exit history browsing on any edit) ── + const insertChar = useCallback( + (input: string) => { + exitHistory(); + const next = value.slice(0, cursorOffset) + input + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev + input.length); + }, + [value, cursorOffset, setValue, exitHistory], + ); + + const backspace = useCallback(() => { + if (cursorOffset > 0) { + exitHistory(); + setValue(value.slice(0, cursorOffset - 1) + value.slice(cursorOffset)); + setCursorOffset((prev) => prev - 1); + } + }, [value, cursorOffset, setValue, exitHistory]); + + // ── Hotkey: handle interrupt (Ctrl+C / Esc in normal mode) ── + const handleInterrupt = useCallback(() => { + ctrlCCount.current += 1; + if (ctrlCCount.current >= 2) { + ctrlCCount.current = 0; + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + onExitRef.current(); + return; + } + if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); + ctrlCTimer.current = setTimeout(() => { ctrlCCount.current = 0; }, CTRLC_WINDOW_MS); + onInterruptRef.current(); + onNotifyRef.current("Ctrl-C", "Press Ctrl-C again to exit"); + }, []); + + // ── Submit handler ── + const doSubmit = useCallback(() => { + // Panel input mode + if (mode.type === "panel_input") { + const trimmed = value.trim(); + if (!trimmed) return; + const result = mode.config.onSubmit(trimmed); + setValue(""); + setCursorOffset(0); + handlePanelResult(result); + return; + } + + // Mention menu: select + if (mode.type === "mention_menu") { + const sel = mention.suggestions[mentionMenuIndex]; + if (sel) { applyMention(sel); return; } + } + + // Slash menu: select and execute + if (mode.type === "slash_menu") { + const sel = getFilteredCommand(commands, slashFilter, slashMenuIndex); + if (sel) { + addToHistory(`/${sel.name}`); + setValue(""); + setCursorOffset(0); + setMode({ type: "normal" }); + if (sel.panel) { + const pc = sel.panel(); + if (pc) { openPanelConfig(pc); return; } + } + onSlashExecuteRef.current(sel); + return; + } + } + + // Normal submit + const trimmed = value.trim(); + if (!trimmed && bufferedLines.length === 0) return; + const fullInput = bufferedLines.length > 0 + ? [...bufferedLines, value].join("\n") : value; + const final = fullInput.trim(); + if (!final) return; + addToHistory(final); + setValue(""); + setCursorOffset(0); + setBufferedLines([]); + onSubmitRef.current(final); + }, [ + mode, value, commands, slashFilter, slashMenuIndex, + mention.suggestions, mentionMenuIndex, applyMention, + addToHistory, setValue, bufferedLines, handlePanelResult, openPanelConfig, + ]); + + // ── Single useInput dispatcher ── + useInput( + (input, key) => { + // ── Global keys: always fire regardless of input stack ── + // Ctrl+C: interrupt / double-press exit + if (key.ctrl && input === "c") { handleInterrupt(); return; } + // Esc: close panel or interrupt (global escape hatch) + if (key.escape) { + const m = mode.type; + if (m === "panel_choice" || m === "panel_input" || m === "panel_content") { + setValue(""); + setCursorOffset(0); + setMode({ type: "normal" }); + return; + } + // If a stack layer is active, let it handle Esc too + const topHandler = getTopHandler(); + if (topHandler) { topHandler(input, key); return; } + handleInterrupt(); + return; + } + + // ── Input stack: if a layer is active, route all other keys to it ── + const topHandler = getTopHandler(); + if (topHandler) { + topHandler(input, key); + return; + } + + // ── Default handler (no stack layers) ── + const m = mode.type; + + // ── Ctrl shortcuts ── + if (key.ctrl) { + // Ctrl+C already handled above as global key + if (input === "v") { pasteClipboard(); return; } + if (m === "normal" || m === "slash_menu" || m === "mention_menu") { + if (input === "x") { + setShellMode((prev) => { + const next = !prev; + onNotifyRef.current("Mode", next ? "Shell mode" : "Agent mode"); + return next; + }); + return; + } + if (input === "o") { onOpenEditorRef.current(); return; } + if (input === "j") { + setBufferedLines((prev) => [...prev, value]); + setValue(""); + setCursorOffset(0); + return; + } + } + return; + } + + // ── Escape already handled above as global key ── + + // ── Panel choice ── + if (m === "panel_choice" && mode.type === "panel_choice") { + if (key.upArrow) { setMode({ ...mode, index: Math.max(0, mode.index - 1) }); return; } + if (key.downArrow) { setMode({ ...mode, index: Math.min(mode.config.items.length - 1, mode.index + 1) }); return; } + if (key.return) { + const item = mode.config.items[mode.index]; + if (item) handlePanelResult(mode.config.onSelect(item.value)); + return; + } + return; + } + + // ── Panel content ── + if (m === "panel_content" && mode.type === "panel_content") { + const maxScroll = Math.max(0, mode.config.content.split("\n").length - 15); + if (key.upArrow) { setMode({ ...mode, scrollOffset: Math.max(0, mode.scrollOffset - 1) }); return; } + if (key.downArrow) { setMode({ ...mode, scrollOffset: Math.min(maxScroll, mode.scrollOffset + 1) }); return; } + return; + } + + // ── Normal / slash / mention / panel_input ── + + if (key.shift && key.tab && m !== "panel_input") { + onPlanModeToggleRef.current(); + return; + } + + if (key.tab && m !== "panel_input") { + if (m === "mention_menu") { + const sel = mention.suggestions[mentionMenuIndex]; + if (sel) applyMention(sel); + } else if (m === "slash_menu") { + const sel = getFilteredCommand(commands, slashFilter, slashMenuIndex); + if (sel) { setValue(`/${sel.name} `); setCursorOffset(`/${sel.name} `.length); } + } + return; + } + + if (key.return) { doSubmit(); return; } + + if (key.upArrow) { + if (m === "mention_menu") setMentionMenuIndex((i) => Math.max(0, i - 1)); + else if (m === "slash_menu") setSlashMenuIndex((i) => Math.max(0, i - 1)); + else if (m === "normal") historyPrev(); + return; + } + if (key.downArrow) { + if (m === "mention_menu") setMentionMenuIndex((i) => Math.min(mention.suggestions.length - 1, i + 1)); + else if (m === "slash_menu") setSlashMenuIndex((i) => Math.min(slashMenuCount - 1, i + 1)); + else if (m === "normal") historyNext(); + return; + } + + if (key.leftArrow) { setCursorOffset((prev) => Math.max(0, prev - 1)); return; } + if (key.rightArrow) { setCursorOffset((prev) => Math.min(value.length, prev + 1)); return; } + if (key.backspace || key.delete) { backspace(); return; } + if (input) { insertChar(input); } + }, + { isActive: !disabled }, + ); + + return { + value, + cursorOffset, + bufferedLines, + mode, + shellMode, + slashFilter, + slashMenuIndex, + slashMenuCount, + mentionSuggestions: mention.suggestions, + mentionMenuIndex, + showSlashMenu, + showMentionMenu, + openPanel: openPanelConfig, + }; +} diff --git a/src/kimi_cli_ts/utils/logging.ts b/src/kimi_cli_ts/utils/logging.ts index 7852ff0ef..87ca2cb4b 100644 --- a/src/kimi_cli_ts/utils/logging.ts +++ b/src/kimi_cli_ts/utils/logging.ts @@ -1,8 +1,12 @@ /** * Logging module — corresponds to Python utils/logging.py - * Simple structured logger using console with level filtering. + * Disk-only logger using log4js. Writes to session logs directory only, + * never to stdout/stderr. */ +import log4js from "log4js"; +import { join } from "node:path"; + export type LogLevel = "debug" | "info" | "warn" | "error"; const LOG_LEVELS: Record = { @@ -12,31 +16,86 @@ const LOG_LEVELS: Record = { error: 3, }; +// log4js logger instance — initialized lazily when setLogDir is called +let log4jsLogger: log4js.Logger | null = null; + class Logger { private level: LogLevel = "info"; + private logDir: string | null = null; + private buffer: Array<{ level: LogLevel; message: string }> = []; setLevel(level: LogLevel): void { this.level = level; + if (log4jsLogger) { + log4jsLogger.level = level; + } + } + + /** + * Set the directory where logs will be written. + * Configures log4js with a file appender and flushes buffered logs. + */ + setLogDir(dir: string): void { + this.logDir = dir; + const logFile = join(dir, "logs.log"); + + log4js.configure({ + appenders: { + file: { + type: "file", + filename: logFile, + maxLogSize: 5 * 1024 * 1024, // 5MB + backups: 1, + layout: { + type: "pattern", + pattern: "[%d{yyyy-MM-dd hh:mm:ss.SSS}] [%p] %m", + }, + }, + }, + categories: { + default: { appenders: ["file"], level: this.level }, + }, + disableClustering: true, + }); + + log4jsLogger = log4js.getLogger("kimi"); + log4jsLogger.level = this.level; + + // Flush buffered log entries + for (const entry of this.buffer) { + log4jsLogger[entry.level](entry.message); + } + this.buffer = []; } - private shouldLog(level: LogLevel): boolean { - return LOG_LEVELS[level] >= LOG_LEVELS[this.level]; + private _log(level: LogLevel, message: string, args: unknown[]): void { + if (LOG_LEVELS[level] < LOG_LEVELS[this.level]) return; + + const fullMessage = `${message}${args.length ? " " + args.map(String).join(" ") : ""}`; + + if (!log4jsLogger) { + // Buffer until log dir is set + this.buffer.push({ level, message: fullMessage }); + return; + } + + log4jsLogger[level](fullMessage); } debug(message: string, ...args: unknown[]): void { - if (this.shouldLog("debug")) process.stderr.write(`[DEBUG] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + this._log("debug", message, args); } info(message: string, ...args: unknown[]): void { - if (this.shouldLog("info")) process.stderr.write(`[INFO] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + this._log("info", message, args); } warn(message: string, ...args: unknown[]): void { - if (this.shouldLog("warn")) process.stderr.write(`[WARN] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + this._log("warn", message, args); } error(message: string, ...args: unknown[]): void { - if (this.shouldLog("error")) process.stderr.write(`[ERROR] ${message}${args.length ? " " + args.map(String).join(" ") : ""}\n`); + this._log("error", message, args); } } From 25df79a0d69b1e40f73555bb3237609ab1eada41 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sat, 4 Apr 2026 23:22:53 +0800 Subject: [PATCH 16/28] docs: update CLAUDE.md with full project architecture; add log4js dep - Rewrite CLAUDE.md with comprehensive project overview, module map, architecture docs, renderer design notes, and dev conventions - Add log4js dependency for disk-based logging --- CLAUDE.md | 329 ++++++++++++++++++++++++++++++++++++++------------- package.json | 1 + 2 files changed, 249 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8100b77e..efc886276 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,3 @@ ---- -description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" -alwaysApply: false ---- - Default to using Bun instead of Node.js. - Use `bun ` instead of `node ` or `ts-node ` @@ -13,99 +7,272 @@ Default to using Bun instead of Node.js. - Use `bun run - - -``` +| Module | Python | TypeScript | Description | +|--------|--------|------------|-------------| +| Entry point | `__main__.py` | `index.ts` | CLI entrypoint, delegates to `cli/` | +| App orchestrator | `app.py` | `app.ts` | `KimiCLI` factory: wires config → LLM → runtime → agent → soul | +| Config | `config.py` | `config.ts` | TOML/JSON config loading, Pydantic/Zod validation. Config at `~/.kimi/config.toml` | +| LLM | `llm.py` | `llm.ts` | Multi-provider LLM abstraction (Kimi, OpenAI, Anthropic, Gemini, VertexAI) | +| Session | `session.py` | `session.ts` | Session persistence (create/continue/restore), session state, wire file | +| Constants | `constant.py` | `constant.ts` | `NAME`, `VERSION`, `USER_AGENT` | + +### Soul Layer (`soul/`) + +The "soul" is the agent reasoning core: + +- **`kimisoul.py/ts`** — `KimiSoul`: main orchestrator for LLM interaction loop, plan mode, slash commands, compaction, dynamic injections +- **`agent.py/ts`** — `Agent`, `Runtime`: agent spec loading, runtime context (config, LLM, session, toolset, hooks, skills, subagents, MCP) +- **`context.py/ts`** — `Context`: conversation history persistence and restore +- **`toolset.py/ts`** — `KimiToolset`: tool registration, MCP integration, tool call dispatch with hooks +- **`compaction.py/ts`** — Auto-compaction when context exceeds token limits +- **`approval.py/ts`** — Tool execution approval flow +- **`slash.py/ts`** — Slash command registry +- **`dynamic_injection.py/ts`** — Dynamic system prompt injection (plan mode, yolo mode, etc.) +- **`denwarenji.py/ts`** — Phone chain / subagent communication +- **`message.py/ts`** — Message construction helpers + +### Tools (`tools/`) + +Built-in tool implementations organized by category: + +- `shell/` — Shell command execution +- `file/` — File read/write/edit/glob/grep +- `web/` — Web search and fetch +- `agent/` — Subagent spawning +- `ask_user/` — User interaction tool +- `plan/` — Plan mode tools +- `think/` — Thinking/reasoning tool +- `todo/` — Task management +- `background/` — Background task management +- `dmail/` — Direct message between agents +- `display.py/ts` — Display/output formatting + +Tools are registered via `tools/` module and listed in agent spec YAML files. + +### Agent Specs (`agents/`) + +YAML-based agent definitions at `src/kimi_cli/agents/`: + +- `default/agent.yaml` — Default agent configuration (tools list, system prompt template, subagent definitions) +- `okabe/agent.yaml` — Alternative agent config +- Agent specs support inheritance via `extend` field +- System prompts are Jinja2 templates with builtin variables (`KIMI_NOW`, `KIMI_WORK_DIR`, `KIMI_OS`, etc.) + +### Subagents (`subagents/`) + +Multi-agent orchestration system: + +- `models.py/ts` — `AgentTypeDefinition`, `ToolPolicy` +- `registry.py/ts` — `LaborMarket`: agent type registration and discovery +- `builder.py/ts` — Subagent instance builder +- `runner.py/ts` — Subagent execution runner +- `store.py/ts` — Subagent instance state persistence +- `core.py/ts` — Core subagent logic +- `git_context.py/ts` — Git context for subagents + +### Skills (`skill/`, `skills/`) + +- `skill/` — Skill loading, indexing, discovery from `~/.kimi/skills/`, `.kimi/skills/`, `.claude/skills/` +- `skill/flow/` — Skill flow graph (choice-based skill routing) +- `skills/` — Built-in skills (e.g., `kimi-cli-help`, `skill-creator`) + +### Wire Protocol (`wire/`) + +Communication layer between soul and UI: + +- `types.py/ts` — Wire message types (StepBegin, ToolCall, ToolResult, StatusUpdate, ApprovalRequest, etc.) +- `protocol.py/ts` — Wire protocol definition +- `serde.py/ts` — Serialization/deserialization +- `file.py/ts` — Wire message file persistence +- `server.py/ts` — Wire server (stdio) +- `root_hub.py/ts` — Message hub for multi-agent wire routing +- `jsonrpc.py/ts` — JSON-RPC transport + +### UI Layer (`ui/`) + +- **Python**: `shell/` (prompt_toolkit TUI), `print/` (non-interactive), `acp/` (Agent Client Protocol server) +- **TypeScript**: `shell/` (React Ink TUI), `print/` (non-interactive), `components/` (Ink components), `hooks/` (React hooks) + +### Other Modules + +- `auth/` — OAuth authentication (`oauth.py/ts`), platform detection (`platforms.py/ts`) +- `hooks/` — Event hook system (PreToolUse, PostToolUse, Stop, SessionStart, etc.) +- `plugin/` — Plugin management (MCP-based plugins) +- `background/` — Background task manager +- `notifications/` — Notification system +- `approval_runtime/` — Approval state management +- `cli/` — CLI argument parsing (Python: Typer, TS: Commander) +- `share.py` / `config.ts:getShareDir()` — Share directory (`~/.kimi/`) +- `metadata.py/ts` — Work directory metadata +- `exception.py/ts` — Custom exception types + +## Key Patterns + +### Python Version -With the following `frontend.tsx`: +- **Async throughout**: Uses `asyncio`, `AsyncGenerator` patterns +- **Pydantic models** for config validation and data classes +- **`@dataclass(slots=True)`** for domain objects +- **`kosong`** framework: `ChatProvider`, `Toolset`, `Tool`, `Message` abstractions +- **`kaos`** for async path operations (`KaosPath`) +- **`loguru`** for logging via `kimi_cli.utils.logging.logger` +- **Jinja2** for system prompt templates +- **YAML** for agent specs +- **prompt-toolkit** for shell TUI +- **Package manager**: `uv` (see `pyproject.toml`) +- **Linting**: `ruff` +- **Type checking**: `pyright` / `ty` +- **Testing**: `pytest` + `pytest-asyncio` -```tsx#frontend.tsx -import React from "react"; +### TypeScript Version -// import .css files directly and it works -import './index.css'; +- **Bun runtime**: Uses Bun APIs (`Bun.file`, `Bun.write`, `Bun.$`, `Bun.serve`) +- **Zod v4** for config validation (corresponds to Python Pydantic) +- **React Ink** for shell TUI (corresponds to Python prompt-toolkit) +- **Commander** for CLI parsing (corresponds to Python Typer) +- **`@iarna/toml`** for TOML parsing +- **Linting/formatting**: Biome +- **Type checking**: `tsc --noEmit` +- **Testing**: `bun test` -import { createRoot } from "react-dom/client"; +## Environment Variables -const root = createRoot(document.body); +| Variable | Description | +|----------|-------------| +| `KIMI_BASE_URL` | Override API base URL | +| `KIMI_API_KEY` | Override API key | +| `KIMI_MODEL_NAME` | Override model name | +| `KIMI_MODEL_MAX_CONTEXT_SIZE` | Override max context size | +| `KIMI_MODEL_CAPABILITIES` | Override model capabilities (comma-separated) | +| `KIMI_MODEL_TEMPERATURE` | Override temperature | +| `KIMI_MODEL_TOP_P` | Override top_p | +| `KIMI_MODEL_MAX_TOKENS` | Override max tokens | +| `KIMI_SHARE_DIR` | Override share directory (default: `~/.kimi/`) | +| `OPENAI_BASE_URL` | Override OpenAI base URL (for openai providers) | +| `OPENAI_API_KEY` | Override OpenAI API key (for openai providers) | -export default function Frontend() { - return

Hello, world!

; -} +## Config File -root.render(); +Located at `~/.kimi/config.toml`. Supports TOML (preferred) and JSON (legacy, auto-migrated). + +Key fields: `default_model`, `default_thinking`, `default_yolo`, `default_plan_mode`, `theme`, `models`, `providers`, `loop_control`, `hooks`, `services`, `background`, `mcp`. + +## Running + +```bash +# Python version +uv run kimi + +# TypeScript version +bun run start # or: bun run src/kimi_cli_ts/index.ts +bun run dev # watch mode +bun run build # compile to binary + +# Tests +pytest # Python tests +bun test # TypeScript tests + +# Lint & format +ruff check src/ # Python lint +biome check src/ # TypeScript lint +biome format --write src/ # TypeScript format ``` -Then, run index.ts +## Testing + +Python tests are in `tests/` using pytest. TypeScript tests use `bun test` with `.test.ts` files. + +```bash +# Run all Python tests +uv run pytest + +# Run specific test file +uv run pytest tests/test_xxx.py + +# Run all TS tests +bun test -```sh -bun --hot ./index.ts +# Run specific TS test +bun test tests/xxx.test.ts ``` -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. +## Conventions + +- Python and TypeScript modules have 1:1 correspondence (same file names, same class/function names in camelCase/snake_case) +- Wire message types are shared between both versions +- Agent specs (YAML) are shared between both versions +- Config format (TOML) is shared between both versions +- When modifying one version, consider whether the same change is needed in the other + +## Renderer: Terminal Text Selection Fix + +### Problem + +React Ink's rendering destroys terminal mouse text selection. When output height >= `stdout.rows`, Ink switches to a "clearTerminal" code path that emits `\x1b[2J` (erase screen) + `\x1b[3J` (erase scrollback) + `\x1b[H` (cursor home) + full content every frame at ~12fps. The `\x1b[2J` wipes all terminal selection state. + +### Root Cause Chain + +1. `Shell.tsx` had `` making output always fill the terminal +2. Ink checks `if (this.lastOutputHeight >= this.options.stdout.rows)` in `ink.js:322` +3. When true, Ink uses `ansiEscapes.clearTerminal + fullStaticOutput + output` instead of incremental diff +4. `clearTerminal` = `\x1b[2J\x1b[3J\x1b[H]` which destroys selection and scrollback + +### Solution (two layers) + +**Layer 1 — Shell.tsx**: Changed root `` from `minHeight={termHeight}` to `height={termHeight - 1}`. This forces Yoga to constrain the layout to exactly `rows - 1` lines, so Ink's `lastOutputHeight >= stdout.rows` check is always false. Ink then uses its incremental diff path (eraseLines + overwrite changed lines only). `` messages naturally flow into scrollback and are never re-drawn. + +**Layer 2 — `ui/renderer/index.ts`**: Wraps `stdout.write` to intercept frames that still hit the clearTerminal path (when content grows beyond terminal height): + +- Strips `\x1b[2J` and `\x1b[3J` from output +- Rewrites content using CUP absolute positioning (`\x1b[row;1H`) per line instead of `\n` +- Shows the last N lines (N = terminal rows) so statusbar/input stay visible +- Zero `\n` emission = no scrollback pollution +- On DEC 2026 terminals, merges BSU/ESU into single atomic `stdout.write()` + +### Key Files + +| File | Role | +|------|------| +| `src/kimi_cli_ts/ui/renderer/index.ts` | stdout.write wrapper, CUP rewrite, BSU/ESU merge | +| `src/kimi_cli_ts/ui/renderer/terminal-detect.ts` | DEC 2026 terminal detection | +| `src/kimi_cli_ts/ui/shell/Shell.tsx` | Removed `minHeight={termHeight}` | +| `src/kimi_cli_ts/cli/index.ts` | Calls `patchInkLogUpdate()` before Ink render | + +### Debug + +The renderer writes `renderer-debug.log` in the working directory. Use `tail -f renderer-debug.log` to monitor. Key log markers: + +- `STRIP!` — a clearTerminal frame was intercepted and rewritten as CUP +- `FRAME#` — a BSU/ESU-wrapped frame (DEC 2026 terminal) +- `NOSYNC` — a write outside BSU/ESU (non-DEC-2026 terminal, e.g. tmux) +- `eraseLn*` — Ink's normal incremental diff (good, means selection-safe path) +- `ERASE_SCREEN!` — should never appear in output (means strip failed) + +### Constraints + +- Bun cannot monkey-patch Ink's ESM `log-update.js` module (ESM default exports are read-only in Bun's require) +- tmux does not support DEC 2026 synchronized output, so BSU/ESU are passed through individually +- The renderer library files under `ui/renderer/` (screen.ts, diff.ts, ansi-parser.ts, patch-writer.ts, etc.) are infrastructure for future cell-level diffing but are not actively used in the current solution diff --git a/package.json b/package.json index 8326a8c90..72a6808eb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "ink": "^6.8.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "log4js": "^6.9.1", "micromatch": "^4.0.8", "nanoid": "^5.1.7", "openai": "^6.33.0", From ec22fd52e3dcd96c2f3c84fbdb5702ad54a56040 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sun, 5 Apr 2026 12:34:43 +0800 Subject: [PATCH 17/28] feat(ts): shell refactor, panel system, approval flow, and UI improvements Major refactor of the Shell TUI: extract shell commands, layout, callbacks, and prompt symbol into dedicated modules; add PanelShell component, selection panel, panel keyboard/scroller hooks; improve approval panel with tool result display blocks; wire subagent event sink for print mode; add bracketed paste cleanup; update renderer, theme, and various UI components. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/CLAUDE.md | 520 ++++++++++++++++++ src/kimi_cli_ts/cli/index.ts | 90 ++- src/kimi_cli_ts/cli/login.ts | 3 +- src/kimi_cli_ts/constant.ts | 2 +- src/kimi_cli_ts/session.ts | 2 + src/kimi_cli_ts/skill/index.ts | 4 + src/kimi_cli_ts/soul/agent.ts | 44 +- src/kimi_cli_ts/soul/approval.ts | 8 +- src/kimi_cli_ts/soul/compaction.ts | 53 +- src/kimi_cli_ts/soul/kimisoul.ts | 403 +++++++++++--- src/kimi_cli_ts/soul/toolset.ts | 56 +- src/kimi_cli_ts/subagents/output.ts | 30 + src/kimi_cli_ts/subagents/runner.ts | 59 +- src/kimi_cli_ts/tools/file/replace.ts | 14 +- src/kimi_cli_ts/tools/file/write.ts | 14 +- src/kimi_cli_ts/tools/shell/shell.ts | 14 +- src/kimi_cli_ts/tools/types.ts | 11 +- src/kimi_cli_ts/types.ts | 20 +- src/kimi_cli_ts/ui/CLAUDE.md | 175 +++++- .../ui/components/CommandPanel.tsx | 148 ++--- src/kimi_cli_ts/ui/components/PanelShell.tsx | 335 +++++++++++ src/kimi_cli_ts/ui/components/Spinner.tsx | 21 +- src/kimi_cli_ts/ui/components/StatusBar.tsx | 21 +- src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 45 +- src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts | 119 ++++ src/kimi_cli_ts/ui/hooks/usePanelScroller.ts | 73 +++ src/kimi_cli_ts/ui/hooks/useReplayHistory.ts | 339 ++++++++++++ src/kimi_cli_ts/ui/hooks/useUsagePanel.ts | 78 +++ src/kimi_cli_ts/ui/hooks/useWire.ts | 124 ++++- src/kimi_cli_ts/ui/renderer/index.ts | 117 ++-- src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx | 272 ++------- src/kimi_cli_ts/ui/shell/DebugPanel.tsx | 389 +++++++++---- src/kimi_cli_ts/ui/shell/Prompt.tsx | 2 +- src/kimi_cli_ts/ui/shell/PromptView.tsx | 5 +- src/kimi_cli_ts/ui/shell/QuestionPanel.tsx | 4 +- src/kimi_cli_ts/ui/shell/SelectionPanel.tsx | 237 ++++++++ src/kimi_cli_ts/ui/shell/SetupWizard.tsx | 8 +- src/kimi_cli_ts/ui/shell/Shell.tsx | 280 +++++----- src/kimi_cli_ts/ui/shell/UsagePanel.tsx | 174 +++--- src/kimi_cli_ts/ui/shell/Visualize.tsx | 180 ++++-- src/kimi_cli_ts/ui/shell/commands/add_dir.ts | 44 +- src/kimi_cli_ts/ui/shell/commands/editor.ts | 32 +- .../ui/shell/commands/export_import.ts | 36 +- src/kimi_cli_ts/ui/shell/commands/feedback.ts | 17 +- src/kimi_cli_ts/ui/shell/commands/info.ts | 100 ++-- src/kimi_cli_ts/ui/shell/commands/init.ts | 18 +- src/kimi_cli_ts/ui/shell/commands/misc.ts | 30 +- src/kimi_cli_ts/ui/shell/commands/model.ts | 197 ++++++- src/kimi_cli_ts/ui/shell/commands/session.ts | 33 +- src/kimi_cli_ts/ui/shell/commands/usage.ts | 212 +++++-- src/kimi_cli_ts/ui/shell/context-types.ts | 172 ++++++ src/kimi_cli_ts/ui/shell/events.ts | 19 +- src/kimi_cli_ts/ui/shell/index.ts | 3 +- src/kimi_cli_ts/ui/shell/input-stack.ts | 3 +- src/kimi_cli_ts/ui/shell/input-state.ts | 244 ++++++-- src/kimi_cli_ts/ui/shell/shell-commands.ts | 29 + src/kimi_cli_ts/ui/shell/shell-executor.ts | 49 ++ src/kimi_cli_ts/ui/shell/slash.ts | 126 ++++- src/kimi_cli_ts/ui/shell/usePromptSymbol.ts | 25 + src/kimi_cli_ts/ui/shell/useShellCallbacks.ts | 216 ++++++++ src/kimi_cli_ts/ui/shell/useShellLayout.ts | 36 ++ src/kimi_cli_ts/ui/theme.ts | 9 +- src/kimi_cli_ts/wire/types.ts | 1 + 63 files changed, 4907 insertions(+), 1237 deletions(-) create mode 100644 src/kimi_cli_ts/CLAUDE.md create mode 100644 src/kimi_cli_ts/ui/components/PanelShell.tsx create mode 100644 src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts create mode 100644 src/kimi_cli_ts/ui/hooks/usePanelScroller.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useReplayHistory.ts create mode 100644 src/kimi_cli_ts/ui/hooks/useUsagePanel.ts create mode 100644 src/kimi_cli_ts/ui/shell/SelectionPanel.tsx create mode 100644 src/kimi_cli_ts/ui/shell/context-types.ts create mode 100644 src/kimi_cli_ts/ui/shell/shell-commands.ts create mode 100644 src/kimi_cli_ts/ui/shell/shell-executor.ts create mode 100644 src/kimi_cli_ts/ui/shell/usePromptSymbol.ts create mode 100644 src/kimi_cli_ts/ui/shell/useShellCallbacks.ts create mode 100644 src/kimi_cli_ts/ui/shell/useShellLayout.ts diff --git a/src/kimi_cli_ts/CLAUDE.md b/src/kimi_cli_ts/CLAUDE.md new file mode 100644 index 000000000..967abe548 --- /dev/null +++ b/src/kimi_cli_ts/CLAUDE.md @@ -0,0 +1,520 @@ +# Kimi CLI TypeScript Implementation + +## Child CLAUDE.md Files + +- `ui/CLAUDE.md` — UI layer architecture (rendering text selection fix, input architecture, state machine, slash command output) + +## Quick Overview + +**Kimi CLI TypeScript** is an AI agent for terminal software engineering. It implements the core agent loop, CLI routing, UI, tools, and LLM integration in TypeScript using Bun as the runtime. + +- **Language**: TypeScript 5 (strict mode) +- **Runtime**: Bun 1.x (Node.js-compatible, native TS support) +- **Build**: `bun build --compile` → native binary +- **UI**: React 19 + Ink 6 (terminal React renderer) +- **CLI**: Commander.js 14 +- **LLM**: Multi-provider abstraction (Anthropic, OpenAI, Google) + +## Tech Stack + +### Core + +| Component | Package | Version | Role | +|-----------|---------|---------|------| +| Runtime | Bun | 1.x | TypeScript-first runtime, bundler, package manager | +| Language | TypeScript | 5 | Type-safe code | +| CLI | Commander.js | 14 | CLI argument parsing & routing | +| Config | @iarna/toml, zod | 2.2.5, 4.3.6 | TOML config loading, schema validation | + +### UI & Terminal + +| Component | Package | Version | Role | +|-----------|---------|---------|------| +| UI Framework | React | 19.2.4 | Component library | +| Terminal Renderer | Ink | 6.8.0 | React → Terminal rendering | +| Input | ink-text-input | 6.0.0 | Text input component | +| Spinner | ink-spinner | 5.0.0 | Loading animation | +| Colors | chalk | 5.6.2 | Terminal colors | + +### LLM Providers + +| Provider | Package | Version | Role | +|----------|---------|---------|------| +| Anthropic (Claude) | @anthropic-ai/sdk | 0.81.0 | LLM API client | +| OpenAI (GPT) | openai | 6.33.0 | LLM API client | +| Google (Gemini) | @google/genai | 1.48.0 | LLM API client | + +### Utilities + +| Component | Package | Version | Role | +|-----------|---------|---------|------| +| Globbing | globby | 16.2.0 | Fast file pattern matching | +| IDs | nanoid | 5.1.7 | Unique ID generation | +| Logging | log4js | 6.9.1 | Legacy logging (being phased out) | + +### Development + +| Tool | Package | Version | Role | +|------|---------|---------|------| +| Linter/Formatter | Biome | 2.4.10 | Code quality (replaces ESLint/Prettier) | +| Type Checking | TypeScript | 5 | tsc --noEmit | + +## Directory Structure + +``` +src/kimi_cli_ts/ +├── Root (11 files) ..................... Entry, config, app, session, LLM +├── cli/ (9 files) ...................... Command routing & dispatcher +├── soul/ (10 files) .................... Core agent loop +├── tools/ (21 files) ................... Built-in tools (file, shell, web, etc.) +├── ui/ (42 files) ...................... User interface layers +│ ├── shell/ (18 files) .............. Shell TUI (Ink-based) +│ ├── components/ (8 files) .......... React components +│ ├── hooks/ (6 files) ............... React hooks +│ ├── renderer/ (8 files) ............ Terminal rendering +│ ├── print/ (1 file) ................ Print UI mode +│ └── CLAUDE.md ...................... UI architecture docs +├── wire/ (9 files) .................... Event streaming protocol +├── utils/ (14 files) .................. Utilities (logging, async, etc.) +├── background/ (6 files) .............. Background task queue +├── subagents/ (8 files) ............... Sub-agent system +├── notifications/ (7 files) ........... User notifications +├── auth/ (3 files) .................... OAuth & token management +├── hooks/ (3 files) ................... Event hooks +├── approval_runtime/ (1 file) ......... Approval state management +├── plugin/ (2 files) .................. Plugin system +└── skill/ (subdirs) ................... Skills & flows +``` + +## Architecture Layers + +### 1. Entry Point & CLI Routing + +**File**: `index.ts`, `cli/index.ts` + +``` +index.ts (#!/usr/bin/env bun) + └─ cli/index.ts (Commander.js dispatcher) + ├─ web mode → Web UI server + ├─ shell mode → Shell TUI (default) + ├─ print mode → Non-interactive print + └─ acp mode → IDE integration server +``` + +**Key Files**: +- `index.ts` - Executable shebang + entry +- `cli/index.ts` - Command routing via Commander.js +- `cli/commands/*.ts` - Command implementations (model, login, config, etc.) + +### 2. App Factory & Runtime + +**File**: `app.ts` + +`KimiCLI.create()` wires together all components: + +```typescript +KimiCLI.create({ + agent: "default", // Agent spec to load + config: {...}, // User config (TOML) + mcp: {...} // MCP servers +}) +``` + +**Responsibilities**: +1. Load agent spec (YAML, with system prompt) +2. Create session (per-workdir, persistent) +3. Choose LLM provider (Anthropic, OpenAI, etc.) +4. Instantiate toolset (file, shell, web, etc.) +5. Restore conversation history (Context) +6. Return configured KimiSoul ready for `.run()` + +**Key Files**: +- `app.ts` - App factory +- `config.ts` - TOML config loading + Zod validation +- `session.ts` - Session management (per-workdir) +- `llm.ts` - Multi-provider LLM abstraction +- `agentspec.ts` - Agent spec YAML loading + +### 3. Core Agent Loop + +**File**: `soul/kimisoul.ts` + +The main event loop: + +``` +1. Accept user input +2. Append to conversation history (Context) +3. Call LLM with tools (streaming) +4. Parse tool calls +5. Execute tools (with approvals) +6. Emit wire events (for UI) +7. Repeat +``` + +**Responsibilities**: +- LLM chat completion with streaming +- Tool call parsing & execution +- Context compaction (when history grows) +- Error handling & retries +- Integration with approval system + +**Key Files**: +- `soul/kimisoul.ts` - Main loop +- `soul/context.ts` - Conversation history + checkpoints +- `soul/approval.ts` - Tool approval facade +- `soul/compaction.ts` - Context compaction (shrink history) + +### 4. Tooling System + +**File**: `tools/registry.ts` + +Tools are registered by import path and executed via `KimiToolset`: + +```typescript +abstract class CallableTool { + abstract params: ZodSchema; + abstract call(params: TParams, context: ToolContext): Promise; +} +``` + +**Built-in Tools** (21 files): + +| Tool | Purpose | +|------|---------| +| `agent/` | Create/resume sub-agents | +| `ask_user/` | Get user input with approval | +| `background/` | Queue background tasks | +| `dmail/` | Checkpointed replies | +| `think/` | Extended reasoning | +| `todo/` | Todo list management | +| `plan/` | Planning & task breakdown | +| `shell/` | Execute shell commands | +| `web/fetch` | HTTP requests | +| `web/search` | Web search | +| `file/glob` | File pattern matching | +| `file/grep` | Text search in files | +| `file/read` | Read file contents | +| `file/read_media` | Read images/binary | +| `file/write` | Write files | +| `file/replace` | Find-replace in files | + +**Key Files**: +- `tools/registry.ts` - Tool loader & registry +- `tools/types.ts` - `CallableTool` base class +- `tools/*/` - Tool implementations + +### 5. User Interface + +**File**: `ui/shell/Shell.tsx` + +React Ink-based terminal TUI with multi-mode input: + +``` +Shell (Ink root) + ├─ (scrollback: WelcomeBox + completed messages) + ├─ (current message + spinners + approval) + ├─ (input line with cursor) + └─ Bottom Slot (one of): + ├─ ChoicePanel (user choice prompt) + ├─ ContentPanel (user text input) + ├─ SlashMenu (/ command menu) + ├─ MentionMenu (@ file mention) + └─ StatusBar (3-line status) +``` + +**Key Concepts**: + +1. **Input Architecture**: Single `useInput` in `useShellInput()` hook (not in components) +2. **UI State Machine**: `UIMode` enum routes keys (normal → slash_menu → panel_choice, etc.) +3. **Input Stack**: Layered keyboard capture for approval panels +4. **Rendering Fix**: `renderer/index.ts` wraps `stdout.write` to preserve text selection + +See **`ui/CLAUDE.md`** for detailed rendering and input architecture. + +**Key Files**: +- `ui/shell/Shell.tsx` - Main TUI orchestrator +- `ui/shell/input-state.ts` - `useShellInput` hook + state machine +- `ui/shell/input-stack.ts` - Layered input capture +- `ui/shell/PromptView.tsx` - Pure render: input line with cursor +- `ui/components/*.tsx` - React components (panels, menus, etc.) +- `ui/renderer/index.ts` - Ink stdout wrapper (text selection fix) +- `ui/hooks/*.ts` - React hooks (approval, file mention, git status, etc.) + +### 6. Wire Protocol & Events + +**File**: `wire/index.ts` + +JSON-RPC protocol for streaming events between soul and UI: + +```typescript +type WireMessage = + | { type: "start", agent: {...} } + | { type: "message_start", role: "user" | "assistant", ... } + | { type: "content_block_delta", index: number, delta: {...} } + | { type: "tool_use", tool_name: string, tool_input: {...} } + | { type: "tool_result", tool_name: string, result: string } + | { type: "approval_request", ... } + | { type: "message_stop", ... } + | { type: "error", ... } +``` + +UIs consume these events via websocket or stdio stream. + +**Key Files**: +- `wire/index.ts` - Wire message types & transport +- `wire/transport.ts` - JSON-RPC serialization + +### 7. Sub-agents + +**File**: `soul/agent.ts` (LaborMarket), `tools/agent/agent.ts` (Agent tool) + +Sub-agents are persistent workers (created once, resumed by ID): + +```typescript +const agent = await KimiCLI.create({ + agent: "explorer", // Sub-agent spec + agent_id: "..." // Resume if exists +}); +``` + +Instances are stored in `~/.kimi/sessions/{SESSION_ID}/subagents/{agent_id}/`: +- `metadata.json` - Instance info +- `context.jsonl` - Conversation history +- `logs.jsonl` - Wire events + +**Key Files**: +- `soul/agent.ts` - `LaborMarket` builtin type registry +- `tools/agent/` - Agent tool (create/resume) +- `subagents/store.ts` - Sub-agent persistence + +## Key Patterns + +### 1. Tool Implementation + +```typescript +import { CallableTool } from "@/tools/types"; +import { z } from "zod"; + +export class MyTool extends CallableTool { + name = "my_tool"; + description = "Does something"; + + params = z.object({ + input: z.string().describe("Input text"), + }); + + async call(params: MyParams, context: ToolContext): Promise { + // context.sessionId, context.workDir, context.config, context.llm, etc. + return "result"; + } +} +``` + +### 2. React Component (TUI) + +```typescript +import { Box, Text } from "ink"; + +export function MyComponent({ data }: MyComponentProps) { + // Pure component — no useInput, no hooks except custom + return ( + + {data} + + ); +} +``` + +### 3. Agent Spec (YAML) + +```yaml +# src/kimi_cli_ts/agents/default.yaml +name: "default" +extends: "base" + +system_prompt: | + You are Kimi, a helpful AI assistant. + +tools: + - module: tools.file.read + - module: tools.shell + - module: tools.web.fetch + +subagents: + explorer: + extends: default + system_prompt: "You explore codebases..." +``` + +## Configuration + +### User Config (`~/.kimi/config.toml`) + +```toml +[model] +provider = "anthropic" # anthropic, openai, google +model = "claude-3-5-sonnet-20241022" + +[auth] +anthropic_api_key = "sk-..." +openai_api_key = "sk-..." + +[ui] +theme = "auto" +editor = "vim" + +[mcp] +enabled = true +``` + +### Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `KIMI_LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | +| `KIMI_WORK_DIR` | CWD | Working directory for tools | +| `KIMI_SESSION_ID` | (auto) | Session ID (MD5 of workdir) | + +## Logging & Debugging + +**File**: `utils/logging.ts` + +All logs written to disk via log4js. **Never use `process.stderr` for debug output in interactive (TUI) mode** — always use the disk logger. + +### Log Location + +Logs are written to **`~/.kimi/sessions/{SESSION_ID}/logs.log`** (log4js file appender): + +``` +[2026-04-04 12:00:00.000] [INFO] Turn completed +[2026-04-04 12:00:01.000] [ERROR] Tool failed: connection timeout +``` + +- Max file size: 5MB with 1 backup rotation +- Controlled via `KIMI_LOG_LEVEL` env var (debug/info/warn/error) +- Buffered before session dir is set, flushed when `setLogDir()` is called + +### Usage + +```typescript +import { logger } from "../utils/logging.ts"; + +logger.debug("Detailed trace info", someVar); +logger.info("Normal operation info"); +logger.warn("Something unexpected"); +logger.error("Something failed", err.message); +``` + +### Debugging TUI + +To debug the interactive Shell TUI: + +1. Open a screen session with two windows +2. In window 1, run: `bun run start` (launches TUI) +3. In window 2, tail the log: `tail -f ~/.kimi/sessions//logs.log` +4. Set `KIMI_LOG_LEVEL=debug` for verbose output: `KIMI_LOG_LEVEL=debug bun run start` + +Renderer debug log is at `renderer-debug.log` in CWD (separate from the main log). + +### stderr/stdout Rules + +- **Interactive mode (TUI)**: Never use `process.stderr` or `process.stdout` directly for diagnostics. Use the disk `logger` from `utils/logging.ts`. +- **Print mode** (`--print`): `process.stdout` is for LLM text output, `process.stderr` is for user-visible diagnostics (tool calls, errors, notifications). This is the CLI output contract — do not change it. +- **Renderer**: `ui/renderer/index.ts` wraps `process.stdout.write` to intercept Ink's screen clearing. This is infrastructure, not logging. + +## Building & Running + +### Development + +```bash +# Start interactive shell TUI +bun run src/kimi_cli_ts/index.ts + +# Type checking +bun run check # or: tsc --noEmit + +# Linting/formatting +bun run format # Biome +bun run lint # Biome check +``` + +### Production Binary + +```bash +# Compile to native binary +bun build --compile --outfile ./dist/kimi + +# Binary size: ~80-100 MB (includes Bun + dependencies) +``` + +## Conventions + +### Code Style + +- **TypeScript**: Strict mode (`strict: true`) +- **Line length**: 100 characters (match Python side) +- **Linter**: Biome (replaces ESLint/Prettier) +- **Module imports**: `@/` alias for `src/kimi_cli_ts/` + +### File Naming + +- **Components**: PascalCase, `.tsx` (e.g., `Shell.tsx`) +- **Functions/utilities**: camelCase, `.ts` (e.g., `logging.ts`) +- **Types**: PascalCase, exported from `types.ts` +- **Tests**: `*.test.ts` in same dir + +### Async Patterns + +```typescript +// Async tool execution (fire-and-forget with approval) +await toolset.run(toolCall, { + approval: approvalRuntime, // Optional approval + timeout: 30000, // 30s timeout +}); + +// Streaming LLM response +for await (const event of llm.stream(messages, tools)) { + // Process event +} +``` + +## Common Tasks + +### Add a New Tool + +1. Create `tools/my_tool/index.ts` +2. Extend `CallableTool` +3. Define `params` (Zod schema) +4. Implement `call(params, context)` +5. Register in `tools/registry.ts` + +### Add a UI Component + +1. Create `ui/components/MyComponent.tsx` +2. Use React + Ink (no `useInput`) +3. Receive all data via props +4. Integrate in `Shell.tsx` + +### Add a Slash Command + +1. Create `ui/shell/commands/my_command.ts` +2. Export `class MyCommand extends SlashCommand` +3. Register in `ui/shell/slash.ts` + +## Important Notes + +1. **No `useInput` in components**: Keyboard handling is centralized in `useShellInput()` hook. +2. **Pure components**: `PromptView`, panels, menus are pure (props-only, no hooks except custom). +3. **Ink stdout wrapper**: `renderer/index.ts` intercepts Ink's screen clearing to preserve text selection. +4. **Tool approval**: All tool calls can be intercepted by `ApprovalRuntime` before execution. +5. **Persistent sessions**: Sessions persist under `~/.kimi/sessions/`, indexed by workdir + session ID. +6. **JSONL everywhere**: Wire events, logs, context are stored as JSONL for streaming & replay. + +## References + +- **Python Side**: `/Users/yuan/git/kimi-cli/AGENTS.md` (architecture parallels) +- **UI Details**: `ui/CLAUDE.md` (rendering & input architecture) +- **Biome Config**: `.biomeignore`, `.biomerc.json` (linting rules) +- **TypeScript Config**: `tsconfig.json` (compiler settings) + +debug时需要使用本地磁盘log 不要污染stderr \ No newline at end of file diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index dfd8d022b..64ae0d349 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -271,6 +271,8 @@ program let unmount: (() => void) | undefined; // pushEvent — hoisted so the outer catch block can push errors to the UI let pushEvent: ((event: WireUIEvent) => void) | null = null; + // disableBracketedPaste — hoisted so the outer catch block can cleanup bracketed paste mode + let disableBracketedPaste: (() => void) | undefined; try { if (options.print) { @@ -306,6 +308,9 @@ program callbacks, }); + // Wire subagent event sink for print mode (output logging only) + app.agent.runtime.subagentEventSink = () => {}; + if (prompt) await app.runPrint(prompt); printResumeHint(app.session); await app.shutdown(); @@ -344,6 +349,15 @@ program // pushEvent will be set by Shell's onWireReady callback pushEvent = null; + // inkUnmount will be set after render() — captured here so callbacks can trigger reload + let inkUnmountFn: (() => void) | null = null; + + // Helper: trigger reload from anywhere (SoulCallbacks or Shell prop) + const triggerReload = (sessionId: string, prefillText?: string) => { + pendingReload = { sessionId, prefillText }; + inkUnmountFn?.(); + }; + const callbacks: SoulCallbacks = { onTurnBegin: (userInput) => { const text = @@ -373,6 +387,16 @@ program }); }, onToolResult: (toolCallId, result) => { + // Build display blocks — include brief for rejected-with-feedback + const display: unknown[] = result.display ?? []; + if (result.isError && result.message?.includes("User feedback:") && display.length === 0) { + const match = result.message.match(/User feedback: (.+)$/); + if (match) { + display.push({ type: "brief", brief: `Rejected: ${match[1]}` }); + } + } else if (result.isError && result.message?.includes("rejected by the user") && display.length === 0) { + display.push({ type: "brief", brief: "Rejected by user" }); + } pushEvent?.({ type: "tool_result", toolCallId, @@ -383,7 +407,7 @@ program output: result.output, message: result.message, }, - display: [], + display, }, }); }, @@ -397,6 +421,7 @@ program token_usage: status.tokenUsage ?? null, message_id: null, plan_mode: status.planMode ?? null, + yolo: status.yoloEnabled ?? null, mcp_status: null, }, }); @@ -413,6 +438,9 @@ program onNotification: (title, body) => { pushEvent?.({ type: "notification", title, body }); }, + onReload: (sessionId, prefillText) => { + triggerReload(sessionId, prefillText); + }, }; const app = await KimiCLI.create({ @@ -429,10 +457,33 @@ program callbacks, }); + // Wire subagent event sink so subagent runner can forward events to UI. + // This bridges the subagent's SoulCallbacks → parent pushEvent as SubagentEvent. + app.agent.runtime.subagentEventSink = (event) => { + pushEvent?.(event as any); + }; + // Patch Ink's log-update with our cell-level diffing renderer. // This must happen before render() creates the Ink instance. patchInkLogUpdate(); + // Enable bracketed paste mode so terminal doesn't show paste warning. + // This tells the terminal (e.g., VSCode) that we handle large pastes. + process.stdout.write("\x1b[?2004h"); + disableBracketedPaste = () => { + process.stdout.write("\x1b[?2004l"); + }; + + // Check if stdin is a proper TTY for Ink's raw mode support + const isRawModeSupported = process.stdin.isTTY === true; + if (!isRawModeSupported) { + console.error("Error: Kimi CLI requires an interactive terminal."); + console.error("Raw mode is not supported on stdin. Make sure you're running:"); + console.error(" bun run start"); + console.error("(not piping stdin from another command)"); + process.exit(1); + } + const { waitUntilExit, unmount: inkUnmount } = render( React.createElement(Shell, { modelName: app.soul.modelName, @@ -461,16 +512,31 @@ program }, onWireReady: (pe) => { pushEvent = pe; + // Emit initial status so StatusBar shows context tokens at startup + const initStatus = app.soul.status; + pe({ + type: "status_update", + status: { + context_usage: initStatus.contextUsage ?? null, + context_tokens: initStatus.contextTokens ?? null, + max_context_tokens: initStatus.maxContextTokens ?? null, + token_usage: initStatus.tokenUsage ?? null, + message_id: null, + plan_mode: initStatus.planMode ?? null, + yolo: initStatus.yoloEnabled ?? null, + mcp_status: null, + }, + }); }, onReload: (newSessionId: string, prefill?: string) => { - pendingReload = { sessionId: newSessionId, prefillText: prefill }; - inkUnmount(); + triggerReload(newSessionId, prefill); }, extraSlashCommands: app.soul.availableSlashCommands, }), { exitOnCtrlC: false }, ); unmount = inkUnmount; + inkUnmountFn = inkUnmount; // Start background task to forward RootWireHub events to UI let rootHubQueue: any = null; @@ -487,12 +553,23 @@ program "tool_call_id" in msg && "sender" in msg ) { - // ApprovalRequest + // ApprovalRequest — enrich with source_description from subagent store + // (mirrors Python's _enrich_approval_request_for_ui) + const request = msg as any; + if (request.agent_id && !request.source_description && app.soul.runtime.subagentStore) { + const record = app.soul.runtime.subagentStore.getInstance(request.agent_id); + if (record) { + request.source_description = record.description; + } + } pushEvent?.({ type: "approval_request", - request: msg as any, + request, }); } + // NOTE: ApprovalResponse is NOT forwarded here. + // Responses flow through handleApprovalResponse → wire.pushEvent + // to avoid double-processing. } } catch (err) { // Queue shutdown or error @@ -511,6 +588,7 @@ program } await waitUntilExit(); + disableBracketedPaste(); if (rootHubQueue && app.soul.runtime.rootWireHub) { app.soul.runtime.rootWireHub.unsubscribe(rootHubQueue); } @@ -530,6 +608,8 @@ program } } } catch (err) { + // Clean up bracketed paste mode on error + disableBracketedPaste?.(); if (isReload(err)) { // Shouldn't happen with the new loop, but handle gracefully console.error("Unexpected reload — restarting is not supported here."); diff --git a/src/kimi_cli_ts/cli/login.ts b/src/kimi_cli_ts/cli/login.ts index 49bbb1178..e7ed17180 100644 --- a/src/kimi_cli_ts/cli/login.ts +++ b/src/kimi_cli_ts/cli/login.ts @@ -4,6 +4,7 @@ */ import { Command } from "commander"; +import { logger } from "../utils/logging.ts"; export const loginCommand = new Command("login") .description("Login to your Kimi account.") @@ -31,7 +32,7 @@ export const loginCommand = new Command("login") for await (const event of loginKimiCode(config)) { if (event.type === "waiting") { if (!waiting) { - process.stderr.write("Waiting for user authorization...\n"); + logger.info("Waiting for user authorization..."); waiting = true; } continue; diff --git a/src/kimi_cli_ts/constant.ts b/src/kimi_cli_ts/constant.ts index 57847f2c9..288d0ac99 100644 --- a/src/kimi_cli_ts/constant.ts +++ b/src/kimi_cli_ts/constant.ts @@ -13,7 +13,7 @@ export function getVersion(): string { if (_version) return _version; try { // Read version from package.json at build/runtime - const pkgPath = join(import.meta.dir, "../../../package.json"); + const pkgPath = join(import.meta.dir, "../../package.json"); const pkg = require(pkgPath); _version = String(pkg.version ?? "0.0.0"); } catch { diff --git a/src/kimi_cli_ts/session.ts b/src/kimi_cli_ts/session.ts index 2e085effe..e7fa6c71b 100644 --- a/src/kimi_cli_ts/session.ts +++ b/src/kimi_cli_ts/session.ts @@ -302,7 +302,9 @@ export class Session { state, }); + // Skip empty sessions (must be done before refresh to respect state.custom_title) if (await session.isEmpty()) continue; + await session.refresh(); sessions.push(session); } diff --git a/src/kimi_cli_ts/skill/index.ts b/src/kimi_cli_ts/skill/index.ts index ee3a8a05c..f3bf5c534 100644 --- a/src/kimi_cli_ts/skill/index.ts +++ b/src/kimi_cli_ts/skill/index.ts @@ -25,6 +25,10 @@ export interface Skill { // ── Directory discovery ── export function getBuiltinSkillsDir(): string { + // Builtin skills are shared with the Python version under src/kimi_cli/skills/ + // Fall back to TS-local directory if it exists + const pySkillsDir = join(dirname(new URL(import.meta.url).pathname), "..", "..", "kimi_cli", "skills"); + if (existsSync(pySkillsDir)) return pySkillsDir; return join(dirname(new URL(import.meta.url).pathname), "..", "skills"); } diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts index 670ebf532..2095b0076 100644 --- a/src/kimi_cli_ts/soul/agent.ts +++ b/src/kimi_cli_ts/soul/agent.ts @@ -20,6 +20,13 @@ import { RootWireHub } from "../wire/root_hub.ts"; import { loadAgentSpec, getAgentsDir } from "../agentspec.ts"; import { defaultToolPolicy, type ToolPolicy } from "../subagents/models.ts"; import { join } from "node:path"; +import type { Skill } from "../skill/index.ts"; +import { + discoverSkillsFromRoots, + indexSkills, + readSkillText, + resolveSkillsRoots, +} from "../skill/index.ts"; // ── Built-in system prompt args ────────────────────── @@ -51,6 +58,13 @@ export class Runtime { rootWireHub: RootWireHub | null; subagentId: string | null; subagentType: string | null; + skills: Map; + /** + * Callback for forwarding subagent events to the parent UI. + * Set by cli/index.ts when wiring the parent soul's callbacks. + * Used by ForegroundSubagentRunner to emit SubagentEvent to the shell. + */ + subagentEventSink: ((event: Record) => void) | null; constructor(opts: { config: Config; @@ -67,6 +81,8 @@ export class Runtime { rootWireHub?: RootWireHub | null; subagentId?: string | null; subagentType?: string | null; + skills?: Map; + subagentEventSink?: ((event: Record) => void) | null; }) { this.config = opts.config; this.llm = opts.llm; @@ -82,6 +98,8 @@ export class Runtime { this.rootWireHub = opts.rootWireHub ?? null; this.subagentId = opts.subagentId ?? null; this.subagentType = opts.subagentType ?? null; + this.skills = opts.skills ?? new Map(); + this.subagentEventSink = opts.subagentEventSink ?? null; } get loopControl(): LoopControl { @@ -115,12 +133,31 @@ export class Runtime { const shell = process.env.SHELL ?? "/bin/bash"; + // Discover and format skills (matching Python version) + const skillsRoots = resolveSkillsRoots(workDir, { + mergeBrands: opts.config.merge_all_available_skills ?? false, + }); + const discoveredSkills = discoverSkillsFromRoots(skillsRoots); + const skillsByName = indexSkills(discoveredSkills); + const skillsFormatted = + discoveredSkills.length > 0 + ? discoveredSkills + .map( + (skill) => + `- ${skill.name}\n` + + ` - Path: ${skill.skillMdFile}\n` + + ` - Description: ${skill.description}`, + ) + .join("\n") + : "No skills found."; + logger.info(`Discovered ${discoveredSkills.length} skill(s)`); + const builtinArgs: BuiltinSystemPromptArgs = { KIMI_NOW: new Date().toISOString(), KIMI_WORK_DIR: workDir, KIMI_WORK_DIR_LS: workDirLs, KIMI_AGENTS_MD: await loadAgentsMd(workDir) ?? "", - KIMI_SKILLS: "", // TODO: list skills + KIMI_SKILLS: skillsFormatted, KIMI_ADDITIONAL_DIRS_INFO: opts.session.state.additional_dirs.length > 0 ? `Additional directories: ${opts.session.state.additional_dirs.join(", ")}` : "", @@ -172,6 +209,7 @@ export class Runtime { ), approvalRuntime, rootWireHub, + skills: skillsByName, }); } @@ -200,6 +238,8 @@ export class Runtime { rootWireHub: this.rootWireHub, subagentId: opts.agentId, subagentType: opts.subagentType, + skills: this.skills, + subagentEventSink: this.subagentEventSink, }); } } @@ -266,7 +306,7 @@ export async function loadAgent(opts: { description, { display: opts?.display }, ); - return result.approved ? "approve" : "reject"; + return { decision: result.approved ? "approve" as const : "reject" as const, feedback: result.feedback }; }, wireEmit: () => {}, // Will be wired by KimiSoul serviceConfig: { diff --git a/src/kimi_cli_ts/soul/approval.ts b/src/kimi_cli_ts/soul/approval.ts index 4eeb8d25e..d37270a51 100644 --- a/src/kimi_cli_ts/soul/approval.ts +++ b/src/kimi_cli_ts/soul/approval.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import { ApprovalRuntime, ApprovalCancelledError, + getCurrentApprovalSourceOrNull, type ApprovalResponseKind, type ApprovalSource, } from "../approval_runtime/index.ts"; @@ -90,7 +91,12 @@ export class Approval { }, ): Promise { const toolCallId = opts?.toolCallId ?? randomUUID(); - const source: ApprovalSource = opts?.source ?? { kind: "foreground_turn", id: toolCallId }; + // Read approval source from AsyncLocalStorage context (set by subagent runner), + // fall back to explicit opts.source, then default. + // Mirrors Python: get_current_approval_source_or_none() or ApprovalSource(...) + const source: ApprovalSource = opts?.source + ?? getCurrentApprovalSourceOrNull() + ?? { kind: "foreground_turn", id: toolCallId }; if (this.state.yolo) return new ApprovalResult(true); if (this.state.autoApproveActions.has(action)) return new ApprovalResult(true); diff --git a/src/kimi_cli_ts/soul/compaction.ts b/src/kimi_cli_ts/soul/compaction.ts index 3dcc1be1a..a1786d76f 100644 --- a/src/kimi_cli_ts/soul/compaction.ts +++ b/src/kimi_cli_ts/soul/compaction.ts @@ -7,6 +7,7 @@ import type { LLM } from "../llm.ts"; import type { Message } from "../types.ts"; import type { Context } from "./context.ts"; +import type { Agent } from "./agent.ts"; import { logger } from "../utils/logging.ts"; /** Default number of recent user/assistant turns to preserve during compaction. */ @@ -21,7 +22,7 @@ export function estimateTextTokens(messages: readonly Message[]): number { for (const msg of messages) { if (typeof msg.content === "string") { totalChars += msg.content.length; - } else { + } else if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === "text") { totalChars += part.text.length; @@ -75,10 +76,22 @@ export function prepareCompaction( /** * Simple compaction strategy: ask the LLM to summarize the conversation. * Preserves recent messages verbatim. + * + * Mirrors Python KimiSoul.compact_context() behavior: + * 1. Call onBegin hook + * 2. Prepare messages (split into to-compact and to-preserve) + * 3. Call LLM to summarize (with fallback) + * 4. Clear context + * 5. Write system prompt + * 6. Create checkpoint + * 7. Append summary + preserved messages + * 8. Update token count estimate + * 9. Call onEnd hook */ export async function compactContext( context: Context, llm: LLM, + agent?: Agent, opts?: { focus?: string; maxPreservedMessages?: number; @@ -125,8 +138,11 @@ export async function compactContext( summary = buildFallbackSummary(toCompact); } - // Clear context and inject summary + preserved messages + // Clear context and rotate backup (preserves system prompt) await context.compact(); + + // Create checkpoint (mirrors Python: self._checkpoint()) + await context.checkpoint(); if (summary) { await context.appendMessage({ @@ -139,6 +155,18 @@ export async function compactContext( for (const msg of toPreserve) { await context.appendMessage(msg); } + + // Estimate token count for accurate context display + const estimatedTokens = estimateTextTokens([ + { role: "user", content: summary }, + ...toPreserve, + ]); + if (estimatedTokens > 0) { + await context.updateTokenCount({ + inputTokens: estimatedTokens, + outputTokens: 0, + }); + } } finally { opts?.onEnd?.(); } @@ -156,7 +184,7 @@ function buildSummaryPrompt( parts.push(`## Message ${i + 1}\nRole: ${msg.role}\nContent:`); if (typeof msg.content === "string") { parts.push(msg.content); - } else { + } else if (Array.isArray(msg.content)) { for (const part of msg.content) { if (part.type === "text") { parts.push(part.text); @@ -179,10 +207,21 @@ function buildSummaryPrompt( } const COMPACT_PROMPT = `Summarize the conversation above concisely. Preserve: -- Key decisions and outcomes -- Important file paths and code changes -- Tool call results that are still relevant -- Any pending tasks or goals +- Current task state and what is being worked on +- All encountered errors and their resolutions +- Code evolution: final working versions (remove intermediate attempts) +- System context: project structure, dependencies, environment setup +- Design decisions and their rationale +- TODO items and unfinished tasks + +Compression rules: +- MUST KEEP: Error messages, stack traces, working solutions, current task +- MERGE: Similar discussions into single summary points +- REMOVE: Redundant explanations, failed attempts (but keep lessons learned), verbose comments +- CONDENSE: Long code blocks → keep signatures and key logic only + +Output format: +Start with a brief summary of the current task state, then organize remaining context by category. Be thorough but concise.`; function buildFallbackSummary(history: readonly Message[]): string { diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 6dd4ecf7d..1810fb6dd 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -31,6 +31,7 @@ import { handleEditor, createEditorPanel } from "../ui/shell/commands/editor.ts" import { handleInit } from "../ui/shell/commands/init.ts"; import { handleAddDir } from "../ui/shell/commands/add_dir.ts"; import { logger } from "../utils/logging.ts"; +import { readSkillText, type Skill } from "../skill/index.ts"; // ── Errors ───────────────────────────────────────── @@ -190,6 +191,100 @@ export class KimiSoul { new PlanModeInjectionProvider(), new YoloModeInjectionProvider(), ]; + + // Build and register skill slash commands + this._buildSkillSlashCommands(); + } + + /** + * Replace the current callbacks. + * Used by subagent runner to forward events as SubagentEvents to the parent UI. + */ + setCallbacks(cb: SoulCallbacks): void { + this.callbacks = cb; + } + + /** + * Build slash commands from discovered skills. + * Mirrors Python KimiSoul._build_slash_commands() + */ + private _buildSkillSlashCommands(): void { + const commands = this.agent.slashCommands.list(); + const seenNames = new Set(commands.map((c) => c.name)); + + // Register skill commands (/skill:name) + for (const skill of this.agent.runtime.skills.values()) { + if (skill.type !== "standard" && skill.type !== "flow") { + continue; + } + const name = `${SKILL_COMMAND_PREFIX}${skill.name}`; + if (seenNames.has(name)) { + logger.warn(`Skipping skill slash command /${name}: name already registered`); + continue; + } + + const skillCommand: SlashCommand = { + name, + description: skill.description || "", + handler: this._makeSkillRunner(skill), + aliases: [], + }; + this.agent.slashCommands.register(skillCommand); + seenNames.add(name); + } + + // Register flow commands (/flow:name) + for (const skill of this.agent.runtime.skills.values()) { + if (skill.type !== "flow") { + continue; + } + if (!skill.flow) { + logger.warn(`Flow skill ${skill.name} has no flow; skipping`); + continue; + } + + const commandName = `${FLOW_COMMAND_PREFIX}${skill.name}`; + if (seenNames.has(commandName)) { + logger.warn(`Skipping prompt flow slash command /${commandName}: name already registered`); + continue; + } + + // For now, flow commands point to the skill handler (flow execution is already in kimisoul.ts) + const flowCommand: SlashCommand = { + name: commandName, + description: skill.description || "", + handler: this._makeSkillRunner(skill), + aliases: [], + }; + this.agent.slashCommands.register(flowCommand); + seenNames.add(commandName); + } + } + + /** + * Create a skill runner function for a given skill. + * Mirrors Python KimiSoul._make_skill_runner() + * + * Called from within run()'s TurnBegin/TurnEnd lifecycle. + * Simply calls _turn() directly, exactly as Python does: + * await soul._turn(Message(role="user", content=skill_text)) + */ + private _makeSkillRunner(skill: Skill) { + return async (args: string): Promise => { + const skillText = readSkillText(skill); + if (!skillText) { + this.notify("Skill Error", `Failed to load skill "${skill.name}".`); + return; + } + + let finalText = skillText; + const extra = args.trim(); + if (extra) { + finalText = `${skillText}\n\nUser request:\n${extra}`; + } + + await this._turn(finalText); + }; } // ── Properties ─────────────────────────────────── @@ -248,6 +343,7 @@ export class KimiSoul { maxContextTokens: maxCtx, tokenUsage: this._totalUsage, planMode: this._planMode, + yoloEnabled: this.isYolo, mcpStatus: null, }; } @@ -381,12 +477,6 @@ export class KimiSoul { return; } - // Check for slash commands - if (typeof userInput === "string" && userInput.trim().startsWith("/")) { - const handled = await this.agent.slashCommands.execute(userInput); - if (handled) return; - } - this._isRunning = true; this.abortController = new AbortController(); this._toolRejectedNoFeedback = false; @@ -395,10 +485,25 @@ export class KimiSoul { let turnFinished = false; try { this.callbacks.onTurnBegin?.(userInput); - this._wireLog({ type: "turn_begin", user_input: typeof userInput === "string" ? userInput : "[complex]" }); + await this._wireLog({ type: "TurnBegin", user_input: typeof userInput === "string" ? [{ type: "text", text: userInput }] : userInput }); turnStarted = true; - await this._turn(userInput); - this._wireLog({ type: "turn_end" }); + + // Slash command dispatch — inside turn lifecycle, matching Python soul/kimisoul.py:505-521. + // TurnBegin has already been emitted with the original user input (e.g. "/skill:foo"). + const commandCall = this._parseSlashCommand(userInput); + if (commandCall) { + const command = this.agent.slashCommands.get(commandCall.name); + if (command) { + await command.handler(commandCall.args); + } else { + // Unknown command (shouldn't happen — Shell already filtered) + this.notify("Unknown command", `Unknown slash command "/${commandCall.name}".`); + } + } else { + await this._turn(userInput); + } + + await this._wireLog({ type: "TurnEnd" }); this.callbacks.onTurnEnd?.(); turnFinished = true; } catch (err) { @@ -418,7 +523,7 @@ export class KimiSoul { } } finally { if (turnStarted && !turnFinished) { - this._wireLog({ type: "turn_end" }); + await this._wireLog({ type: "TurnEnd" }); this.callbacks.onTurnEnd?.(); } this._isRunning = false; @@ -442,9 +547,36 @@ export class KimiSoul { this._pendingSteers.push(msg); } + // ── Slash command parsing ────────────────────────── + + /** + * Parse a slash command from user input. + * Mirrors Python utils/slashcmd.py:parse_slash_command_call() + */ + private _parseSlashCommand( + userInput: string | ContentPart[], + ): { name: string; args: string } | null { + const text = + typeof userInput === "string" ? userInput.trim() : ""; + if (!text || !text.startsWith("/")) return null; + + const match = text.match(/^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)/); + if (!match || !match[1]) return null; + + const name = match[1]; + const rest = text.slice(match[0].length); + // If there's a non-space character immediately after the command name, it's not a valid command + if (rest.length > 0 && !/^\s/.test(rest)) return null; + + return { name, args: rest.trim() }; + } + // ── Turn execution ────────────────────────────── private async _turn(userInput: string | ContentPart[]): Promise { + // Create checkpoint before appending user message (mirrors Python: self._checkpoint()) + await this.context.checkpoint(); + // Append user message const userMsg: Message = { role: "user", @@ -482,6 +614,7 @@ export class KimiSoul { // Execute one step this._stepCount++; + await this._wireLog({ type: "StepBegin", n: this._stepCount }); this.callbacks.onStepBegin?.(this._stepCount); const maxRetries = this.agent.runtime.config.loop_control.max_retries_per_step; @@ -515,18 +648,44 @@ export class KimiSoul { * Execute tools and append results to context. * This is "shielded" from abort to keep context consistent — * once we start appending, we finish even if abort fires. + * + * Mirrors Python's concurrent tool execution pattern: + * - Tool calls are dispatched as concurrent promises (not awaited immediately) + * - All promises are collected, then awaited together + * - Results are appended sequentially to maintain context order */ private async _executeToolsShielded(toolCalls: ToolCall[]): Promise { - // Execute all tools concurrently + // Phase 1: Dispatch all tool calls as concurrent promises (non-blocking) + // This mirrors Python's toolset.handle() which returns asyncio.Task immediately + const toolPromises = toolCalls.map((tc) => ({ + tc, + promise: this.agent.toolset.handle(tc), + })); + + // Phase 2: Collect all results concurrently + // All tool executions run in parallel, but we wait for all to complete const results = await Promise.all( - toolCalls.map(async (tc) => { - // Check abort before starting, but don't interrupt mid-execution + toolPromises.map(async (item) => { + // Check abort before waiting for result, but don't interrupt mid-execution if (this.abortController?.signal.aborted) return null; - return { tc, result: await this.agent.toolset.handle(tc) }; + try { + return { tc: item.tc, result: await item.promise }; + } catch (err) { + // Tool execution failed — wrap as error result + return { + tc: item.tc, + result: { + isError: true, + output: "", + message: `Tool execution failed: ${err instanceof Error ? err.message : String(err)}`, + }, + }; + } }), ); - // Append results sequentially to maintain context order consistency + // Phase 3: Append results sequentially to maintain context order consistency + // This is where sequential coordination happens even though execution was concurrent for (const entry of results) { if (!entry) continue; const { tc, result } = entry; @@ -545,6 +704,18 @@ export class KimiSoul { isError: result.isError, message: result.message, }); + + // Wire log the tool result (matching Python format) + await this._wireLog({ + type: "ToolResult", + tool_call_id: tc.id, + return_value: { + is_error: result.isError, + output: result.output, + }, + display: result.display ?? [], + }); + // Append atomically — even if abort was signaled during tool execution, // we still append the result to keep context consistent await this.context.appendMessage(resultMsg); @@ -676,16 +847,39 @@ export class KimiSoul { await this.context.updateTokenCount(usage); } - // Wire log step results + // Wire log step results (order matches Python: think → text → toolcalls → status) + if (thinkText) { + await this._wireLog({ type: "ContentPart", payload: { type: "think", think: thinkText } }); + } if (assistantText) { - this._wireLog({ type: "text_part", text: assistantText }); + await this._wireLog({ type: "ContentPart", payload: { type: "text", text: assistantText } }); } for (const tc of toolCalls) { - this._wireLog({ type: "tool_call", name: tc.name, id: tc.id }); + // Match Python ToolCall format: {type:"function", id, function:{name, arguments}} + await this._wireLog({ + type: "ToolCall", + payload: { + type: "function", + id: tc.id, + function: { name: tc.name, arguments: tc.arguments }, + }, + }); } - // Send status update - this.callbacks.onStatusUpdate?.(this.status); + // Wire log + callback status update + const snap = this.status; + await this._wireLog({ + type: "StatusUpdate", + context_usage: snap.contextUsage ?? null, + context_tokens: snap.contextTokens ?? null, + max_context_tokens: snap.maxContextTokens ?? null, + token_usage: usage ?? null, + message_id: null, + plan_mode: snap.planMode ?? false, + yolo: snap.yoloEnabled ?? false, + mcp_status: null, + }); + this.callbacks.onStatusUpdate?.(snap); return toolCalls; } @@ -737,14 +931,27 @@ export class KimiSoul { wireSlashCommands(): void { const registry = this.agent.slashCommands; - // Wire /clear + // Wire /clear — soul-level handler clears context + wire file. + // The shell-level /clear handler orchestrates: clearMessages() + soulClear() + triggerReload(). const clearCmd = registry.get("clear"); if (clearCmd) { clearCmd.handler = async () => { await this.context.clear(); await this.context.writeSystemPrompt(this.agent.systemPrompt); + // Truncate wire.jsonl so replay doesn't re-show old messages after reload. + // Python achieves this implicitly: replay_recent_history() checks + // context.history (empty after clear) and returns early. + const wireFile = this.agent.runtime.session.wireFile; + if (wireFile) { + try { + const { writeFile } = await import("node:fs/promises"); + await writeFile(wireFile, ""); + } catch { /* best-effort */ } + } logger.info("Context cleared"); this.callbacks.onStatusUpdate?.(this.status); + // Trigger reload — mirrors Python: shell raises Reload() after run_soul_command("/clear") + this.callbacks.onReload?.(this.agent.runtime.session.id); }; } @@ -753,14 +960,20 @@ export class KimiSoul { if (compactCmd) { compactCmd.handler = async (args: string) => { if (this.context.nCheckpoints === 0) { - logger.info("The context is empty."); - return; + return "The context is empty."; } const llm = this.agent.runtime.llm; if (!llm) return; logger.info("Running `/compact`"); - await compactContext(this.context, llm, { focus: args || undefined }); + this.callbacks.onCompactionBegin?.(); + try { + await compactContext(this.context, llm, this.agent, { focus: args || undefined }); + } finally { + this.callbacks.onCompactionEnd?.(); + } this.callbacks.onStatusUpdate?.(this.status); + logger.info("Context has been compacted."); + return "The context has been compacted."; }; } @@ -771,10 +984,12 @@ export class KimiSoul { if (this.agent.runtime.approval.isYolo()) { this.agent.runtime.approval.setYolo(false); logger.info("YOLO mode: OFF"); + this.callbacks.onStatusUpdate?.(this.status); return "You only die once! Actions will require approval."; } else { this.agent.runtime.approval.setYolo(true); logger.info("YOLO mode: ON"); + this.callbacks.onStatusUpdate?.(this.status); return "You only live once! All actions will be auto-approved."; } }; @@ -788,32 +1003,28 @@ export class KimiSoul { if (subcmd === "on") { if (!this._planMode) await this.togglePlanModeFromManual(); const planPath = this.getPlanFilePath(); - logger.info(`Plan mode ON. Plan file: ${planPath}`); this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + return `Plan mode ON. Plan file: ${planPath}`; } else if (subcmd === "off") { if (this._planMode) await this.togglePlanModeFromManual(); - logger.info("Plan mode OFF. All tools are now available."); this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); + return "Plan mode OFF. All tools are now available."; } else if (subcmd === "view") { const content = this.readCurrentPlan(); - if (content) { - logger.info(content); - } else { - logger.info("No plan file found for this session."); - } + return content ?? "No plan file found for this session."; } else if (subcmd === "clear") { this.clearCurrentPlan(); - logger.info("Plan cleared."); + return "Plan cleared."; } else { // Default: toggle const newState = await this.togglePlanModeFromManual(); + this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); if (newState) { const planPath = this.getPlanFilePath(); - logger.info(`Plan mode ON. Write your plan to: ${planPath}`); + return `Plan mode ON. Write your plan to: ${planPath}\nUse ExitPlanMode when done, or /plan off to exit manually.`; } else { - logger.info("Plan mode OFF. All tools are now available."); + return "Plan mode OFF. All tools are now available."; } - this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); } }; } @@ -822,18 +1033,28 @@ export class KimiSoul { const modelCmd = registry.get("model"); if (modelCmd) { const notify = (t: string, b: string) => this.notify(t, b); + const onReload = (sessionId: string, prefillText?: string) => { + this.callbacks.onReload?.(sessionId, prefillText); + }; const configMeta = { isFromDefaultLocation: true, sourceFile: null }; modelCmd.handler = async () => { await handleModel(this.agent.runtime.config, configMeta, notify); }; - modelCmd.panel = () => createModelPanel(this.agent.runtime.config, configMeta, notify); + modelCmd.panel = () => + createModelPanel( + this.agent.runtime.config, + configMeta, + notify, + this.agent.runtime.session.id, + onReload, + ); } // Wire /export const exportCmd = registry.get("export"); if (exportCmd) { exportCmd.handler = async (args: string) => { - await handleExport(this.context, this.agent.runtime.session, args); + return await handleExport(this.context, this.agent.runtime.session, args); }; } @@ -841,7 +1062,7 @@ export class KimiSoul { const importCmd = registry.get("import"); if (importCmd) { importCmd.handler = async (args: string) => { - await handleImport(this.context, this.agent.runtime.session, args); + return await handleImport(this.context, this.agent.runtime.session, args); }; } @@ -849,7 +1070,7 @@ export class KimiSoul { const webCmd = registry.get("web"); if (webCmd) { webCmd.handler = async () => { - handleWeb(this.agent.runtime.session.id); + return handleWeb(this.agent.runtime.session.id); }; } @@ -857,7 +1078,7 @@ export class KimiSoul { const visCmd = registry.get("vis"); if (visCmd) { visCmd.handler = async () => { - handleVis(this.agent.runtime.session.id); + return handleVis(this.agent.runtime.session.id); }; } @@ -865,7 +1086,7 @@ export class KimiSoul { const reloadCmd = registry.get("reload"); if (reloadCmd) { reloadCmd.handler = async () => { - handleReload(); + return handleReload(); }; } @@ -873,7 +1094,7 @@ export class KimiSoul { const taskCmd = registry.get("task"); if (taskCmd) { taskCmd.handler = async () => { - handleTask(); + return handleTask(); }; } @@ -899,7 +1120,7 @@ export class KimiSoul { const usageCmd = registry.get("usage"); if (usageCmd) { usageCmd.handler = async () => { - await handleUsage(this.agent.runtime.config, this.agent.runtime.config.default_model || undefined); + return await handleUsage(this.agent.runtime.config, this.agent.runtime.config.default_model || undefined); }; } @@ -907,7 +1128,7 @@ export class KimiSoul { const feedbackCmd = registry.get("feedback"); if (feedbackCmd) { feedbackCmd.handler = async (args: string) => { - await handleFeedback( + return await handleFeedback( this.agent.runtime.config, args, this.agent.runtime.session.id, @@ -928,7 +1149,7 @@ export class KimiSoul { const editorNotify = (t: string, b: string) => this.notify(t, b); const editorConfigMeta = { isFromDefaultLocation: true, sourceFile: null }; editorCmd.handler = async (args: string) => { - await handleEditor(this.agent.runtime.config, editorConfigMeta, args); + return await handleEditor(this.agent.runtime.config, editorConfigMeta, args); }; editorCmd.panel = () => createEditorPanel(this.agent.runtime.config, editorConfigMeta, editorNotify); } @@ -937,7 +1158,7 @@ export class KimiSoul { const hooksCmd = registry.get("hooks"); if (hooksCmd) { hooksCmd.handler = async () => { - handleHooks(this.agent.runtime.hookEngine); + return handleHooks(this.agent.runtime.hookEngine); }; hooksCmd.panel = () => createHooksPanel(this.agent.runtime.hookEngine); } @@ -946,7 +1167,7 @@ export class KimiSoul { const mcpCmd = registry.get("mcp"); if (mcpCmd) { mcpCmd.handler = async () => { - handleMcp(this.agent.runtime.config); + return handleMcp(this.agent.runtime.config); }; mcpCmd.panel = () => createMcpPanel(this.agent.runtime.config); } @@ -955,7 +1176,7 @@ export class KimiSoul { const debugCmd = registry.get("debug"); if (debugCmd) { debugCmd.handler = async () => { - handleDebug(this.context); + return handleDebug(this.context); }; debugCmd.panel = () => createDebugPanel(this.context); } @@ -964,7 +1185,7 @@ export class KimiSoul { const changelogCmd = registry.get("changelog"); if (changelogCmd) { changelogCmd.handler = async () => { - handleChangelog(); + return handleChangelog(); }; changelogCmd.panel = () => createChangelogPanel(); } @@ -973,7 +1194,7 @@ export class KimiSoul { const newCmd = registry.get("new"); if (newCmd) { newCmd.handler = async () => { - await handleNew(this.agent.runtime.session); + return await handleNew(this.agent.runtime.session); }; } @@ -981,7 +1202,7 @@ export class KimiSoul { const sessionsCmd = registry.get("sessions"); if (sessionsCmd) { sessionsCmd.handler = async () => { - await handleSessions(this.agent.runtime.session); + return await handleSessions(this.agent.runtime.session); }; sessionsCmd.panel = () => createSessionsPanel( this.agent.runtime.session, @@ -994,7 +1215,7 @@ export class KimiSoul { const titleCmd = registry.get("title"); if (titleCmd) { titleCmd.handler = async (args: string) => { - await handleTitle(this.agent.runtime.session, args); + return await handleTitle(this.agent.runtime.session, args); }; titleCmd.panel = () => createTitlePanel( this.agent.runtime.session, @@ -1006,14 +1227,7 @@ export class KimiSoul { const initCmd = registry.get("init"); if (initCmd) { initCmd.handler = async () => { - const result = await handleInit(this.agent.runtime.session.workDir); - if (result) { - // Inject the generated AGENTS.md into context so the LLM knows about it - await this.context.appendMessage({ - role: "user", - content: `The user ran /init. Generated AGENTS.md:\n${result}`, - }); - } + return await handleInit(this.agent.runtime.session.workDir); }; } @@ -1021,18 +1235,11 @@ export class KimiSoul { const addDirCmd = registry.get("add-dir"); if (addDirCmd) { addDirCmd.handler = async (args: string) => { - const result = await handleAddDir( + return await handleAddDir( this.agent.runtime.session, this.agent.runtime.session.workDir, args, ); - if (result) { - // Inject directory info into context so the LLM knows about it - await this.context.appendMessage({ - role: "user", - content: result, - }); - } }; } } @@ -1050,15 +1257,60 @@ export class KimiSoul { /** * Append a wire event to the session's wire.jsonl file. - * Used for session title generation and debugging. + * Matches Python's wire.jsonl format exactly: + * - First line: {"type":"metadata","protocol_version":"1.8"} + * - Subsequent lines: {"timestamp":float,"message":{"type":"TypeName","payload":{...}}} + * + * Event format: + * - { type: "TurnBegin", user_input: "..." } + * - { type: "text_part", text: "..." } + * - { type: "ContentPart", payload: { type: "think", thinking: "..." } } + * - { type: "tool_call", name: "...", id: "..." } */ private async _wireLog(event: Record): Promise { const wireFile = this.agent.runtime.session.wireFile; if (!wireFile) return; try { - const { appendFile } = await import("node:fs/promises"); - const line = JSON.stringify({ ...event, ts: Date.now() }) + "\n"; - await appendFile(wireFile, line, "utf-8"); + const { appendFile, stat } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + + // Ensure directory exists + await import("node:fs/promises") + .then(m => m.mkdir(dirname(wireFile), { recursive: true })) + .catch(() => {}); + + // Check if file is empty (need metadata header) + let needsMetadata = false; + try { + const stats = await stat(wireFile); + needsMetadata = stats.size === 0; + } catch { + needsMetadata = true; // File doesn't exist + } + + let content = ""; + + // Write metadata header on first append + if (needsMetadata) { + const metadata = { type: "metadata", protocol_version: "1.8" }; + content += JSON.stringify(metadata) + "\n"; + } + + // Extract type and build payload + const { type, payload, ...otherFields } = event; + const finalPayload = payload || otherFields; + + // Write the message record in Python-compatible format + const record = { + timestamp: Date.now() / 1000, // Convert ms to float seconds + message: { + type, + payload: finalPayload, + }, + }; + content += JSON.stringify(record) + "\n"; + + await appendFile(wireFile, content, "utf-8"); } catch { // Wire logging is best-effort — don't crash on failure } @@ -1071,6 +1323,7 @@ import type { Flow, FlowNode, FlowEdge } from "../skill/flow/index.ts"; import { parseChoice } from "../skill/flow/index.ts"; const DEFAULT_MAX_FLOW_MOVES = 1000; +const SKILL_COMMAND_PREFIX = "skill:"; const FLOW_COMMAND_PREFIX = "flow:"; interface FlowTurnResult { @@ -1277,7 +1530,7 @@ export class FlowRunner { const stepsAfter = soul["_stepCount"]; // Extract final assistant text from context - const history = soul["context"].messages; + const history = soul["context"].history; const lastMsg = history.length > 0 ? history[history.length - 1] : undefined; let finalText: string | undefined; if (lastMsg?.role === "assistant") { @@ -1286,8 +1539,8 @@ export class FlowRunner { ? lastMsg.content : Array.isArray(lastMsg.content) ? lastMsg.content - .filter((p): p is { type: "text"; text: string } => "type" in p && p.type === "text") - .map((p) => p.text) + .filter((p: any): p is { type: "text"; text: string } => "type" in p && p.type === "text") + .map((p: any) => p.text) .join(" ") : undefined; } diff --git a/src/kimi_cli_ts/soul/toolset.ts b/src/kimi_cli_ts/soul/toolset.ts index 1dc3be49b..c4059e695 100644 --- a/src/kimi_cli_ts/soul/toolset.ts +++ b/src/kimi_cli_ts/soul/toolset.ts @@ -4,6 +4,7 @@ * currentToolCall tracking, and sessionId context. */ +import { AsyncLocalStorage } from "node:async_hooks"; import type { CallableTool } from "../tools/base.ts"; import { ToolRegistry } from "../tools/registry.ts"; import type { ToolContext, ToolResult } from "../tools/types.ts"; @@ -11,9 +12,12 @@ import type { HookEngine } from "../hooks/engine.ts"; import type { ToolCall } from "../types.ts"; import { logger } from "../utils/logging.ts"; -// ── Context variables (module-level singletons) ────── +// ── Context variables ───────────────────────────────── +// Use AsyncLocalStorage to mirror Python's ContextVar behavior: +// each concurrent execution (Promise / asyncio.Task) gets its own +// value, preventing races when multiple tools run in parallel. -let _currentToolCall: ToolCall | null = null; +const _toolCallStorage = new AsyncLocalStorage(); let _currentSessionId = ""; /** Set the current session ID for tool call context. */ @@ -28,7 +32,7 @@ export function getSessionId(): string { /** Get the current tool call, or null if not in a tool execution. */ export function getCurrentToolCallOrNull(): ToolCall | null { - return _currentToolCall; + return _toolCallStorage.getStore() ?? null; } export interface ToolsetOptions { @@ -92,12 +96,36 @@ export class KimiToolset { // ── Tool execution with hooks ──────────────────── - async handle(toolCall: ToolCall): Promise { - const { id, name, arguments: argsStr } = toolCall; + /** + * Dispatch a tool call asynchronously. + * + * Returns a Promise that is already running (not awaited here), + * so the caller can fire multiple handles concurrently and collect + * them with Promise.all(). + * + * Mirrors Python's pattern where toolset.handle() returns + * asyncio.create_task(_call()) — the task starts immediately and + * inherits the ContextVar value set before creation. + * + * We use AsyncLocalStorage.run() to give each execution its own + * currentToolCall value (Python ContextVar equivalent). + */ + handle(toolCall: ToolCall): Promise { + // Run the async execution inside an AsyncLocalStorage context + // so getCurrentToolCallOrNull() returns the correct tool call + // for each concurrent execution — mirrors Python ContextVar. + return _toolCallStorage.run(toolCall, () => + this._executeToolAsync(toolCall), + ); + } - // Set current tool call context - const prevToolCall = _currentToolCall; - _currentToolCall = toolCall; + /** + * Execute a single tool call with hooks. + * Runs inside AsyncLocalStorage context — getCurrentToolCallOrNull() + * returns the correct ToolCall throughout the execution. + */ + private async _executeToolAsync(toolCall: ToolCall): Promise { + const { id, name, arguments: argsStr } = toolCall; try { // Notify about tool call @@ -179,9 +207,15 @@ export class KimiToolset { this.onToolResult?.(id, result); return result; - } finally { - // Restore previous tool call context - _currentToolCall = prevToolCall; + } catch (err) { + // Defensive: catch any unexpected errors so the caller never hangs + const result: ToolResult = { + isError: true, + output: "", + message: `Tool "${name}" error: ${err instanceof Error ? err.message : String(err)}`, + }; + this.onToolResult?.(id, result); + return result; } } diff --git a/src/kimi_cli_ts/subagents/output.ts b/src/kimi_cli_ts/subagents/output.ts index 59d7d2c7e..c97f9650e 100644 --- a/src/kimi_cli_ts/subagents/output.ts +++ b/src/kimi_cli_ts/subagents/output.ts @@ -42,6 +42,36 @@ export class SubagentOutputWriter { this.append(`[error] ${message}\n`); } + /** + * Dispatch a wire message to the appropriate output method. + * Corresponds to Python SubagentOutputWriter.write_wire_message(). + */ + writeWireMessage(msg: Record): void { + const type = msg?.type as string | undefined; + const payload = (msg?.payload ?? msg) as Record; + switch (type) { + case "ToolCall": { + const fn = payload?.function as Record | undefined; + this.toolCall((fn?.name as string) ?? "unknown"); + break; + } + case "ToolResult": { + const rv = payload?.return_value as Record | undefined; + const isError = (rv?.isError ?? rv?.is_error ?? false) as boolean; + const brief = (rv?.brief as string) ?? undefined; + this.toolResult(isError ? "error" : "success", brief); + break; + } + case "ContentPart": { + if (payload?.type === "text") { + this.text((payload.text as string) ?? ""); + } + break; + } + // Ignore other wire message types (think, hooks, etc.) + } + } + private append(text: string): void { try { appendFileSync(this._path, text, "utf-8"); diff --git a/src/kimi_cli_ts/subagents/runner.ts b/src/kimi_cli_ts/subagents/runner.ts index 2b140626e..8ca2548a3 100644 --- a/src/kimi_cli_ts/subagents/runner.ts +++ b/src/kimi_cli_ts/subagents/runner.ts @@ -16,6 +16,7 @@ import { SubagentBuilder } from "./builder.ts"; import type { AgentInstanceRecord } from "./models.ts"; import { type SubagentRunSpec, prepareSoul } from "./core.ts"; import type { ApprovalSource } from "../approval_runtime/index.ts"; +import { runWithApprovalSourceAsync } from "../approval_runtime/index.ts"; import type { Wire } from "../wire/wire_core.ts"; import type { WireMessage } from "../wire/types.ts"; import { @@ -223,15 +224,48 @@ export class ForegroundSubagentRunner { const toolCall = getCurrentToolCallOrNull(); const parentToolCallId = toolCall?.id ?? null; - // Build UI loop function for wire event forwarding - // TODO: Pass _uiLoopFn into soul.run() once the TS Wire integration supports it - const _uiLoopFn = ForegroundSubagentRunner._makeUiLoopFn({ - parentToolCallId, - agentId, - subagentType: actualType, - outputWriter, - superWireSoulSide: null, // TODO: get from parent wire when available - }); + // Wire subagent soul callbacks to forward events as SubagentEvent to the parent UI. + // This is the TS equivalent of Python's _make_ui_loop_fn() + Wire pattern. + // Python captures the parent wire via ContextVar closure; we capture parentToolCallId + // in the closure and forward events to the parent via runtime.subagentEventSink. + const sink = this._runtime.subagentEventSink; + if (sink && parentToolCallId) { + const wrap = (nestedEvent: Record) => { + sink({ + type: "subagent_event", + parentToolCallId, + agentId, + subagentType: actualType, + event: nestedEvent, + }); + }; + // Emit initial metadata event so the UI knows about this subagent + // even before any tool calls (matches Python's set_subagent_metadata timing) + wrap({ type: "_metadata" }); + + soul.setCallbacks({ + onToolCall: (tc) => { + outputWriter.toolCall(tc.name); + wrap({ type: "ToolCall", payload: { id: tc.id, function: { name: tc.name, arguments: tc.arguments } } }); + }, + onToolResult: (toolCallId, result) => { + const isError = result.isError ?? false; + const brief = result.message ?? undefined; + outputWriter.toolResult(isError ? "error" : "success", brief); + wrap({ + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: { isError, output: result.output, message: result.message }, + display: result.display ?? [], + }, + }); + }, + onTextDelta: (text) => { + outputWriter.text(text); + }, + }); + } // approvalSource is created inside the try block so the finally // clause can safely skip cancellation if we never got that far. @@ -261,7 +295,10 @@ export class ForegroundSubagentRunner { }); outputWriter.stage("run_soul_start"); - const [finalResponse, failure] = await runWithSummaryContinuation(soul, prompt); + const [finalResponse, failure] = await runWithApprovalSourceAsync( + approvalSource, + () => runWithSummaryContinuation(soul, prompt), + ); if (failure !== null) { this._store.updateInstance(agentId, { status: "failed" }); @@ -369,7 +406,7 @@ export class ForegroundSubagentRunner { } // Always write to output file regardless of wire availability - // TODO: outputWriter.writeWireMessage(msg) — not yet implemented + outputWriter.writeWireMessage(msg as Record); logger.debug(`[subagent:${agentId}] wire event: ${(msg as any)?.type ?? "unknown"}`); if (superWireSoulSide == null || parentToolCallId == null) { diff --git a/src/kimi_cli_ts/tools/file/replace.ts b/src/kimi_cli_ts/tools/file/replace.ts index 584c3b999..ca188ce28 100644 --- a/src/kimi_cli_ts/tools/file/replace.ts +++ b/src/kimi_cli_ts/tools/file/replace.ts @@ -7,7 +7,7 @@ import { resolve } from "node:path"; import { z } from "zod/v4"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; -import { ToolError } from "../types.ts"; +import { ToolError, ToolRejectedError } from "../types.ts"; import { inspectPlanEditTarget } from "./plan_mode.ts"; const DESCRIPTION = `Replace specific strings within a specified file. @@ -147,15 +147,19 @@ export class StrReplaceFile extends CallableTool { } const diffPreview = diffLines.length > 0 ? `\n${diffLines.join("\n")}` : ""; - const decision = await ctx.approval( + const { decision, feedback } = await ctx.approval( "StrReplaceFile", "edit", `Edit file \`${resolvedPath}\` (${edits.length} edit(s))${diffPreview}`, ); if (decision === "reject") { - return ToolError( - "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", - ); + return new ToolRejectedError({ + message: feedback + ? `The tool call is rejected by the user. User feedback: ${feedback}` + : undefined, + brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", + hasFeedback: !!feedback, + }).toToolResult(); } } diff --git a/src/kimi_cli_ts/tools/file/write.ts b/src/kimi_cli_ts/tools/file/write.ts index cbd79fff5..db8b0c7b4 100644 --- a/src/kimi_cli_ts/tools/file/write.ts +++ b/src/kimi_cli_ts/tools/file/write.ts @@ -7,7 +7,7 @@ import { resolve, dirname } from "node:path"; import { z } from "zod/v4"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; -import { ToolError } from "../types.ts"; +import { ToolError, ToolRejectedError } from "../types.ts"; import { inspectPlanEditTarget } from "./plan_mode.ts"; import type { DiffDisplayBlock } from "../display.ts"; @@ -148,15 +148,19 @@ export class WriteFile extends CallableTool { ? `${params.mode === "append" ? "Append to" : "Overwrite"} file \`${params.path}\`${diffPreview ? `\n${diffPreview}` : ""}` : `Create file \`${params.path}\` (${params.content.length} chars)`; - const decision = await ctx.approval( + const { decision, feedback } = await ctx.approval( "WriteFile", fileExisted ? "edit" : "create", approvalSummary, ); if (decision === "reject") { - return ToolError( - "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", - ); + return new ToolRejectedError({ + message: feedback + ? `The tool call is rejected by the user. User feedback: ${feedback}` + : undefined, + brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", + hasFeedback: !!feedback, + }).toToolResult(); } } diff --git a/src/kimi_cli_ts/tools/shell/shell.ts b/src/kimi_cli_ts/tools/shell/shell.ts index 28d500bff..364451f57 100644 --- a/src/kimi_cli_ts/tools/shell/shell.ts +++ b/src/kimi_cli_ts/tools/shell/shell.ts @@ -6,7 +6,7 @@ import { z } from "zod/v4"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; -import { ToolError, ToolResultBuilder } from "../types.ts"; +import { ToolError, ToolRejectedError, ToolResultBuilder } from "../types.ts"; const MAX_FOREGROUND_TIMEOUT = 5 * 60; // 5 minutes const MAX_BACKGROUND_TIMEOUT = 24 * 60 * 60; // 24 hours @@ -126,7 +126,7 @@ export class Shell extends CallableTool { } // Request approval - const decision = await ctx.approval( + const { decision, feedback } = await ctx.approval( "Shell", "run command", `Run command \`${params.command}\``, @@ -141,9 +141,13 @@ export class Shell extends CallableTool { }, ); if (decision === "reject") { - return ToolError( - "The tool call is rejected by the user. Stop what you are doing and wait for the user to tell you how to proceed.", - ); + return new ToolRejectedError({ + message: feedback + ? `The tool call is rejected by the user. User feedback: ${feedback}` + : undefined, + brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", + hasFeedback: !!feedback, + }).toToolResult(); } try { diff --git a/src/kimi_cli_ts/tools/types.ts b/src/kimi_cli_ts/tools/types.ts index 6143cba47..eb35718a3 100644 --- a/src/kimi_cli_ts/tools/types.ts +++ b/src/kimi_cli_ts/tools/types.ts @@ -2,7 +2,7 @@ * Tool-related types — corresponds to Python tools/utils.py and kosong.tooling types. */ -import type { ApprovalDecision, JsonValue } from "../types.ts"; +import type { ApprovalDecisionResult, JsonValue } from "../types.ts"; import type { Runtime } from "../soul/agent.ts"; // ── ToolContext ────────────────────────────────────────── @@ -13,13 +13,13 @@ export interface ToolContext { workingDir: string; /** AbortSignal for cooperative cancellation. */ signal?: AbortSignal; - /** Request user approval; returns the decision. */ + /** Request user approval; returns the decision and optional feedback. */ approval: ( toolName: string, action: string, summary: string, opts?: { display?: unknown[] }, - ) => Promise; + ) => Promise; /** Emit a wire event (for UI communication). */ wireEmit?: (event: unknown) => void; /** Toggle plan mode on/off. */ @@ -66,8 +66,9 @@ export function ToolError( message: string, output = "", display?: unknown[], + extras?: Record, ): ToolResult { - return { isError: true, output, message, display }; + return { isError: true, output, message, display, extras }; } // ── ToolDefinition ────────────────────────────────────── @@ -246,6 +247,8 @@ export class ToolRejectedError extends Error { isError: true, output: "", message: this.message, + display: [{ type: "brief" as const, brief: this.brief }], + extras: this.hasFeedback ? { userFeedback: true } : undefined, }; } } diff --git a/src/kimi_cli_ts/types.ts b/src/kimi_cli_ts/types.ts index e9684923e..4fbae87bd 100644 --- a/src/kimi_cli_ts/types.ts +++ b/src/kimi_cli_ts/types.ts @@ -35,7 +35,12 @@ export const ToolResultPart = z.object({ isError: z.boolean().optional(), }); -export const ContentPart = z.union([TextPart, ImagePart, ToolUsePart, ToolResultPart]); +export const ThinkPart = z.object({ + type: z.literal("think"), + think: z.string(), +}); + +export const ContentPart = z.union([TextPart, ImagePart, ToolUsePart, ToolResultPart, ThinkPart]); export type ContentPart = z.infer; // ── Message Types ──────────────────────────────────────── @@ -43,6 +48,7 @@ export type ContentPart = z.infer; export const Message = z.object({ role: z.enum(["user", "assistant", "system", "tool"]), content: z.union([z.string(), z.array(ContentPart)]), + reasoning_content: z.string().optional(), // Thinking content (stored separately for now) }); export type Message = z.infer; @@ -88,6 +94,12 @@ export type ToolReturnValue = z.infer; export type ApprovalDecision = "approve" | "approve_for_session" | "reject"; +/** Result returned by the ToolContext.approval callback. */ +export interface ApprovalDecisionResult { + decision: ApprovalDecision; + feedback: string; +} + // ── Status ────────────────────────────────────────────── export interface StatusSnapshot { @@ -96,6 +108,7 @@ export interface StatusSnapshot { maxContextTokens: number | null; tokenUsage: TokenUsage | null; planMode: boolean; + yoloEnabled: boolean; mcpStatus: Record | null; } @@ -108,10 +121,13 @@ export interface PanelChoiceItem { current?: boolean; } +import type { KContextInfo, KMessage } from "./ui/shell/context-types.ts"; + export type CommandPanelConfig = | { type: "choice"; title: string; items: PanelChoiceItem[]; onSelect: (value: string) => CommandPanelConfig | Promise | void } | { type: "content"; title: string; content: string } - | { type: "input"; title: string; placeholder?: string; password?: boolean; onSubmit: (value: string) => CommandPanelConfig | Promise | void }; + | { type: "input"; title: string; placeholder?: string; password?: boolean; onSubmit: (value: string) => CommandPanelConfig | Promise | void } + | { type: "debug"; data: { context: KContextInfo; messages: KMessage[] } }; export interface SlashCommand { name: string; diff --git a/src/kimi_cli_ts/ui/CLAUDE.md b/src/kimi_cli_ts/ui/CLAUDE.md index 22aa67db7..a15dce6ba 100644 --- a/src/kimi_cli_ts/ui/CLAUDE.md +++ b/src/kimi_cli_ts/ui/CLAUDE.md @@ -4,28 +4,43 @@ React Ink destroys terminal mouse text selection when it clears the screen. The fix has two layers: -**`ui/renderer/index.ts`** wraps `stdout.write` as a safety net to prevent Ink from clearing the screen: +**Shell.tsx** does NOT use a fixed `height` on the root ``. This lets Ink render only the actual content height, using incremental line-level diffing (eraseLines + overwrite). WelcomeBox is rendered via `` into terminal scrollback, and the dynamic content (prompt + status bar + panels) renders directly below. When a panel closes, Ink's `eraseLines(N)` correctly clears all N previous lines; any remaining blank space below is normal terminal viewport area. + +**`ui/renderer/index.ts`** wraps `stdout.write` as a safety net: - Strips `\x1b[2J` (erase screen) and `\x1b[3J` (erase scrollback) if they appear - Rewrites those frames using CUP absolute positioning (`\x1b[row;1H`) per line — zero `\n`, no scroll pollution - On DEC 2026 terminals, merges BSU/ESU into single atomic `stdout.write()` -- **Content shrink handling**: Tracks max rendered lines and emits `ERASE_BELOW` when content shrinks (e.g., closing a tall help panel), cleaning up orphaned lines at the bottom +- Guards against double-wrapping (module re-imports in reload loop) via `__rendererPatched`/`__rendererWrapped` markers on stdout -By relying on this safety net, **Shell.tsx** does NOT use a fixed `height` on the root ``. This allows Ink to use incremental line-level diffing without reserving extra vertical space, avoiding excess blank lines and keeping the WelcomeBox visible without being pushed off-screen. +Debug log: `renderer-debug.log` in CWD. Key markers: `STRIP!` = clearTerminal intercepted, `FRAME#` = BSU/ESU frame. -Debug log: `renderer-debug.log` in CWD. Key markers: `STRIP!` = clearTerminal intercepted, `FRAME#` = BSU/ESU frame, `SHRINK` = content shrink cleanup. +**Layout constraints:** +- Do NOT add `height={termHeight-1}` to root Box — causes WelcomeBox to be pushed far from input, or lost entirely +- Do NOT use `justifyContent="flex-end"` on root Box — breaks `` rendering +- Blank terminal rows below the content after closing a tall panel is normal terminal behavior (not dirty content) **Constraints:** - Bun cannot monkey-patch Ink's ESM `log-update.js` (default exports are read-only) -- tmux does not support DEC 2026 synchronized output +- screen does not support DEC 2026 synchronized output - `renderer/` subdirectory has unused infrastructure files (screen.ts, diff.ts, ansi-parser.ts, patch-writer.ts) for future cell-level diffing ## Input Architecture Single `useInput` in Shell via `useShellInput()` hook (`input-state.ts`). All rendering components are pure (no `useInput`, no keyboard state). All hotkey logic (Ctrl+C double-press exit, shell mode toggle, plan mode, editor) is also inside the hook. +Shell.tsx is a thin orchestrator. Logic is split across focused modules: + ``` -Shell.tsx (thin orchestrator — no keyboard logic, no hotkey state) -├── useShellInput() ← SINGLE useInput + UI state machine + hotkeys +Shell.tsx (orchestrator — wires hooks together, renders layout) +│ +├── shell-commands.ts ← deduplicateCommands(), createAllCommands(), SHELL_MODE_COMMANDS +├── shell-executor.ts ← runShellCommand(), openExternalEditor() +├── useShellCallbacks.ts ← all callback logic for useShellInput + handleApprovalResponse +│ uses ref-based accessor to break circular dep with inputState +├── usePromptSymbol.ts ← getPromptSymbol() — pure function: mode/shellMode/streaming → symbol +├── useShellLayout.ts ← useShellLayout() — terminal height + staticItems computation +│ +├── useShellInput() ← SINGLE useInput + UI state machine + hotkeys (input-state.ts) │ ├── useInputHistory() ← input value, cursor, history (persisted per-cwd) │ ├── useFileMention() ← @ file mention suggestions │ ├── UIMode state machine ← routes keys by current mode @@ -71,10 +86,15 @@ panel_content ── Esc ───────→ normal | File | Role | |------|------| +| `shell/Shell.tsx` | Thin orchestrator: wires hooks together, renders layout. No keyboard logic, no callbacks. | | `shell/input-state.ts` | `useShellInput` hook: useInput singleton, state machine, key dispatcher, hotkeys, shellMode | | `shell/input-stack.ts` | `useInputLayer` hook: input focus stack for layered keyboard capture | +| `shell/shell-commands.ts` | Command management: `deduplicateCommands`, `createAllCommands`, `SHELL_MODE_COMMANDS` | +| `shell/shell-executor.ts` | Subprocess execution: `runShellCommand`, `openExternalEditor` | +| `shell/useShellCallbacks.ts` | Callback bundle for useShellInput (onSubmit, onInterrupt, etc.) + handleApprovalResponse | +| `shell/usePromptSymbol.ts` | `getPromptSymbol()` — pure function deriving prompt symbol from UI state | +| `shell/useShellLayout.ts` | `useShellLayout()` — terminal height tracking + staticItems computation | | `shell/PromptView.tsx` | Pure render: separator + panel title + buffered lines + input with cursor | -| `shell/Shell.tsx` | Thin orchestrator: wires callbacks, renders layout. No keyboard logic. | | `components/CommandPanel.tsx` | Controlled `ChoicePanel` + `ContentPanel` (no useInput) | | `components/SlashMenu.tsx` | Pure render slash command menu | | `components/MentionMenu.tsx` | Pure render @ mention menu | @@ -83,7 +103,8 @@ panel_content ── Esc ───────→ normal ### Rules - **Never add `useInput` to rendering components.** All keyboard handling goes through `useShellInput` in `input-state.ts`. -- **Never add hotkey/shortcut logic to Shell.tsx.** Shell passes external callbacks (`onExit`, `onInterrupt`, `onPlanModeToggle`, `onOpenEditor`, `onNotify`) to `useShellInput`, which handles the logic internally. +- **Never add hotkey/shortcut logic to Shell.tsx.** Shell delegates callback construction to `useShellCallbacks`, which returns the bundle for `useShellInput`. Hotkey handling lives in `input-state.ts`. +- **Never put utility functions or subprocess logic in Shell.tsx.** Shell commands go in `shell-commands.ts`, execution logic in `shell-executor.ts`. - **PromptView, ChoicePanel, ContentPanel, SlashMenu, MentionMenu, StatusBar** are pure — they receive all data via props. - **Bottom slot is mutually exclusive**: only one of the 5 components renders at a time based on `UIMode`. - **`` for completed content**: WelcomeBox and finished messages go into `` so they enter scrollback and are never re-drawn. @@ -110,3 +131,139 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { return ...; } ``` + +## Slash Command Output → Message Stream + +Slash command handlers that need to display text in the message stream must **return a string**. The flow is: + +1. Handler returns `Promise` (or `string`) +2. `useShellCallbacks.ts` `onSubmit` calls `cmd.handler(args)`, awaits the promise, and if result is a string pushes `wire.pushEvent({ type: "slash_result", text: feedback })` +3. `useWire.ts` receives `slash_result` event, creates a `UIMessage` with `role: "system"` and inserts it into the message list via `setMessages` + +**Anti-pattern**: using `logger.info()` inside a handler — this only writes to the log file on disk, nothing appears in the UI. This was the original bug in `/export` and `/import`. + +**Correct pattern** (e.g., `export_import.ts`): +```typescript +// Handler returns a string → displayed in message stream +export async function handleExport(...): Promise { + // ... do work ... + return `Exported ${count} messages to ${display}\nNote: ...`; +} + +// kimisoul.ts wiring — must return the string +exportCmd.handler = async (args: string) => { + return await handleExport(this.context, this.agent.runtime.session, args); +}; +``` + +**Key files in the chain:** +- `useShellCallbacks.ts:86-93` — checks handler return, pushes `slash_result` event +- `events.ts:65` — `{ type: "slash_result"; text: string }` event type definition +- `useWire.ts:214-224` — converts `slash_result` into a system `UIMessage` + +### Migration Status + +**Completed** (all handlers now return strings): +- ✅ `export_import.ts` — `/export`, `/import` +- ✅ `misc.ts` — `/web`, `/vis`, `/reload`, `/task` +- ✅ `editor.ts` — `/editor` +- ✅ `info.ts` — `/hooks`, `/mcp`, `/debug`, `/changelog` +- ✅ `add_dir.ts` — `/add-dir` +- ✅ `feedback.ts` — `/feedback` +- ✅ `session.ts` — `/new`, `/sessions`, `/title` +- ✅ `usage.ts` — `/usage` +- ✅ `init.ts` — `/init` +- ✅ All handlers wired in `kimisoul.ts` to return strings + +**Not changed** (use notify callbacks, not logger.info): +- `login.ts` — `/login`, `/logout` (use notify pattern, not slash output) +- `model.ts` — `/model` (uses notify pattern) + +## `/clear` Command Logic Comparison: Python vs TS + +### Python Implementation ✅ + +**File:** `src/kimi_cli/ui/shell/slash.py` (lines 488-494) +```python +@registry.command(aliases=["reset"]) +async def clear(app: Shell, args: str): + """Clear the context""" + if ensure_kimi_soul(app) is None: + return + await app.run_soul_command("/clear") # Executes soul-level command + raise Reload() # Reloads shell UI with clean display +``` + +**Soul-level handler in `src/kimi_cli/soul/slash.py` (lines 75-89):** +```python +@registry.command(aliases=["reset"]) +async def clear(soul: KimiSoul, args: str): + """Clear the context""" + await soul.context.clear() + await soul.context.write_system_prompt(soul.agent.system_prompt) + wire_send(TextPart(text="The context has been cleared.")) # ← SENDS TO WIRE + snap = soul.status + wire_send(StatusUpdate(...)) # ← SENDS TO WIRE +``` + +**Key behaviors:** +1. Soul-level handler uses `wire_send(TextPart(...))` to **send message to the message stream** while still in the same session +2. The visualizer (`visualize.py`) catches this TextPart and wraps it with a bullet character "•" +3. Shell-level handler raises `Reload()` which reloads the UI to a clean state +4. User sees: `"• The context has been cleared."` in message stream, then fresh session reload + +### TypeScript Implementation (BROKEN) ❌ + +**File:** `src/kimi_cli_ts/ui/shell/slash.ts` (lines 40-59) +```typescript +handler: async () => { + await ctx.soulClear?.(); // Calls soul-level handler + const height = ctx.getDynamicViewportHeight?.() ?? 5; + if (ctx.triggerReload && ctx.sessionId) { + ctx.triggerReload(ctx.sessionId); // Unmounts Ink + } + // MISSING: ctx.clearMessages() ❌ + process.stdout.write( + (eraseLine + cursorUp).repeat(height) + + eraseLine + "\r" + + "• The context has been cleared.\n" + ); +}, +``` + +**Soul-level handler in `src/kimi_cli_ts/soul/kimisoul.ts` (lines 755-760):** +```typescript +clearCmd.handler = async () => { + await this.context.clear(); + await this.context.writeSystemPrompt(this.agent.systemPrompt); + logger.info("Context cleared"); // ← WRITES TO LOG FILE ONLY ❌ + this.callbacks.onStatusUpdate?.(this.status); // ← Status update only +}; +``` + +### Root Causes of Residual Display + +1. **Missing `wire_send` in soul-level handler**: TS soul has no `wire_send` mechanism (see TODO at `kimisoul.ts:1234`). The soul-level handler only calls `onStatusUpdate`, not `onTextDelta` or equivalent. Should send the message via callbacks. + +2. **Missing `clearMessages()` call**: The shell-level `/clear` handler receives `ctx.clearMessages` (wire state reset function) but never calls it. This leaves thinking text, message history, and previous content in `wire.messages`. + +3. **Timing issue with `process.stdout.write`**: After `triggerReload()` calls `inkUnmount()`, the handler immediately calls `process.stdout.write()`. But: + - The new Ink instance is created and starts rendering immediately + - The Static component rebuilds from `wire.messages` (which hasn't been cleared!) + - The new render likely overwrites the `process.stdout.write` output + - The old Ink final frame's residual text (input prompt showing "/clear") persists in the terminal + +4. **Static rendering from uncleared wire.messages**: When the new Ink instance renders, `useShellLayout` builds staticItems from `wire.messages`. Since messages were never cleared, all previous thinking text, responses, etc. re-appear in the new session's Static area. + +### Fix Strategy + +The TS version needs to: +1. **Clear wire messages BEFORE unmounting Ink**: Call `ctx.clearMessages()` in the handler before `triggerReload()` +2. **Add wire_send equivalent to soul-level handler**: Instead of just `logger.info()`, call `this.callbacks.onTextDelta()` or create a new callback to send the "context has been cleared" message to wire +3. **Ensure clean terminal state**: The Ink unmount/remount cycle needs to properly erase old content + +## DEBUG + +you can use screen and bun run start to launch tui and debug ui + +debug时需要使用本地磁盘log 不要污染stderr \ No newline at end of file diff --git a/src/kimi_cli_ts/ui/components/CommandPanel.tsx b/src/kimi_cli_ts/ui/components/CommandPanel.tsx index 30f735ff6..ed69cc90a 100644 --- a/src/kimi_cli_ts/ui/components/CommandPanel.tsx +++ b/src/kimi_cli_ts/ui/components/CommandPanel.tsx @@ -1,71 +1,85 @@ /** - * CommandPanel.tsx — Controlled panel components for slash commands. + * CommandPanel.tsx — Self-contained panel components for slash commands. * * Two modes: - * - ChoicePanel: selectable list (controlled via selectedIndex prop) - * - ContentPanel: scrollable text (controlled via scrollOffset prop) + * - ChoicePanel: selectable list with keyboard navigation + * - ContentPanel: scrollable text viewer * - * No useInput inside — keyboard is handled by Shell's useShellInput dispatcher. - * "input" type panels are handled by Prompt + useShellInput directly. + * Both use usePanelKeyboard for input (via useInputLayer stack). + * Both use usePanelScroller for windowing. + * Both use PanelShell for border rendering. */ -import React from "react"; +import React, { useState } from "react"; import { Box, Text, useStdout } from "ink"; +import { PanelShell } from "./PanelShell.tsx"; +import { usePanelScroller } from "../hooks/usePanelScroller.ts"; +import { usePanelKeyboard } from "../hooks/usePanelKeyboard.ts"; import type { CommandPanelConfig } from "../../types.ts"; const DIM = "#888888"; const HIGHLIGHT = "#1e90ff"; -const BORDER_COLOR = "#555555"; -// ── Choice Panel (controlled) ─────────────────────────── +// ── Choice Panel ───────────────────────────────────────── interface ChoicePanelProps { config: Extract; - selectedIndex: number; + onClose: () => void; + onChain: (next: CommandPanelConfig) => void; } -export function ChoicePanel({ config, selectedIndex }: ChoicePanelProps) { +export function ChoicePanel({ config, onClose, onChain }: ChoicePanelProps) { const { items, title } = config; - const { stdout } = useStdout(); - const columns = stdout?.columns ?? 80; - const rows = stdout?.rows ?? 24; + const initialIndex = Math.max(0, items.findIndex((i) => i.current)); + const [selectedIndex, setSelectedIndex] = useState(initialIndex); - const maxVisible = Math.max(rows - 8, 5); - const total = items.length; + const scroller = usePanelScroller({ + totalItems: items.length, + focusedIndex: selectedIndex, + minVisible: 5, + terminalReservedLines: 8, + }); - // Compute visible window centered on selectedIndex - let start = 0; - if (total > maxVisible) { - start = Math.max( - 0, - Math.min(selectedIndex - Math.floor(maxVisible / 2), total - maxVisible), - ); - } - const visibleItems = items.slice(start, start + maxVisible); - const hasMore = start + maxVisible < total; - const hasPrev = start > 0; + usePanelKeyboard({ + selectedIndex, + maxIndex: items.length - 1, + onIndexChange: setSelectedIndex, + onEnter: (idx) => { + const item = items[idx]; + if (!item) return; + const result = config.onSelect(item.value); + if (!result) { + onClose(); + } else if (result instanceof Promise) { + result.then((next) => (next ? onChain(next) : onClose())); + } else { + onChain(result); + } + }, + onEscape: onClose, + }); + + const total = items.length; + const footerLeft = total > scroller.visibleCount + ? `[${selectedIndex + 1}/${total}]` + : undefined; return ( - - {"─".repeat(columns)} - - - {title} - - (↑↓ select, Enter confirm, Esc cancel) - {total > maxVisible && ( - {` [${selectedIndex + 1}/${total}]`} - )} - - {"─".repeat(columns)} - {hasPrev && ↑ more...} - {visibleItems.map((item, vi) => { - const i = start + vi; + + {scroller.hasAbove && \u2191 more...} + {items.slice(scroller.startIndex, scroller.endIndex).map((item, vi) => { + const i = scroller.startIndex + vi; const isSelected = i === selectedIndex; return ( - {isSelected ? "▸ " : " "} + {isSelected ? "\u25B8 " : " "} ); })} - {hasMore && ↓ more...} - + {scroller.hasBelow && \u2193 more...} + ); } -// ── Content Panel (controlled) ────────────────────────── +// ── Content Panel ──────────────────────────────────────── interface ContentPanelProps { config: Extract; - scrollOffset: number; + onClose: () => void; } -export function ContentPanel({ config, scrollOffset }: ContentPanelProps) { +export function ContentPanel({ config, onClose }: ContentPanelProps) { const { content, title } = config; const { stdout } = useStdout(); - const columns = stdout?.columns ?? 80; - const maxVisibleLines = Math.max((stdout?.rows ?? 24) - 8, 10); + const rows = stdout?.rows ?? 24; + const maxVisibleLines = Math.max(rows - 8, 10); const lines = content.split("\n"); const maxScroll = Math.max(0, lines.length - maxVisibleLines); + const [scrollOffset, setScrollOffset] = useState(0); const clampedOffset = Math.min(scrollOffset, maxScroll); const visibleLines = lines.slice(clampedOffset, clampedOffset + maxVisibleLines); const hasMore = clampedOffset < maxScroll; + usePanelKeyboard({ + onScrollUp: () => setScrollOffset((o) => Math.max(0, o - 1)), + onScrollDown: () => setScrollOffset((o) => Math.min(maxScroll, o + 1)), + onEscape: onClose, + }); + + const footerLeft = maxScroll > 0 + ? `[${clampedOffset + 1}-${Math.min(clampedOffset + maxVisibleLines, lines.length)}/${lines.length}]` + : undefined; + return ( - - {"─".repeat(columns)} - - - {title} - - (↑↓ scroll, Esc close) - {maxScroll > 0 && ( - - {` [${clampedOffset + 1}-${Math.min(clampedOffset + maxVisibleLines, lines.length)}/${lines.length}]`} - - )} - - {"─".repeat(columns)} + {visibleLines.map((line, i) => ( {line || " "} ))} - {hasMore && ↓ more...} - + {hasMore && \u2193 more...} + ); } diff --git a/src/kimi_cli_ts/ui/components/PanelShell.tsx b/src/kimi_cli_ts/ui/components/PanelShell.tsx new file mode 100644 index 000000000..d7dee90e1 --- /dev/null +++ b/src/kimi_cli_ts/ui/components/PanelShell.tsx @@ -0,0 +1,335 @@ +/** + * PanelShell.tsx — Universal bordered container for terminal panels. + * + * Two variant modes: + * - "box": Unicode box border (╭─╮│╰─╯) with title centered in top border. + * Used by UsagePanel, DebugPanel, SelectionPanel. + * - "rules": Horizontal rule lines (─) as top/bottom separator with header. + * Used by SlashMenu, ChoicePanel, ContentPanel. + * + * Pure render component — no useInput. + */ + +import React from "react"; +import { Box, Text } from "ink"; +import { getTerminalSize } from "../shell/console.ts"; + +// ── Border characters ──────────────────────────────────── + +const BOX_TL = "\u256D"; // ╭ +const BOX_TR = "\u256E"; // ╮ +const BOX_BL = "\u2570"; // ╰ +const BOX_BR = "\u256F"; // ╯ +const BOX_H = "\u2500"; // ─ +const BOX_V = "\u2502"; // │ + +// ── Default colors ─────────────────────────────────────── + +const DEFAULT_BOX_BORDER = "#d2b48c"; +const DEFAULT_RULES_BORDER = "#555555"; +const DEFAULT_FOOTER_COLOR = "#808080"; + +// ── Props ──────────────────────────────────────────────── + +export interface PanelShellProps { + variant?: "box" | "rules"; + borderColor?: string; + width?: "fit" | "full"; + paddingX?: number; + contentWidth?: number; + + title?: string; + titleColor?: string; + titlePosition?: "border" | "inside"; + + footerHints?: string[]; + footerLeft?: string; + footerColor?: string; + + children: React.ReactNode; +} + +// ── PanelRow (for box variant) ─────────────────────────── + +export interface PanelRowProps { + borderColor?: string; + paddingX?: number; + contentWidth?: number; + children: React.ReactNode; +} + +/** + * A row inside a box-variant PanelShell. + * Wraps children with │ + padding + content + padding + │. + */ +export function PanelRow({ + borderColor = DEFAULT_BOX_BORDER, + paddingX = 2, + contentWidth, + children, +}: PanelRowProps) { + const pad = " ".repeat(paddingX); + + if (contentWidth != null) { + // Fixed-width row: right-pad content area so │ aligns + return ( + + {BOX_V} + {pad} + {children} + {pad} + {BOX_V} + + ); + } + + return ( + + {BOX_V} + {pad} + {children} + {pad} + {BOX_V} + + ); +} + +// ── PanelShell ─────────────────────────────────────────── + +export function PanelShell({ + variant = "box", + borderColor, + width, + paddingX, + contentWidth, + title, + titleColor, + titlePosition, + footerHints, + footerLeft, + footerColor = DEFAULT_FOOTER_COLOR, + children, +}: PanelShellProps) { + if (variant === "rules") { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +// ── Box variant ────────────────────────────────────────── + +interface BoxVariantProps { + borderColor?: string; + width?: "fit" | "full"; + paddingX?: number; + contentWidth?: number; + title?: string; + titleColor?: string; + titlePosition?: "border" | "inside"; + footerHints?: string[]; + footerLeft?: string; + footerColor: string; + children: React.ReactNode; +} + +function BoxVariant({ + borderColor = DEFAULT_BOX_BORDER, + width = "fit", + paddingX = 2, + contentWidth: contentWidthProp, + title, + titleColor, + titlePosition = "border", + footerHints, + footerLeft, + footerColor, + children, +}: BoxVariantProps) { + // Determine inner width (content area between border chars) + let innerWidth: number; + if (width === "full") { + const { columns } = getTerminalSize(); + innerWidth = columns - 2; // minus 2 border chars (│ │) + } else if (contentWidthProp != null) { + innerWidth = contentWidthProp + paddingX * 2; + } else { + // Fallback: let caller control via contentWidth + innerWidth = 40; + } + + // ── Top border ── + let topBorder: string; + if (title && titlePosition === "border") { + const titleStr = ` ${title} `; + const dashesTotal = Math.max(0, innerWidth - titleStr.length); + const dashesLeft = Math.floor(dashesTotal / 2); + const dashesRight = dashesTotal - dashesLeft; + topBorder = + BOX_TL + BOX_H.repeat(dashesLeft) + titleStr + BOX_H.repeat(dashesRight) + BOX_TR; + } else { + topBorder = BOX_TL + BOX_H.repeat(innerWidth) + BOX_TR; + } + + // ── Bottom border ── + const bottomBorder = BOX_BL + BOX_H.repeat(innerWidth) + BOX_BR; + + // ── Footer row (inside the border) ── + const hasFooter = (footerHints && footerHints.length > 0) || footerLeft; + + return ( + + {/* Top border with optional title */} + + {titleColor && title && titlePosition === "border" ? ( + <> + {BOX_TL + BOX_H.repeat(Math.floor(Math.max(0, innerWidth - title.length - 2) / 2))} + {" "} + {title} + {" "} + {BOX_H.repeat( + Math.max(0, innerWidth - title.length - 2) - + Math.floor(Math.max(0, innerWidth - title.length - 2) / 2), + ) + BOX_TR} + + ) : ( + topBorder + )} + + + {/* Inside title line (when titlePosition="inside") */} + {title && titlePosition === "inside" && ( + + + {title} + + + )} + + {/* Children */} + {children} + + {/* Footer row */} + {hasFooter && ( + + {footerLeft && {footerLeft}} + {contentWidthProp != null && ( + + {" ".repeat( + Math.max( + 0, + contentWidthProp - + (footerLeft?.length ?? 0) - + (footerHints ? footerHints.join(" ").length : 0), + ), + )} + + )} + {footerHints && footerHints.length > 0 && ( + + {footerHints.join(" ")} + + )} + + )} + + {/* Bottom border */} + {bottomBorder} + + ); +} + +// ── Rules variant ──────────────────────────────────────── + +interface RulesVariantProps { + borderColor?: string; + width?: "fit" | "full"; + paddingX?: number; + title?: string; + titleColor?: string; + footerHints?: string[]; + footerLeft?: string; + footerColor: string; + children: React.ReactNode; +} + +function RulesVariant({ + borderColor = DEFAULT_RULES_BORDER, + width = "full", + paddingX = 1, + title, + titleColor, + footerHints, + footerLeft, + footerColor, + children, +}: RulesVariantProps) { + const { columns } = getTerminalSize(); + const ruleWidth = width === "full" ? columns : columns; + const rule = BOX_H.repeat(ruleWidth); + + // Build header line: title (left) + hints (middle) + footerLeft (right) + const hasHeader = title || (footerHints && footerHints.length > 0) || footerLeft; + + return ( + + {/* Top rule */} + {rule} + + {/* Header line */} + {hasHeader && ( + + {title && ( + + {title} + + )} + {footerHints && footerHints.length > 0 && ( + + {title ? " " : ""}({footerHints.join(", ")}) + + )} + {footerLeft && ( + + {" "}{footerLeft} + + )} + + )} + + {/* Bottom rule (below header) */} + {hasHeader && {rule}} + + {/* Children */} + {children} + + ); +} diff --git a/src/kimi_cli_ts/ui/components/Spinner.tsx b/src/kimi_cli_ts/ui/components/Spinner.tsx index edc77c274..31d8dc20a 100644 --- a/src/kimi_cli_ts/ui/components/Spinner.tsx +++ b/src/kimi_cli_ts/ui/components/Spinner.tsx @@ -38,7 +38,15 @@ interface CompactionSpinnerProps { export function CompactionSpinner({ active }: CompactionSpinnerProps) { if (!active) return null; - return ; + // Match Python exactly: Spinner("balloon", "Compacting...") + return ( + + + + + Compacting... + + ); } interface StreamingSpinnerProps { @@ -46,9 +54,14 @@ interface StreamingSpinnerProps { } export function StreamingSpinner({ stepCount }: StreamingSpinnerProps) { + // Match Python: StepBegin shows a moon-phase spinner with no text. + // The "Thinking..." / "Composing..." text is shown by _ContentBlock spinners, + // not by this top-level streaming indicator. return ( - 0 ? `Thinking... (step ${stepCount})` : "Thinking..."} - /> + + + + + ); } diff --git a/src/kimi_cli_ts/ui/components/StatusBar.tsx b/src/kimi_cli_ts/ui/components/StatusBar.tsx index aa9ebba5a..f59a9ce0a 100644 --- a/src/kimi_cli_ts/ui/components/StatusBar.tsx +++ b/src/kimi_cli_ts/ui/components/StatusBar.tsx @@ -12,7 +12,15 @@ import { Box, Text, useStdout } from "ink"; import type { StatusUpdate } from "../../wire/types.ts"; import type { Toast } from "./NotificationStack.tsx"; -const DIM = "#888888"; +// Python 256-color palette mappings: +// - palette 239 = #4e4e4e (separator) +// - palette 240 = #585858 (tip text) +// - palette 241 = #626262 (cwd, dim text) +// - palette 244 = #808080 (grey50, bg_tasks) +const SEPARATOR_COLOR = "#4e4e4e"; // palette 239 +const TIP_COLOR = "#585858"; // palette 240 +const DIM = "#626262"; // palette 241 for cwd +const BG_TASKS_COLOR = "#808080"; // palette 244 const TIP_ROTATE_MS = 30_000; const DEFAULT_TIPS = [ @@ -203,7 +211,7 @@ export function StatusBar({ leftSegments.push({ key: "git", text: gitBadge, - render: () => {gitBadge}, + render: () => {gitBadge}, }); } @@ -220,7 +228,7 @@ export function StatusBar({ leftSegments.push({ key: "bg", text: t, - render: () => {t}, + render: () => {t}, }); } @@ -278,6 +286,9 @@ export function StatusBar({ return ( + {/* Separator line above status bar — matches Python's palette 239 */} + {separator} + {/* Line 1: status indicators — guaranteed single row */} @@ -285,7 +296,7 @@ export function StatusBar({ {visibleTip && ( - {visibleTip} + {visibleTip} )} @@ -302,7 +313,7 @@ export function StatusBar({ )} - {rightText} + {rightText} diff --git a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx index 612a1386b..916ef3411 100644 --- a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx +++ b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx @@ -7,7 +7,10 @@ import React from "react"; import { Box, Text } from "ink"; import { modelDisplayName } from "../../llm.ts"; -const KIMI_BLUE = "#1e90ff"; +// Python uses 256-color palette index 33 = RGB(0, 135, 255) = #0087ff (dodger_blue1) +const KIMI_BLUE = "#0087ff"; +// Python uses palette index 244 = RGB(178, 178, 178) = #b2b2b2 (grey50) +const GREY_50 = "#b2b2b2"; interface WelcomeBoxProps { workDir?: string; @@ -38,56 +41,44 @@ export function WelcomeBox({ borderStyle="round" borderColor={KIMI_BLUE} flexDirection="column" - paddingX={1} + paddingX={2} paddingY={1} > {/* Logo + Welcome */} - ▐█▛█▛█▌ - ▐█████▌ + ▐█▛█▛█▌ + ▐█████▌ - Welcome to Kimi Code CLI! - Send /help for help information. + Welcome to Kimi Code CLI! + Send /help for help information. {/* Blank line */} - {/* Directory */} - - Directory: - {displayDir} - + {/* Directory — Python renders entire line in grey50 */} + Directory: {displayDir} {/* Session */} {sessionId && ( - - Session: - {sessionId} - + Session: {sessionId} )} {/* Model */} - - Model: - {displayModel ? ( - {displayModel} - ) : ( - not set, send /login to login - )} - + {displayModel ? ( + Model: {displayModel} + ) : ( + Model: not set, send /login to login + )} {/* Tip */} {tip && ( <> - - Tip: - {tip} - + Tip: {tip} )} diff --git a/src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts b/src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts new file mode 100644 index 000000000..047a17ee6 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts @@ -0,0 +1,119 @@ +/** + * usePanelKeyboard — keyboard handling hook for terminal TUI panels. + * + * Internally pushes onto the input stack via useInputLayer so the panel + * captures all keyboard events while mounted. + */ + +import { useRef } from "react"; + +import { useInputLayer } from "../shell/input-stack.ts"; +import type { InputKey } from "../shell/input-stack.ts"; + +export interface PanelKeyboardOptions { + // Navigation (arrow keys) + selectedIndex?: number; + maxIndex?: number; + onIndexChange?: (idx: number) => void; + circular?: boolean; // wrap around at bounds (default: false) + + // Action keys + onEnter?: (index: number) => void; + onEscape?: () => void; + onTab?: () => void; + onSpace?: () => void; + + // Number key shortcuts (1-9) + onNumberKey?: (num: number) => void; + + // Scroll (takes priority over index navigation) + onScrollUp?: () => void; + onScrollDown?: () => void; + + // Text input mode (for inline text editing) + textInput?: boolean; + onTextChange?: (char: string) => void; + onTextSubmit?: (text: string) => void; + onBackspace?: () => void; +} + +export function usePanelKeyboard(opts: PanelKeyboardOptions): void { + const optsRef = useRef(opts); + optsRef.current = opts; + + useInputLayer((input: string, key: InputKey) => { + const o = optsRef.current; + + // 1. Escape + if (key.escape) { + o.onEscape?.(); + return; + } + + // 2. Enter/Return + if (key.return) { + o.onEnter?.(o.selectedIndex ?? 0); + return; + } + + // 3. Tab + if (key.tab) { + o.onTab?.(); + return; + } + + // 4. Up arrow + if (key.upArrow) { + if (o.onScrollUp) { + o.onScrollUp(); + } else if (o.onIndexChange && o.maxIndex != null) { + const cur = o.selectedIndex ?? 0; + const next = o.circular + ? (cur - 1 + o.maxIndex + 1) % (o.maxIndex + 1) + : Math.max(0, cur - 1); + o.onIndexChange(next); + } + return; + } + + // 5. Down arrow + if (key.downArrow) { + if (o.onScrollDown) { + o.onScrollDown(); + } else if (o.onIndexChange && o.maxIndex != null) { + const cur = o.selectedIndex ?? 0; + const next = o.circular + ? (cur + 1) % (o.maxIndex + 1) + : Math.min(o.maxIndex, cur + 1); + o.onIndexChange(next); + } + return; + } + + // 6. Space + if (input === " ") { + o.onSpace?.(); + return; + } + + // 7. Backspace / Delete + if (key.backspace || key.delete) { + o.onBackspace?.(); + return; + } + + // 8. Number keys 1-9 + if (input >= "1" && input <= "9") { + o.onNumberKey?.(parseInt(input)); + return; + } + + // 9. Text input mode + if (o.textInput && input && !key.ctrl && !key.meta) { + o.onTextChange?.(input); + return; + } + + // 10. All other keys consumed — prevent leaking to outer handler + }); +} diff --git a/src/kimi_cli_ts/ui/hooks/usePanelScroller.ts b/src/kimi_cli_ts/ui/hooks/usePanelScroller.ts new file mode 100644 index 000000000..1cd16ec8c --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/usePanelScroller.ts @@ -0,0 +1,73 @@ +/** + * usePanelScroller.ts — Pure calculation hook for windowing/scrolling in list-based panels. + * + * Computes which items are visible given total count, terminal height, and optional focus index. + * No rendering, no keyboard handling — just windowing math. + */ + +import { useMemo } from "react"; +import { getTerminalSize } from "../shell/console.ts"; + +export interface PanelScrollerOptions { + totalItems: number; + maxVisible?: number; + focusedIndex?: number; + minVisible?: number; + terminalReservedLines?: number; +} + +export interface PanelScrollerReturn { + startIndex: number; + endIndex: number; + hasAbove: boolean; + hasBelow: boolean; + aboveCount: number; + belowCount: number; + visibleCount: number; +} + +export function usePanelScroller(options: PanelScrollerOptions): PanelScrollerReturn { + const { + totalItems, + maxVisible: maxVisibleOverride, + focusedIndex, + minVisible = 5, + terminalReservedLines = 8, + } = options; + + return useMemo(() => { + const maxVisible = + maxVisibleOverride ?? Math.max(minVisible, getTerminalSize().rows - terminalReservedLines); + + if (totalItems <= maxVisible) { + return { + startIndex: 0, + endIndex: totalItems, + hasAbove: false, + hasBelow: false, + aboveCount: 0, + belowCount: 0, + visibleCount: totalItems, + }; + } + + let start: number; + if (focusedIndex != null) { + start = Math.max(0, Math.min(focusedIndex - Math.floor(maxVisible / 2), totalItems - maxVisible)); + } else { + start = 0; + } + + const end = Math.min(totalItems, start + maxVisible); + + return { + startIndex: start, + endIndex: end, + hasAbove: start > 0, + hasBelow: end < totalItems, + aboveCount: start, + belowCount: totalItems - end, + visibleCount: end - start, + }; + }, [totalItems, maxVisibleOverride, focusedIndex, minVisible, terminalReservedLines]); +} diff --git a/src/kimi_cli_ts/ui/hooks/useReplayHistory.ts b/src/kimi_cli_ts/ui/hooks/useReplayHistory.ts new file mode 100644 index 000000000..6042bfdf2 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useReplayHistory.ts @@ -0,0 +1,339 @@ +/** + * useReplayHistory — Load and replay conversation history from wire.jsonl on session resume. + * Corresponds to Python's ui/shell/replay.py. + * + * Handles two wire.jsonl formats: + * 1. TS flat: {"type":"turn_begin","user_input":"hi","ts":123} + * 2. Python nested: {"timestamp":123,"message":{"type":"TurnBegin","payload":{"user_input":[...]}}} + */ + +import { useEffect, useState } from "react"; +import { join } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import type { WireUIEvent } from "../shell/events.ts"; +import { buildReplayTurnsFromEvents, type ReplayTurn } from "../shell/ReplayPanel.tsx"; + +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + +interface UseReplayHistoryOptions { + sessionDir?: string; + enabled?: boolean; +} + +export function useReplayHistory({ + sessionDir, + enabled = true, +}: UseReplayHistoryOptions): { + turns: ReplayTurn[]; + loading: boolean; +} { + const [turns, setTurns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!enabled || !sessionDir) { + setTurns([]); + return; + } + + setLoading(true); + + try { + // Try wire.jsonl first (preferred — has structured events) + const wirePath = join(sessionDir, "wire.jsonl"); + const wireExists = existsSync(wirePath); + + if (wireExists) { + const stat = Bun.file(wirePath).size; + if (stat <= MAX_FILE_SIZE) { + const content = readFileSync(wirePath, "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + const events: WireUIEvent[] = []; + for (const line of lines) { + try { + const obj = JSON.parse(line); + const event = parseWireLine(obj); + if (event) events.push(event); + } catch { /* skip */ } + } + const replayTurns = buildReplayTurnsFromEvents(events); + if (replayTurns.length > 0) { + setTurns(replayTurns); + setLoading(false); + return; + } + } + } + + // Fallback: reconstruct from context.jsonl + const contextPath = join(sessionDir, "context.jsonl"); + if (existsSync(contextPath)) { + const stat = Bun.file(contextPath).size; + if (stat <= MAX_FILE_SIZE) { + const content = readFileSync(contextPath, "utf-8"); + const replayTurns = buildTurnsFromContext(content); + setTurns(replayTurns); + setLoading(false); + return; + } + } + + setTurns([]); + } catch { + setTurns([]); + } finally { + setLoading(false); + } + }, [sessionDir, enabled]); + + return { turns, loading }; +} + +/** + * Parse a single wire.jsonl line into a WireUIEvent. + * Handles both TS flat format and Python nested format. + */ +function parseWireLine(obj: any): WireUIEvent | null { + if (!obj || typeof obj !== "object") return null; + + // Python nested format: {timestamp, message: {type, payload}} + if (obj.message && typeof obj.message === "object" && obj.message.type) { + return parsePythonWireMessage(obj.message); + } + + // TS flat format: {type, ...fields, ts} + if (obj.type && typeof obj.type === "string") { + return parseFlatWireMessage(obj); + } + + return null; +} + +/** + * Parse Python nested wire message: {type: "TurnBegin", payload: {...}} + * Python uses ContentPart for both text and think content. + */ +function parsePythonWireMessage(msg: any): WireUIEvent | null { + const type: string = msg.type; + const payload: any = msg.payload ?? {}; + + if (type === "TurnBegin") { + const userInput = extractUserInput(payload.user_input); + if (userInput.startsWith("/clear")) return null; + return { type: "turn_begin", userInput }; + } + + if (type === "StepBegin") { + return { type: "step_begin", n: payload.n ?? 1 }; + } + + // Python uses ContentPart for both text and think + if (type === "ContentPart") { + const contentType = payload.type; + if (contentType === "text") { + return { type: "text_delta", text: payload.text ?? "" }; + } + if (contentType === "think") { + return { type: "think_delta", text: payload.think ?? payload.text ?? "" }; + } + return null; + } + + // Fallback: TextPart / ThinkPart (in case some versions use these) + if (type === "TextPart") { + return { type: "text_delta", text: payload.text ?? "" }; + } + + if (type === "ThinkPart") { + return { type: "think_delta", text: payload.text ?? "" }; + } + + if (type === "ToolCall") { + // Python stores tool call as: {type: "function", id: "...", function: {name, arguments}} + const fn = payload.function ?? {}; + return { + type: "tool_call", + id: payload.id ?? "", + name: fn.name ?? payload.name ?? "", + arguments: typeof fn.arguments === "string" + ? fn.arguments + : JSON.stringify(fn.arguments ?? payload.arguments ?? {}), + }; + } + + if (type === "ToolResult") { + return { + type: "tool_result", + toolCallId: payload.tool_call_id ?? "", + result: { + return_value: { + output: payload.output ?? payload.text ?? "", + isError: payload.is_error ?? false, + }, + } as any, + }; + } + + if (type === "TurnEnd") { + return { type: "turn_end" }; + } + + return null; +} + +/** + * Parse TS flat wire message: {type: "turn_begin", user_input: "...", ts: ...} + */ +function parseFlatWireMessage(obj: any): WireUIEvent | null { + const type: string = obj.type; + + // Skip metadata lines + if (type === "metadata") return null; + + if (type === "turn_begin") { + const userInput = typeof obj.user_input === "string" ? obj.user_input : "[complex input]"; + if (userInput.startsWith("/clear")) return null; + return { type: "turn_begin", userInput }; + } + + if (type === "step_begin") { + return { type: "step_begin", n: obj.n ?? 1 }; + } + + if (type === "text_part") { + return { type: "text_delta", text: obj.text ?? "" }; + } + + if (type === "think_part") { + return { type: "think_delta", text: obj.text ?? "" }; + } + + if (type === "tool_call") { + return { + type: "tool_call", + id: obj.id ?? "", + name: obj.name ?? "", + arguments: typeof obj.arguments === "string" + ? obj.arguments + : JSON.stringify(obj.arguments ?? {}), + }; + } + + if (type === "tool_result") { + return { + type: "tool_result", + toolCallId: obj.tool_call_id ?? "", + result: { + return_value: { + output: obj.output ?? obj.text ?? "", + isError: obj.is_error ?? false, + }, + } as any, + }; + } + + if (type === "turn_end") { + return { type: "turn_end" }; + } + + return null; +} + +/** + * Extract user input text from various formats. + * Python wire stores user_input as [{type: "text", text: "..."}] array. + */ +function extractUserInput(userInput: any): string { + if (typeof userInput === "string") return userInput; + if (Array.isArray(userInput)) { + const textParts = userInput + .filter((p: any) => p.type === "text" && typeof p.text === "string") + .map((p: any) => p.text); + return textParts.join("") || "[complex input]"; + } + return "[complex input]"; +} + +/** + * Build replay turns from context.jsonl (fallback when wire.jsonl is missing). + * context.jsonl stores messages as JSONL with {role, content, tool_calls?, ...}. + * We reconstruct user/assistant turn pairs from this. + */ +const MAX_CONTEXT_TURNS = 5; + +function buildTurnsFromContext(content: string): ReplayTurn[] { + const lines = content.split("\n").filter((l) => l.trim()); + const messages: any[] = []; + + for (const line of lines) { + try { + const obj = JSON.parse(line); + // Skip internal messages (system prompt, usage, checkpoint, etc.) + if (!obj.role || obj.role === "system" || obj.role === "developer") continue; + if (obj.role?.startsWith("_")) continue; + messages.push(obj); + } catch { /* skip */ } + } + + // Group into turns: each user message starts a new turn + const turns: ReplayTurn[] = []; + let currentTurn: ReplayTurn | null = null; + + for (const msg of messages) { + if (msg.role === "user") { + // Extract user text + const text = extractContentText(msg.content); + if (!text) continue; + currentTurn = { userInput: text, events: [], stepCount: 0 }; + turns.push(currentTurn); + } else if (msg.role === "assistant" && currentTurn) { + // Extract assistant text content + const text = extractContentText(msg.content); + if (text) { + currentTurn.events.push({ type: "text", text }); + } + // Extract tool calls + if (Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + const fn = tc.function ?? {}; + currentTurn.events.push({ + type: "tool_call", + toolName: fn.name ?? tc.name ?? "", + toolArgs: fn.arguments ?? "", + toolCallId: tc.id ?? "", + }); + } + } + // TS format: tool_use in content array + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "tool_use") { + currentTurn.events.push({ + type: "tool_call", + toolName: part.name ?? "", + toolArgs: typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {}), + toolCallId: part.id ?? "", + }); + } + } + } + } else if (msg.role === "tool" && currentTurn) { + // Tool result — skip for display (tool calls already shown) + } + } + + // Return last N turns + return turns.slice(-MAX_CONTEXT_TURNS); +} + +/** Extract text content from a message's content field. */ +function extractContentText(content: any): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((p: any) => p.type === "text" && typeof p.text === "string") + .map((p: any) => p.text) + .join(""); + } + return ""; +} diff --git a/src/kimi_cli_ts/ui/hooks/useUsagePanel.ts b/src/kimi_cli_ts/ui/hooks/useUsagePanel.ts new file mode 100644 index 000000000..a7fc53106 --- /dev/null +++ b/src/kimi_cli_ts/ui/hooks/useUsagePanel.ts @@ -0,0 +1,78 @@ +/** + * useUsagePanel.ts — Hook for managing the /usage panel state. + * + * Handles: + * - Visibility toggle + * - Loading state while fetching + * - Caching fetched data + * - Error handling + */ + +import { useState, useCallback } from "react"; +import { fetchAndParseUsage } from "../shell/commands/usage.ts"; +import type { UsageRow } from "../shell/UsagePanel.tsx"; +import type { Config } from "../../config.ts"; + +export interface UsagePanelState { + visible: boolean; + loading: boolean; + error: string | null; + summary: UsageRow | null; + limits: UsageRow[]; +} + +export interface UseUsagePanelReturn extends UsagePanelState { + show: (config: Config, modelKey: string | undefined) => Promise; + hide: () => void; + reset: () => void; +} + +const initialState: UsagePanelState = { + visible: false, + loading: false, + error: null, + summary: null, + limits: [], +}; + +export function useUsagePanel(): UseUsagePanelReturn { + const [state, setState] = useState(initialState); + + const show = useCallback(async (config: Config, modelKey: string | undefined) => { + setState({ ...initialState, visible: true, loading: true }); + + try { + const result = await fetchAndParseUsage(config, modelKey); + setState({ + visible: true, + loading: false, + error: result.error || null, + summary: result.summary, + limits: result.limits, + }); + } catch (err) { + setState({ + visible: true, + loading: false, + error: `Unexpected error: ${err instanceof Error ? err.message : err}`, + summary: null, + limits: [], + }); + } + }, []); + + const hide = useCallback(() => { + setState(initialState); + }, []); + + const reset = useCallback(() => { + setState(initialState); + }, []); + + return { + ...state, + show, + hide, + reset, + }; +} diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts index a90482c54..cfafd2798 100644 --- a/src/kimi_cli_ts/ui/hooks/useWire.ts +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -11,14 +11,19 @@ import type { ThinkSegment, ToolCallSegment, } from "../shell/events"; +import { extractKeyArgument } from "../../tools/types.ts"; import type { StatusUpdate, ApprovalRequest } from "../../wire/types"; import type { Toast } from "../components/NotificationStack"; import { nanoid } from "nanoid"; +const MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4; export interface WireState { messages: UIMessage[]; isStreaming: boolean; + /** The approval request currently being shown to the user (head of queue). */ pendingApproval: ApprovalRequest | null; + /** Full queue of pending approval requests (including the current one). */ + approvalQueue: ApprovalRequest[]; status: StatusUpdate | null; stepCount: number; isCompacting: boolean; @@ -40,8 +45,9 @@ export function useWire(options?: UseWireOptions): WireState & { } { const [messages, setMessages] = useState([]); const [isStreaming, setIsStreaming] = useState(false); - const [pendingApproval, setPendingApproval] = - useState(null); + // Approval queue — mirrors Python's deque[ApprovalRequest]. + // The first element is the one currently displayed to the user. + const [approvalQueue, setApprovalQueue] = useState([]); const [status, setStatus] = useState(null); const [stepCount, setStepCount] = useState(0); const [isCompacting, setIsCompacting] = useState(false); @@ -153,7 +159,8 @@ export function useWire(options?: UseWireOptions): WireState & { ) as ToolCallSegment | undefined; if (toolSeg) { toolSeg.result = event.result; - toolSeg.collapsed = true; + // Keep expanded if result has display blocks (e.g. "Rejected: feedback") + toolSeg.collapsed = event.result.display.length === 0; } setMessages((prev) => { const idx = prev.findIndex((m) => m.id === msg.id); @@ -164,12 +171,20 @@ export function useWire(options?: UseWireOptions): WireState & { } case "approval_request": { - setPendingApproval(event.request); + // Enqueue — mirrors Python's _queue_approval_request() + setApprovalQueue((prev) => { + // Deduplicate: don't enqueue if already present + if (prev.some((r) => r.id === event.request.id)) return prev; + return [...prev, event.request]; + }); break; } case "approval_response": { - setPendingApproval(null); + // Dequeue the resolved request — advances to the next one automatically + setApprovalQueue((prev) => + prev.filter((r) => r.id !== event.requestId), + ); break; } @@ -211,20 +226,93 @@ export function useWire(options?: UseWireOptions): WireState & { } case "slash_result": { - // Atomically insert a user+assistant message pair (for slash command feedback) - const userMsg: UIMessage = { - id: nanoid(), - role: "user", - segments: [{ type: "text", text: event.userInput }], - timestamp: Date.now(), - }; - const assistantMsg: UIMessage = { + // Insert a system message for slash command feedback (matches Python's wire_send(TextPart(...))) + const sysMsg: UIMessage = { id: nanoid(), - role: "assistant", + role: "system", segments: [{ type: "text", text: event.text }], timestamp: Date.now(), }; - setMessages((prev) => [...prev, userMsg, assistantMsg]); + setMessages((prev) => [...prev, sysMsg]); + break; + } + + case "subagent_event": { + if (!currentAssistantRef.current || !event.parentToolCallId) break; + const msg = currentAssistantRef.current; + const toolSeg = msg.segments.find( + (s) => + s.type === "tool_call" && + (s as ToolCallSegment).id === event.parentToolCallId, + ) as ToolCallSegment | undefined; + if (!toolSeg) break; + + // Store subagent metadata + if (event.agentId) toolSeg.subagentId = event.agentId; + if (event.subagentType) toolSeg.subagentType = event.subagentType; + + // Initialize tracking structures + if (!toolSeg.ongoingSubCalls) toolSeg.ongoingSubCalls = {}; + if (!toolSeg.finishedSubCalls) toolSeg.finishedSubCalls = []; + + // Dispatch nested event by type (wire envelope: {type, payload}) + const nested = event.event as Record; + const nestedType = nested?.type as string | undefined; + const nestedPayload = (nested?.payload ?? nested) as Record; + + if (nestedType === "ToolCall") { + // New subagent tool call + const p = nestedPayload as { id?: string; function?: { name?: string; arguments?: string } }; + const callId = (p.id ?? "") as string; + const fn = p.function; + if (callId) { + toolSeg.ongoingSubCalls[callId] = { + id: callId, + name: fn?.name ?? "unknown", + arguments: fn?.arguments ?? "", + }; + } + } else if (nestedType === "ToolResult") { + // Completed subagent tool call + const p = nestedPayload as { tool_call_id?: string; return_value?: { isError?: boolean; is_error?: boolean } }; + const callId = (p.tool_call_id ?? "") as string; + const ongoing = callId ? toolSeg.ongoingSubCalls[callId] : undefined; + if (ongoing) { + delete toolSeg.ongoingSubCalls[callId]; + const isError = p.return_value?.isError ?? p.return_value?.is_error ?? false; + // Deque behavior: keep last MAX items, track overflow + if (toolSeg.finishedSubCalls.length >= MAX_SUBAGENT_TOOL_CALLS_TO_SHOW) { + toolSeg.finishedSubCalls.shift(); + toolSeg.nExtraSubCalls = (toolSeg.nExtraSubCalls ?? 0) + 1; + } + let keyArg = ""; + try { + keyArg = extractKeyArgument(ongoing.arguments, ongoing.name) ?? ""; + } catch { /* ignore parse errors */ } + toolSeg.finishedSubCalls.push({ + callId: ongoing.id, + toolName: ongoing.name, + arguments: keyArg, + isError, + }); + } + } + // ToolCallPart: update ongoing call arguments + else if (nestedType === "ToolCallPart") { + const p = nestedPayload as { id?: string; arguments_part?: string }; + const callId = (p.id ?? "") as string; + const ongoing = callId ? toolSeg.ongoingSubCalls[callId] : undefined; + if (ongoing && p.arguments_part) { + ongoing.arguments += p.arguments_part; + } + } + + // Trigger re-render + setMessages((prev) => { + const idx = prev.findIndex((m) => m.id === msg.id); + if (idx === -1) return prev; + return [...prev.slice(0, idx), { ...msg }, ...prev.slice(idx + 1)]; + }); break; } @@ -250,7 +338,7 @@ export function useWire(options?: UseWireOptions): WireState & { setMessages([]); currentAssistantRef.current = null; setIsStreaming(false); - setPendingApproval(null); + setApprovalQueue([]); setStepCount(0); }, []); @@ -264,10 +352,14 @@ export function useWire(options?: UseWireOptions): WireState & { onReady?.(pushEvent); }, [pushEvent, onReady]); + // The head of the queue is the approval currently shown to the user + const pendingApproval = approvalQueue.length > 0 ? approvalQueue[0]! : null; + return { messages, isStreaming, pendingApproval, + approvalQueue, status, stepCount, isCompacting, diff --git a/src/kimi_cli_ts/ui/renderer/index.ts b/src/kimi_cli_ts/ui/renderer/index.ts index dc68fd800..1fb3cf09f 100644 --- a/src/kimi_cli_ts/ui/renderer/index.ts +++ b/src/kimi_cli_ts/ui/renderer/index.ts @@ -1,17 +1,16 @@ /** * Optimized Ink renderer. * - * Two layers of protection for text selection: + * Protection layers for text selection: * - * 1. Shell removes minHeight={termHeight} so Ink uses incremental diff - * (eraseLines + overwrite changed lines) instead of clearTerminal. + * 1. Shell.tsx avoids fixed height so Ink uses incremental diff + * (eraseLines + overwrite) instead of clearTerminal. * Static messages stay in scrollback untouched. * * 2. As a safety net, if \x1b[2J (erase screen) still appears in output * (e.g., resize, edge cases), strip it to prevent selection destruction. * - * 3. On terminals supporting DEC 2026, buffer BSU/ESU sequences into - * a single atomic stdout.write() call. + * 3. On DEC 2026 terminals, buffer BSU/ESU into single atomic write. * * Usage: * import { patchInkLogUpdate } from '../ui/renderer'; @@ -70,8 +69,8 @@ function analyzeAnsi(s: string): string { const f: string[] = []; if (s.includes("\x1b[2J")) f.push("ERASE_SCREEN!"); if (s.includes("\x1b[3J")) f.push("ERASE_SCROLLBACK!"); - if (s.includes("\x1b[J")) f.push("ERASE_BELOW"); - if (s.includes("\x1b[H")) f.push("HOME"); + if (s.includes("\x1b[J")) f.push("ERASE_BELOW"); + if (s.includes("\x1b[H")) f.push("HOME"); if (s.includes("\x1b[?2026h")) f.push("BSU"); if (s.includes("\x1b[?2026l")) f.push("ESU"); if (s.includes("\x1b[?25l")) f.push("HIDE"); @@ -97,39 +96,16 @@ const ERASE_EOL = "\x1b[K"; const ERASE_BELOW = "\x1b[J"; const CURSOR_HOME = "\x1b[H"; -/** - * When we strip clearTerminal, Ink's log.sync() sets previousLineCount to the - * current content height. On the next frame with smaller content, eraseLines() - * won't erase enough lines. We track the max height we've rendered and emit - * extra ERASE_BELOW when content shrinks. - */ -let maxRenderedLines = 0; - /** * Rewrite a clearTerminal frame into CUP-positioned lines. - * - * When Ink hits the clearTerminal path, it emits: - * \x1b[2J \x1b[3J \x1b[H fullStaticOutput + output - * - * fullStaticOutput = completed messages (already in scrollback via ) - * output = dynamic part (streaming msg, spinner, prompt, statusbar) - * - * We rewrite this as CUP-positioned lines showing only the LAST `rows` lines - * (the dynamic viewport). The static history is already in scrollback from - * earlier writes — no need to re-emit it. - * - * This means: no \x1b[2J (no erase), no \x1b[3J (scrollback preserved), - * no \n (no scroll pollution). Just CUP overwrite of the visible viewport. */ function rewriteClearFrame(s: string, termRows: number): string { - // Strip all destructive/positioning sequences let body = s; body = body.replaceAll(ERASE_SCREEN, ""); body = body.replaceAll(ERASE_SCROLLBACK, ""); body = body.replaceAll(CURSOR_HOME, ""); const lines = body.split("\n"); - // Remove trailing empty line from split if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } @@ -137,8 +113,6 @@ function rewriteClearFrame(s: string, termRows: number): string { const totalLines = lines.length; const rows = termRows || 24; - // Show the LAST `rows` lines — this keeps statusbar/input visible - // and drops the static history (which is already in scrollback) const startLine = totalLines > rows ? totalLines - rows : 0; const visibleCount = Math.min(totalLines - startLine, rows); @@ -148,7 +122,6 @@ function rewriteClearFrame(s: string, termRows: number): string { parts.push(lines[startLine + i]!); parts.push(ERASE_EOL); } - // Clear any lines below content (in case previous frame was taller) if (visibleCount < rows) { parts.push(ERASE_BELOW); } @@ -168,6 +141,12 @@ function installWrapper(stream: NodeJS.WriteStream): void { return; } + if ((stream as any).__rendererWrapped) { + log("wrapper: skip (already wrapped)"); + return; + } + (stream as any).__rendererWrapped = true; + const originalWrite = stream.write.bind(stream) as typeof stream.write; let buffer: string[] = []; let inSyncBlock = false; @@ -190,6 +169,13 @@ function installWrapper(stream: NodeJS.WriteStream): void { const wn = writeCounter; const now = Date.now(); + // Track frame height: count newlines in every Ink write to stdout. + // This gives us the exact line count of the dynamic viewport. + const nlCount = (str.match(/\n/g) || []).length; + if (nlCount > 0) { + (stream as any).__lastFrameHeight = nlCount; + } + // ── BSU/ESU buffering (only when DEC 2026 is supported) ── if (SYNC_SUPPORTED) { @@ -208,10 +194,8 @@ function installWrapper(stream: NodeJS.WriteStream): void { inSyncBlock = false; frameCounter++; - // Safety: strip erase-screen if present const hadClear = hasEraseScreen(merged); if (hadClear) { - // Unwrap BSU/ESU, rewrite as CUP, re-wrap let inner = merged; const hadBSU = inner.startsWith(BSU); const hadESU = inner.endsWith(ESU); @@ -219,24 +203,6 @@ function installWrapper(stream: NodeJS.WriteStream): void { if (hadESU) inner = inner.slice(0, -ESU.length); inner = rewriteClearFrame(inner, stream.rows || 24); merged = (hadBSU ? BSU : "") + inner + (hadESU ? ESU : ""); - // Update maxRenderedLines - const lines = inner.split("\n").length; - maxRenderedLines = Math.min(lines, stream.rows || 24); - } else { - // Check for content shrinking and add ERASE_BELOW if needed - const nlCount = (merged.match(/\n/g) || []).length; - if (maxRenderedLines > 0 && nlCount + 1 < maxRenderedLines) { - // Unwrap ESU, add ERASE_BELOW, re-add ESU - const hasTrailingESU = merged.endsWith(ESU); - if (hasTrailingESU) { - merged = merged.slice(0, -ESU.length) + ERASE_BELOW + ESU; - } else { - merged = merged + ERASE_BELOW; - } - log(`FRAME#${frameCounter}: SHRINK ${nlCount + 1}<${maxRenderedLines} +ERASE_BELOW`); - } else if (nlCount + 1 > maxRenderedLines) { - maxRenderedLines = nlCount + 1; - } } const gap = lastFlushTime ? now - lastFlushTime : 0; @@ -268,32 +234,9 @@ function installWrapper(stream: NodeJS.WriteStream): void { const hadClear = hasEraseScreen(output); if (hadClear) { output = rewriteClearFrame(output, stream.rows || 24); - // rewriteClearFrame already includes ERASE_BELOW, and positions content - // at top of screen. Update maxRenderedLines to the visible count. - const lines = str.split("\n").length; - maxRenderedLines = Math.min(lines, stream.rows || 24); - log(`NOSYNC w#${wn}: STRIP! ${str.length}b→${output.length}b maxLines=${maxRenderedLines} | ${analyzeAnsi(output)}`); - } else { - // Count newlines in this frame - const nlCount = (str.match(/\n/g) || []).length; - - // Check if content shrank below our max rendered height - // If so, we need to erase the orphaned lines at the bottom - if (maxRenderedLines > 0 && nlCount + 1 < maxRenderedLines) { - // After Ink writes, cursor is at bottom of new content. - // Add ERASE_BELOW to clear any orphaned lines below it. - output = str + ERASE_BELOW; - log(`NOSYNC w#${wn}: SHRINK ${nlCount + 1}<${maxRenderedLines} +ERASE_BELOW | ${analyzeAnsi(output)}`); - // Keep maxRenderedLines at current level until next clearTerminal - } else { - // Update max if we rendered more lines - if (nlCount + 1 > maxRenderedLines) { - maxRenderedLines = nlCount + 1; - } - if (wn <= 20 || wn % 200 === 0) { - log(`NOSYNC w#${wn}: ${str.length}b maxLines=${maxRenderedLines} | ${analyzeAnsi(str)}`); - } - } + log(`NOSYNC w#${wn}: STRIP! ${str.length}b→${output.length}b | ${analyzeAnsi(output)}`); + } else if (wn <= 20 || wn % 200 === 0) { + log(`NOSYNC w#${wn}: ${str.length}b | ${analyzeAnsi(str)}`); } if (output !== str) { @@ -306,16 +249,30 @@ function installWrapper(stream: NodeJS.WriteStream): void { log("wrapper: installed"); } +/** + * Get the line count of the last Ink render frame. + * Used by /clear to know how many residual lines to erase after Ink unmount. + */ +export function getLastFrameHeight(): number { + return (process.stdout as any).__lastFrameHeight ?? 5; +} + // ── Public API ────────────────────────────────────────── +let installed = false; + export function patchInkLogUpdate(): void { + if (installed || (process.stdout as any).__rendererPatched) return; + installed = true; + (process.stdout as any).__rendererPatched = true; + initLog(); log("patch: starting"); installWrapper(process.stdout); log(`patch: done — log at ${LOG_FILE}`); - process.stderr.write(`[renderer] patched, log: ${LOG_FILE}\n`); + // process.stderr.write(`[renderer] patched, log: ${LOG_FILE}\n`); } // ── Re-exports ────────────────────────────────────────── diff --git a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx index 83604e92b..61dc6aafc 100644 --- a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx @@ -1,17 +1,15 @@ /** - * ApprovalPanel.tsx — Full approval request panel with React Ink. + * ApprovalPanel.tsx — Approval request panel built on SelectionPanel. * Corresponds to Python's ui/shell/approval_panel.py. * - * Features: - * - 4 options: approve once, approve for session, reject, reject with feedback - * - Content preview with line-budget truncation (diff, shell, brief) - * - Inline feedback input with draft persistence - * - Keyboard navigation (↑↓ or 1-4 number keys) + * This is a thin wrapper that configures SelectionPanel with the 4 approval + * options and renders the request header / content preview above them. */ -import React, { useState, useCallback, useRef } from "react"; +import React, { useRef } from "react"; import { Box, Text } from "ink"; -import { useInputLayer } from "./input-stack.ts"; +import { SelectionPanel } from "./SelectionPanel.tsx"; +import type { SelectionOption } from "./SelectionPanel.tsx"; import type { ApprovalRequest, ApprovalResponseKind, @@ -22,18 +20,19 @@ import type { } from "../../wire/types"; const MAX_PREVIEW_LINES = 4; -const FEEDBACK_OPTION_INDEX = 3; -interface ApprovalOption { - label: string; - response: ApprovalResponseKind; -} +const APPROVAL_OPTIONS: SelectionOption[] = [ + { label: "Approve once" }, + { label: "Approve for this session" }, + { label: "Reject" }, + { label: "Reject, tell the model what to do instead", inputMode: true, inputPrefix: "Reject: " }, +]; -const OPTIONS: ApprovalOption[] = [ - { label: "Approve once", response: "approve" }, - { label: "Approve for this session", response: "approve_for_session" }, - { label: "Reject", response: "reject" }, - { label: "Reject, tell the model what to do instead", response: "reject" }, +const RESPONSE_MAP: ApprovalResponseKind[] = [ + "approve", + "approve_for_session", + "reject", + "reject", ]; // ── DiffPreview ────────────────────────────────────────── @@ -44,7 +43,6 @@ function DiffPreview({ blocks }: { blocks: DisplayBlock[] }) { ); if (diffBlocks.length === 0) return null; - // Group by path const byPath = new Map(); for (const block of diffBlocks) { const existing = byPath.get(block.path) || []; @@ -100,34 +98,23 @@ function ContentPreview({ for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; - if (budget <= 0) { - truncated = true; - break; - } - + if (budget <= 0) { truncated = true; break; } if (!block) continue; + if (block.type === "shell") { const shellBlock = block as ShellDisplayBlock; const lines = shellBlock.command.trim().split("\n"); const showLines = lines.slice(0, budget); if (lines.length > budget) truncated = true; budget -= showLines.length; - elements.push( - - {showLines.join("\n")} - , - ); + elements.push({showLines.join("\n")}); } else if (block.type === "brief") { const briefBlock = block as BriefDisplayBlock; const lines = briefBlock.brief.trim().split("\n"); const showLines = lines.slice(0, budget); if (lines.length > budget) truncated = true; budget -= showLines.length; - elements.push( - - {showLines.join("\n")} - , - ); + elements.push({showLines.join("\n")}); } } @@ -136,11 +123,7 @@ function ContentPreview({ return ( {elements} - {truncated && ( - - ... (truncated, ctrl-e to expand) - - )} + {truncated && ... (truncated, ctrl-e to expand)} ); } @@ -149,146 +132,31 @@ function ContentPreview({ export interface ApprovalPanelProps { request: ApprovalRequest; - onRespond: ( - decision: ApprovalResponseKind, - feedback?: string, - ) => void; + onRespond: (decision: ApprovalResponseKind, feedback?: string) => void; } export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { - const [selectedIndex, setSelectedIndex] = useState(0); - const [feedbackMode, setFeedbackMode] = useState(false); - const [feedbackText, setFeedbackText] = useState(""); - // Feedback draft: persisted when navigating away from option 4 - const feedbackDraftRef = useRef(""); const nonDiffTruncatedRef = useRef(false); const hasDiff = request.display.some((b) => b.type === "diff"); + const hasNonDiffBlocks = request.display.some((b) => b.type === "shell" || b.type === "brief"); const hasExpandableContent = hasDiff || nonDiffTruncatedRef.current; - const submit = useCallback( - (index: number) => { - if (index === FEEDBACK_OPTION_INDEX) { - setFeedbackMode(true); - // Restore draft if available - if (feedbackDraftRef.current) { - setFeedbackText(feedbackDraftRef.current); - } - return; - } - feedbackDraftRef.current = ""; - onRespond(OPTIONS[index]!.response); - }, - [onRespond], - ); - - useInputLayer((input, key) => { - if (feedbackMode) { - if (key.return) { - // Only submit if non-empty (matches Python: empty enter does nothing) - if (feedbackText.trim()) { - feedbackDraftRef.current = ""; - onRespond("reject", feedbackText.trim()); - } - return; - } - if (key.escape) { - // Esc in feedback mode: reject with empty feedback - feedbackDraftRef.current = ""; - onRespond("reject", ""); - return; - } - if (key.backspace || key.delete) { - setFeedbackText((t) => t.slice(0, -1)); - return; - } - if (key.upArrow) { - // Save draft, navigate away - feedbackDraftRef.current = feedbackText; - setFeedbackMode(false); - setFeedbackText(""); - setSelectedIndex( - (i) => (i - 1 + OPTIONS.length) % OPTIONS.length, - ); - return; - } - if (key.downArrow) { - feedbackDraftRef.current = feedbackText; - setFeedbackMode(false); - setFeedbackText(""); - setSelectedIndex((i) => (i + 1) % OPTIONS.length); - return; - } - if (input && !key.ctrl && !key.meta) { - setFeedbackText((t) => t + input); - } - return; - } - - // Normal navigation - if (key.upArrow) { - setSelectedIndex((prev) => { - const next = (prev - 1 + OPTIONS.length) % OPTIONS.length; - if (next === FEEDBACK_OPTION_INDEX && feedbackDraftRef.current) { - setFeedbackMode(true); - setFeedbackText(feedbackDraftRef.current); - } - return next; - }); - } else if (key.downArrow) { - setSelectedIndex((prev) => { - const next = (prev + 1) % OPTIONS.length; - if (next === FEEDBACK_OPTION_INDEX && feedbackDraftRef.current) { - setFeedbackMode(true); - setFeedbackText(feedbackDraftRef.current); - } - return next; - }); - } else if (key.return) { - submit(selectedIndex); - } else if (key.escape) { - onRespond("reject"); - } else if (input >= "1" && input <= "4") { - const idx = parseInt(input) - 1; - if (idx < OPTIONS.length) { - setSelectedIndex(idx); - if (idx === FEEDBACK_OPTION_INDEX) { - setFeedbackMode(true); - if (feedbackDraftRef.current) { - setFeedbackText(feedbackDraftRef.current); - } - } else { - submit(idx); - } - } - } - }); - - // Check whether we have non-diff content blocks - const hasNonDiffBlocks = request.display.some( - (b) => b.type === "shell" || b.type === "brief", - ); - return ( - onRespond(RESPONSE_MAP[idx]!)} + onInputSubmit={(_idx, text) => onRespond("reject", text)} + onCancel={() => onRespond("reject")} borderColor="yellow" - paddingX={1} + title="⚠ ACTION REQUIRED" + extraHint={hasExpandableContent ? " ctrl-e expand" : ""} > - {/* Title — matches Python Panel(title="⚠ ACTION REQUIRED", border_style="bold yellow") */} - - ⚠ ACTION REQUIRED - - {" "} - - {/* Request header — matches Python render() content_lines */} + {/* Request header */} {request.sender} is requesting approval to {request.action}: - - {/* Source metadata — matches Python _render_source_metadata_lines() */} {(request.subagent_type || request.agent_id) && ( Subagent:{" "} @@ -302,70 +170,36 @@ export function ApprovalPanel({ request, onRespond }: ApprovalPanelProps) { )} - {" "} - - {/* Description (only if no display blocks) — matches Python line 74-83 */} + {/* Description (only if no display blocks) */} {request.description && !request.display.length && ( - - {truncateLines(request.description, MAX_PREVIEW_LINES)} - + <> + {" "} + + {truncateLines(request.description, MAX_PREVIEW_LINES)} + + )} {/* Diff preview */} {hasDiff && ( - - - + <> + {" "} + + + + )} - {/* Non-diff content preview (shell/brief) with line budget */} + {/* Non-diff content preview */} {hasNonDiffBlocks && ( - - - - )} - - {" "} - - {/* Options — matches Python render() menu section */} - {OPTIONS.map((option, i) => { - const num = i + 1; - const isSelected = i === selectedIndex; - const isFeedbackOption = i === FEEDBACK_OPTION_INDEX; - - // Feedback input line: → [4] Reject: {text}█ - if (isFeedbackOption && feedbackMode && isSelected) { - return ( - - → [{num}] Reject: {feedbackText}█ - - ); - } - - return ( - - {isSelected ? "→" : " "} [{num}] {option.label} - - ); - })} - - {" "} - - {/* Keyboard hints — matches Python render() hint lines */} - {feedbackMode ? ( - - {" "}Type your feedback, then press Enter to submit. - - ) : ( - - {" "}▲/▼ select{" "}1/2/3/4 choose{" "}↵ confirm - {hasExpandableContent ? " ctrl-e expand" : ""} - + <> + {" "} + + + + )} - + ); } diff --git a/src/kimi_cli_ts/ui/shell/DebugPanel.tsx b/src/kimi_cli_ts/ui/shell/DebugPanel.tsx index 459f35c09..8008e04db 100644 --- a/src/kimi_cli_ts/ui/shell/DebugPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/DebugPanel.tsx @@ -2,179 +2,340 @@ * DebugPanel.tsx — Context debug viewer. * Corresponds to Python's ui/shell/debug.py. * - * Features: - * - Display full context (all messages with role colors) - * - Token count - * - Checkpoint information + * Pixel-perfect style match with Python version: + * - Full Context Info panel with cyan border + * - Horizontal rule separator + * - Message panels with role-color borders + * - Nested thinking/tool-call panels + * + * Uses KMessage/KContentPart types from context-types.ts for full + * type safety across both Python (kosong) and TS (Anthropic) formats. */ import React from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useStdout } from "ink"; +import { usePanelKeyboard } from "../hooks/usePanelKeyboard.ts"; +import type { + KContextInfo, + KMessage, + KContentPart, + KTextPart, + KThinkPart, + KImageURLPart, + KImagePart, + KAudioURLPart, + KVideoURLPart, + KToolUsePart, + KToolResultPart, + KToolCall, +} from "./context-types.ts"; -// ── Types ─────────────────────────────────────────────── +// Re-export for external use +export type { KContextInfo as ContextInfo } from "./context-types.ts"; -export interface ContextInfo { - totalMessages: number; - tokenCount: number; - checkpoints: number; - trajectory?: string; -} - -export interface DebugMessage { - role: string; - content: string; - name?: string; - toolCallId?: string; - toolCalls?: Array<{ - id: string; - name: string; - arguments: string; - }>; - partial?: boolean; -} +// ── Props ──────────────────────────────────────────────── export interface DebugPanelProps { - context: ContextInfo; - messages: DebugMessage[]; + context: KContextInfo; + messages: KMessage[]; + onClose?: () => void; } -// ── Role colors ───────────────────────────────────────── +// ── Color constants ────────────────────────────────────── const ROLE_COLORS: Record = { - system: "magenta", - developer: "magenta", - user: "green", - assistant: "blue", - tool: "yellow", + system: "#d787d7", // magenta + developer: "#d787d7", // magenta + user: "#5fff5f", // green + assistant: "#5fafff", // blue + tool: "#ffff5f", // yellow }; +const DIM_COLOR = "#888888"; +const BORDER_COLOR = "#555555"; + function getRoleColor(role: string): string { return ROLE_COLORS[role] || "white"; } -// ── ContentPart formatting ────────────────────────────── +// ── Panel box ──────────────────────────────────────────── -function formatContent(content: string): React.ReactNode { - const trimmed = content.trim(); - if (trimmed.startsWith("") && trimmed.endsWith("")) { - const inner = trimmed.slice(8, -9).trim(); +function PanelBox({ title, color = BORDER_COLOR, children }: { + title?: string; + color?: string; + children: React.ReactNode; +}) { + const { stdout } = useStdout(); + const width = stdout?.columns ?? 80; + const contentWidth = width - 4; // 2 padding + 2 border chars + + const titleStr = title ? ` ${title} ` : ""; + const titleLen = titleStr.length; + const leftDashes = Math.max(0, Math.floor((contentWidth - titleLen) / 2)); + const rightDashes = contentWidth - titleLen - leftDashes; + + return ( + + {`╭${"─".repeat(leftDashes)}${titleStr}${"─".repeat(rightDashes)}╮`} + + {children} + + {`╰${"─".repeat(contentWidth)}╯`} + + ); +} + +// ── Content part rendering ─────────────────────────────── + +function renderTextPart(part: KTextPart): React.ReactNode { + const text = part.text; + if (text.trim().startsWith("") && text.trim().endsWith("")) { + const inner = text.trim().slice(8, -9).trim(); return ( - - system + {inner} - + ); } - return {content}; + return {text}; +} + +function renderThinkPart(part: KThinkPart): React.ReactNode { + return ( + + {part.think} + + ); } -// ── ToolCall formatting ───────────────────────────────── +function renderImageURLPart(part: KImageURLPart): React.ReactNode { + const url = part.image_url.url; + const display = url.length > 80 ? url.slice(0, 80) + "..." : url; + return [Image] {display}; +} -function ToolCallDebugView({ - toolCall, -}: { - toolCall: { id: string; name: string; arguments: string }; -}) { - let argsFormatted: string; - try { - argsFormatted = JSON.stringify(JSON.parse(toolCall.arguments), null, 2); - } catch { - argsFormatted = toolCall.arguments; +function renderImagePart(part: KImagePart): React.ReactNode { + const data = part.source.data; + const display = data.length > 80 ? data.slice(0, 80) + "..." : data; + return [Image] {display}; +} + +function renderAudioURLPart(part: KAudioURLPart): React.ReactNode { + const url = part.audio_url.url; + const idText = part.audio_url.id ? ` (id: ${part.audio_url.id})` : ""; + const display = url.length > 80 ? url.slice(0, 80) + "..." : url; + return [Audio{idText}] {display}; +} + +function renderVideoURLPart(part: KVideoURLPart): React.ReactNode { + const url = part.video_url.url; + const display = url.length > 80 ? url.slice(0, 80) + "..." : url; + return [Video] {display}; +} + +function renderToolUsePart(part: KToolUsePart): React.ReactNode { + let argsStr: string; + if (typeof part.input === "object" && part.input !== null) { + argsStr = JSON.stringify(part.input, null, 2); + } else { + argsStr = "{}"; } + return ( + + Function: {part.name} + Call ID: {part.id} + Arguments: + {argsStr} + + ); +} + +function renderToolResultPart(part: KToolResultPart): React.ReactNode { + const content = part.content; + const trimmed = content.trim(); + if (trimmed.startsWith("")) { + const systemEnd = trimmed.indexOf(""); + if (systemEnd !== -1) { + const systemText = trimmed.slice(8, systemEnd).trim(); + const remainder = trimmed.slice(systemEnd + 9).trim(); + return ( + + + {systemText} + + {remainder ? {remainder} : null} + + ); + } + } + return {content}; +} + +function renderContentPart(part: KContentPart): React.ReactNode { + switch (part.type) { + case "text": return renderTextPart(part); + case "think": return renderThinkPart(part); + case "image_url": return renderImageURLPart(part); + case "image": return renderImagePart(part); + case "audio_url": return renderAudioURLPart(part); + case "video_url": return renderVideoURLPart(part); + case "tool_use": return renderToolUsePart(part); + case "tool_result": return renderToolResultPart(part); + default: + return [Unknown: {(part as any).type}]; + } +} + +// ── Tool call rendering (Python format) ────────────────── + +function renderToolCall(tc: KToolCall): React.ReactNode { + let argsStr = tc.function.arguments || "{}"; + try { + argsStr = JSON.stringify(JSON.parse(argsStr), null, 2); + } catch { /* keep raw */ } return ( - - Tool Call - Function: {toolCall.name} - Call ID: {toolCall.id} + + Function: {tc.function.name} + Call ID: {tc.id} Arguments: - {argsFormatted} - + {argsStr} + ); } -// ── Message formatting ────────────────────────────────── +// ── Message view ───────────────────────────────────────── -function MessageDebugView({ - msg, - index, -}: { - msg: DebugMessage; - index: number; -}) { +function MessageView({ msg, index }: { msg: KMessage; index: number }) { const roleColor = getRoleColor(msg.role); - let title = `#${index + 1} ${msg.role.toUpperCase()}`; - if (msg.name) title += ` (${msg.name})`; - if (msg.toolCallId) title += ` → ${msg.toolCallId}`; - if (msg.partial) title += " (partial)"; + let titleStr = `#${index + 1} ${msg.role.toUpperCase()}`; + + if (msg.name) { + titleStr += ` (${msg.name})`; + } + + if (msg.tool_call_id) { + titleStr += ` → ${msg.tool_call_id}`; + } + + // TS tool messages: extract toolUseId from first tool_result content part + if (msg.role === "tool" && !msg.tool_call_id && Array.isArray(msg.content)) { + const firstResult = msg.content.find((p): p is KToolResultPart => p.type === "tool_result"); + if (firstResult) { + titleStr += ` → ${firstResult.toolUseId}`; + } + } + + if (msg.partial) { + titleStr += " (partial)"; + } + + const parts: React.ReactNode[] = []; + + // 1. reasoning_content (TS-specific thinking field) + if (msg.reasoning_content) { + parts.push( + + + {msg.reasoning_content} + + + ); + } + + // 2. Content (string or array) + if (typeof msg.content === "string") { + parts.push({msg.content}); + } else if (Array.isArray(msg.content)) { + msg.content.forEach((part, i) => { + parts.push( + + {renderContentPart(part)} + + ); + }); + } + + // 3. tool_calls (Python/kosong format: separate array) + if (msg.tool_calls && msg.tool_calls.length > 0) { + if (parts.length > 0) { + parts.push({""}); + } + msg.tool_calls.forEach((tc, i) => { + parts.push( + + {renderToolCall(tc)} + + ); + }); + } + + // 4. Empty message fallback + if (parts.length === 0) { + parts.push( + [empty message] + ); + } return ( - - {title} - {msg.content ? ( - formatContent(msg.content) - ) : ( - [empty message] - )} - {msg.toolCalls?.map((tc) => ( - - ))} + + + {parts.length === 1 ? parts[0] : {parts}} + ); } -// ── DebugPanel ────────────────────────────────────────── +// ── DebugPanel ─────────────────────────────────────────── + +export function DebugPanel({ context, messages, onClose }: DebugPanelProps) { + const { stdout } = useStdout(); + const width = stdout?.columns ?? 80; + + usePanelKeyboard({ + onEscape: () => onClose?.(), + }); + + const escHint = ( + <> + {"─".repeat(width - 2)} + + Esc close + + + ); -export function DebugPanel({ context, messages }: DebugPanelProps) { if (messages.length === 0) { return ( - - Context is empty - no messages yet + + {escHint} + + Context is empty - no messages yet + ); } return ( + {escHint} + {/* Context info */} - - Context Info + Total messages: {context.totalMessages} Token count: {context.tokenCount.toLocaleString()} Checkpoints: {context.checkpoints} {context.trajectory && ( - Trajectory: {context.trajectory} + Trajectory: {context.trajectory} )} - + {/* Separator */} - {"─".repeat(60)} + {"─".repeat(width - 2)} - {/* All messages */} + {/* Messages */} {messages.map((msg, idx) => ( - + ))} ); diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 639edc21f..9c107ed54 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -322,7 +322,7 @@ export function Prompt({ } // ── Enter → submit ── - if (key.return) { + if (key.return || key.enter) { doSubmit(); return; } diff --git a/src/kimi_cli_ts/ui/shell/PromptView.tsx b/src/kimi_cli_ts/ui/shell/PromptView.tsx index a15ba3b89..71b85b53a 100644 --- a/src/kimi_cli_ts/ui/shell/PromptView.tsx +++ b/src/kimi_cli_ts/ui/shell/PromptView.tsx @@ -44,10 +44,13 @@ export function PromptView({ return ( + {/* Separator line above prompt — matches Python's palette 240 = #585858 */} + {"─".repeat(columns)} + {/* Panel input title */} {panelTitle && ( - + {panelTitle} (Enter submit, Esc cancel) diff --git a/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx index e517fdab9..b2869dbde 100644 --- a/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx @@ -191,7 +191,7 @@ export function QuestionPanel({ useInput((input, key) => { // Other text input mode if (otherMode) { - if (key.return) { + if (key.return || key.enter) { submitOther(otherText.trim()); return; } @@ -241,7 +241,7 @@ export function QuestionPanel({ } return next; }); - } else if (key.return) { + } else if (key.return || key.enter) { submitCurrent(); } else if (key.escape) { onCancel(); diff --git a/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx b/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx new file mode 100644 index 000000000..7753d44a4 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx @@ -0,0 +1,237 @@ +/** + * SelectionPanel.tsx — Reusable selection panel with optional inline text input. + * + * A bordered panel that displays a list of numbered options with keyboard + * navigation. One or more options can be marked as `inputMode`, which turns + * them into inline text fields when selected. + * + * Features: + * - ↑/↓ circular navigation with number-key shortcuts (1-N) + * - Inline text input for options with `inputMode: true` + * - Draft persistence when navigating away from input options + * - Captures ALL keyboard input via useInputLayer (nothing leaks) + * + * Used by ApprovalPanel and any future panel that needs option selection. + */ + +import React, { useState, useRef } from "react"; +import { Box, Text } from "ink"; +import { useInputLayer } from "./input-stack.ts"; + +// ── Types ──────────────────────────────────────────────── + +export interface SelectionOption { + label: string; + /** If true, selecting this option enters inline text input mode. */ + inputMode?: boolean; + /** Prefix shown before the input cursor (e.g. "Reject: "). Defaults to label + ": ". */ + inputPrefix?: string; +} + +export interface SelectionPanelProps { + /** Options to display. */ + options: SelectionOption[]; + /** Called when user confirms a non-input option (Enter or number key). */ + onSelect: (index: number) => void; + /** Called when user submits text from an inputMode option. */ + onInputSubmit?: (index: number, text: string) => void; + /** Called on Escape. */ + onCancel?: () => void; + /** Content rendered above the options (children slot). */ + children?: React.ReactNode; + /** Border color. Default: "yellow". */ + borderColor?: string; + /** Title text shown at top of panel. */ + title?: string; + /** Title color. Default: same as borderColor. */ + titleColor?: string; + /** Extra hint text appended after the standard keyboard hints. */ + extraHint?: string; +} + +// ── Component ──────────────────────────────────────────── + +export function SelectionPanel({ + options, + onSelect, + onInputSubmit, + onCancel, + children, + borderColor = "yellow", + title, + titleColor, + extraHint, +}: SelectionPanelProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [inputText, setInputText] = useState(""); + const inputDraftRef = useRef(""); + + const isInputActive = !!options[selectedIndex]?.inputMode; + const optCount = options.length; + + useInputLayer((input, key) => { + if (isInputActive) { + // ── INPUT MODE ── + if (key.return || key.enter) { + const text = inputText.trim(); + if (text) { + setInputText(""); + inputDraftRef.current = ""; + onInputSubmit?.(selectedIndex, text); + } + // Empty enter: keep editing (matches Python) + return; + } + + if (key.escape) { + setInputText(""); + inputDraftRef.current = ""; + onCancel?.(); + return; + } + + if (key.upArrow) { + inputDraftRef.current = inputText; + setInputText(""); + setSelectedIndex((i) => (i - 1 + optCount) % optCount); + return; + } + + if (key.downArrow) { + inputDraftRef.current = inputText; + setInputText(""); + setSelectedIndex((i) => (i + 1) % optCount); + return; + } + + if (key.backspace || key.delete) { + setInputText((t) => t.slice(0, -1)); + return; + } + + if (input && !key.ctrl && !key.meta) { + setInputText((t) => t + input); + return; + } + + return; // Consume everything in input mode + } + + // ── SELECTION MODE ── + if (key.upArrow) { + setSelectedIndex((prev) => { + const next = (prev - 1 + optCount) % optCount; + if (options[next]?.inputMode && inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + return next; + }); + return; + } + + if (key.downArrow) { + setSelectedIndex((prev) => { + const next = (prev + 1) % optCount; + if (options[next]?.inputMode && inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + return next; + }); + return; + } + + if (key.return || key.enter) { + inputDraftRef.current = ""; + onSelect(selectedIndex); + return; + } + + if (key.escape) { + onCancel?.(); + return; + } + + // Number keys 1-9 (up to option count) + if (input >= "1" && input <= "9") { + const idx = parseInt(input) - 1; + if (idx < optCount) { + setSelectedIndex(idx); + if (options[idx]?.inputMode) { + // Enter input mode; restore draft if available + if (inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + } else { + inputDraftRef.current = ""; + onSelect(idx); + } + } + return; + } + + // Consume all other keys + }); + + const effectiveTitleColor = titleColor ?? borderColor; + + return ( + + {/* Title */} + {title && ( + <> + + {title} + + {" "} + + )} + + {/* Content slot */} + {children} + + {children && {" "}} + + {/* Options */} + {options.map((option, i) => { + const num = i + 1; + const isSelected = i === selectedIndex; + + // Input mode rendering + if (option.inputMode && isInputActive && isSelected) { + const prefix = option.inputPrefix ?? `${option.label}: `; + return ( + + → [{num}] {prefix}{inputText}█ + + ); + } + + return ( + + {isSelected ? "→" : " "} [{num}] {option.label} + + ); + })} + + {" "} + + {/* Keyboard hints */} + {isInputActive ? ( + + {" "}Type your feedback, then press Enter to submit. + + ) : ( + + {" "}▲/▼ select{" "} + {optCount <= 9 ? `1/${optCount}` : "1-9"} choose{" "}↵ confirm + {extraHint ?? ""} + + )} + + ); +} diff --git a/src/kimi_cli_ts/ui/shell/SetupWizard.tsx b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx index d17aa2618..dbb43ba41 100644 --- a/src/kimi_cli_ts/ui/shell/SetupWizard.tsx +++ b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx @@ -113,7 +113,7 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + platforms.length) % platforms.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % platforms.length); - } else if (key.return) { + } else if (key.return || key.enter) { const platform = platforms[selectedIndex]!; setSelectedPlatform(platform); setStep("api_key"); @@ -138,7 +138,7 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + models.length) % models.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % models.length); - } else if (key.return) { + } else if (key.return || key.enter) { const model = models[selectedIndex]!; const caps = model.capabilities || []; if (caps.includes("always_thinking")) { @@ -160,14 +160,14 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + choices.length) % choices.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % choices.length); - } else if (key.return) { + } else if (key.return || key.enter) { finishSetup(selectedModel!, selectedIndex === 0); } break; } case "error": { - if (key.return) { + if (key.return || key.enter) { setStep("api_key"); setApiKey(""); setError(""); diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 458751c7f..9461e556c 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -7,76 +7,33 @@ * - Renders layout: Static → streaming → PromptView → bottom slot */ -import React, { useCallback, useEffect, useState } from "react"; -import { Box, Static, useApp, useStdout } from "ink"; +import React, { useCallback, useEffect, useRef } from "react"; +import { Box, Static, useApp } from "ink"; import { MessageList, StaticMessageView } from "./Visualize.tsx"; import { PromptView } from "./PromptView.tsx"; import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; -import { ApprovalPrompt } from "../components/ApprovalPrompt.tsx"; +import { ApprovalPanel } from "./ApprovalPanel.tsx"; import { ChoicePanel, ContentPanel } from "../components/CommandPanel.tsx"; +import { DebugPanel } from "./DebugPanel.tsx"; import { SlashMenu } from "../components/SlashMenu.tsx"; import { MentionMenu } from "../components/MentionMenu.tsx"; +import { UsagePanel } from "./UsagePanel.tsx"; import { useGitStatus } from "../hooks/useGitStatus.ts"; +import { useUsagePanel } from "../hooks/useUsagePanel.ts"; import { StreamingSpinner, CompactionSpinner } from "../components/Spinner.tsx"; import { useWire } from "../hooks/useWire.ts"; +import { useReplayHistory } from "../hooks/useReplayHistory.ts"; import { useShellInput } from "./input-state.ts"; -import { - createShellSlashCommands, - parseSlashCommand, - findSlashCommand, -} from "./slash.ts"; +import { createShellSlashCommands } from "./slash.ts"; import { setActiveTheme } from "../theme.ts"; +import { createAllCommands } from "./shell-commands.ts"; +import { useShellCallbacks } from "./useShellCallbacks.ts"; +import { getPromptSymbol } from "./usePromptSymbol.ts"; +import { getLastFrameHeight } from "../renderer/index.ts"; +import { useShellLayout } from "./useShellLayout.ts"; import type { WireUIEvent } from "./events.ts"; import type { ApprovalResponseKind } from "../../wire/types.ts"; -import type { SlashCommand } from "../../types.ts"; - -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -async function runShellCommand( - command: string, - notify: (title: string, body: string) => void, -): Promise { - const trimmed = command.trim(); - if (!trimmed) return; - if (trimmed.split(/\s+/)[0] === "cd") { - notify("Shell", "Warning: Directory changes are not preserved."); - return; - } - try { - const proc = Bun.spawn(["sh", "-c", trimmed], { stdio: ["inherit", "inherit", "inherit"], env: process.env }); - await proc.exited; - } catch (err: any) { - notify("Shell", `Failed: ${err?.message ?? err}`); - } -} - -async function openExternalEditor( - notify: (title: string, body: string) => void, - onSubmit?: (input: string) => void, -): Promise { - const editor = process.env.VISUAL || process.env.EDITOR || "vim"; - const tmpFile = join(tmpdir(), `kimi-input-${Date.now()}.md`); - try { - await Bun.write(tmpFile, ""); - const proc = Bun.spawn(editor.split(/\s+/).concat(tmpFile), { stdio: ["inherit", "inherit", "inherit"] }); - if ((await proc.exited) !== 0) { notify("Editor", "Editor exited with error"); return; } - const content = (await Bun.file(tmpFile).text()).trim(); - if (content && onSubmit) onSubmit(content); - else if (!content) notify("Editor", "Empty input, nothing submitted."); - } catch (err: any) { - notify("Editor", `Failed: ${err?.message ?? err}`); - } finally { - try { require("node:fs").unlinkSync(tmpFile); } catch { /* ignore */ } - } -} - -function deduplicateCommands(commands: SlashCommand[]): SlashCommand[] { - const seen = new Map(); - for (const cmd of commands) if (!seen.has(cmd.name)) seen.set(cmd.name, cmd); - return [...seen.values()]; -} export interface ShellProps { modelName?: string; @@ -96,6 +53,8 @@ export interface ShellProps { extraSlashCommands?: SlashCommand[]; } +import type { SlashCommand } from "../../types.ts"; + export function Shell({ modelName = "", workDir, @@ -114,17 +73,70 @@ export function Shell({ extraSlashCommands = [], }: ShellProps) { const { exit } = useApp(); - const { stdout } = useStdout(); - const [termHeight, setTermHeight] = useState(stdout?.rows || 24); - const wire = useWire({ onReady: onWireReady }); const gitStatus = useGitStatus(); + // Load conversation history on session resume + const { turns: replayTurns } = useReplayHistory({ + sessionDir, + enabled: !!sessionDir, + }); + + // Replay historical turns into wire state so they persist as regular messages. + // Uses a ref to ensure we only replay once per mount. + const replayedRef = useRef(false); + useEffect(() => { + if (replayedRef.current || replayTurns.length === 0) return; + replayedRef.current = true; + + for (const turn of replayTurns) { + wire.pushEvent({ type: "turn_begin", userInput: turn.userInput }); + + for (const event of turn.events) { + switch (event.type) { + case "text": + wire.pushEvent({ type: "text_delta", text: event.text || "" }); + break; + case "think": + wire.pushEvent({ type: "think_delta", text: event.text || "" }); + break; + case "tool_call": + wire.pushEvent({ + type: "tool_call", + id: event.toolCallId || "", + name: event.toolName || "", + arguments: event.toolArgs || "", + }); + break; + case "tool_result": + wire.pushEvent({ + type: "tool_result", + toolCallId: event.toolCallId || "", + result: { + tool_call_id: event.toolCallId || "", + return_value: { + output: event.text || "", + isError: event.isError || false, + }, + display: (event as any).display ?? [], + }, + }); + break; + } + } + + wire.pushEvent({ type: "turn_end" }); + } + }, [replayTurns, wire]); + const pushNotification = useCallback( (title: string, body: string) => wire.pushEvent({ type: "notification", title, body }), [wire], ); + // ── Commands ── + const usagePanel = useUsagePanel(); + const shellCommands = createShellSlashCommands({ clearMessages: wire.clearMessages, exit: () => exit(), @@ -136,105 +148,51 @@ export function Shell({ return { sessionDir, workDir, title: sessionTitle ?? "Untitled" }; }, triggerReload: (newSessionId: string, prefill?: string) => onReload?.(newSessionId, prefill), + sessionId, + showUsage: async (config) => { + await usagePanel.show(config, config.default_model); + }, + soulClear: async () => { + // Find and call the soul-level /clear handler (context clear + status update) + const soulClearCmd = extraSlashCommands.find(c => c.name === "clear"); + if (soulClearCmd) { + await soulClearCmd.handler(""); + } + }, + getDynamicViewportHeight: () => getLastFrameHeight(), }); + const allCommands = createAllCommands(shellCommands, extraSlashCommands); - const allCommands = deduplicateCommands([...shellCommands, ...extraSlashCommands]); - - useEffect(() => { - const onResize = () => setTermHeight(stdout?.rows || 24); - stdout?.on("resize", onResize); - return () => { stdout?.off("resize", onResize); }; - }, [stdout]); - - // ── Input state machine (owns ALL keyboard handling) ── - const SHELL_MODE_COMMANDS = new Set(["clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?"]); + // ── Callbacks ── + const { inputCallbacks, handleApprovalResponse, setInputStateAccessor } = useShellCallbacks({ + wire, + allCommands, + shellCommands, + exit: () => exit(), + pushNotification, + onSubmitExternal: onSubmit, + onInterruptExternal: onInterrupt, + onPlanModeToggleExternal: onPlanModeToggle, + onApprovalResponseExternal: onApprovalResponse, + onReloadExternal: onReload, + }); + // ── Input state ── const inputState = useShellInput({ commands: allCommands, workDir, - onSubmit: useCallback( - (input: string) => { - const parsed = parseSlashCommand(input); - if (parsed) { - if (inputState.shellMode && !SHELL_MODE_COMMANDS.has(parsed.name)) { - wire.pushEvent({ type: "notification", title: "Shell mode", body: `/${parsed.name} is not available in shell mode.` }); - return; - } - const cmd = findSlashCommand(allCommands, parsed.name); - if (cmd) { - if (cmd.panel && !parsed.args) { - const pc = cmd.panel(); - if (pc) { inputState.openPanel(pc); return; } - } - const result = cmd.handler(parsed.args); - if (result && typeof result.then === "function") { - result.then((feedback: void | string) => { - if (typeof feedback === "string") { - wire.pushEvent({ type: "slash_result", userInput: input, text: feedback }); - } - }); - } - return; - } - wire.pushEvent({ type: "notification", title: "Unknown command", body: `/${parsed.name} is not recognized. Type /help.` }); - return; - } - if (inputState.shellMode) { runShellCommand(input, pushNotification); return; } - onSubmit?.(input); - }, - [allCommands, onSubmit, wire, pushNotification], - ), - onSlashExecute: useCallback((cmd: SlashCommand) => { - const result = cmd.handler(""); - if (result && typeof result.then === "function") { - result.then((feedback: void | string) => { - if (typeof feedback === "string") { - wire.pushEvent({ type: "slash_result", userInput: `/${cmd.name}`, text: feedback }); - } - }); - } - }, [wire]), - onExit: useCallback(() => exit(), [exit]), - onInterrupt: useCallback(() => { - if (wire.isStreaming) { - onInterrupt?.(); - wire.pushEvent({ type: "error", message: "Interrupted by user" }); - } - }, [wire, onInterrupt]), - onPlanModeToggle: useCallback(() => { - onPlanModeToggle?.() - .then((s) => pushNotification("Plan mode", s ? "ON" : "OFF")) - .catch((e: unknown) => pushNotification("Plan mode", `Error: ${String(e)}`)); - }, [onPlanModeToggle, pushNotification]), - onOpenEditor: useCallback(() => openExternalEditor(pushNotification, onSubmit), [pushNotification, onSubmit]), - onNotify: pushNotification, + ...inputCallbacks, }); - const handleApprovalResponse = useCallback( - (decision: ApprovalResponseKind, feedback?: string) => { - if (wire.pendingApproval) { - onApprovalResponse?.(wire.pendingApproval.id, decision, feedback); - wire.pushEvent({ type: "approval_response", requestId: wire.pendingApproval.id, response: decision }); - } - }, - [wire.pendingApproval, onApprovalResponse, wire], - ); + // Keep ref in sync so callbacks can access shellMode/openPanel. + useEffect(() => { + setInputStateAccessor({ shellMode: inputState.shellMode, openPanel: inputState.openPanel }); + }); - // ── Prompt symbol ── + // ── Layout ── + const { staticItems } = useShellLayout(wire.messages, wire.isStreaming); const mode = inputState.mode; - const promptSymbol = - mode.type === "panel_input" ? "▸ " - : inputState.shellMode ? "$ " - : wire.isStreaming ? "💫 " - : (wire.status?.plan_mode ?? false) ? "📋 " - : "✨ "; - - // ── Static items ── - const staticItems = React.useMemo(() => { - const welcome = { id: "__welcome__", _isWelcome: true as const }; - const msgs = wire.isStreaming ? wire.messages.slice(0, -1) : wire.messages; - return [welcome, ...msgs]; - }, [wire.isStreaming, wire.messages]); + const promptSymbol = getPromptSymbol(mode, inputState.shellMode, thinking, wire.status?.plan_mode ?? false); return ( @@ -255,7 +213,13 @@ export function Shell({ )} {wire.isStreaming && !wire.isCompacting && } - {wire.pendingApproval && } + {wire.pendingApproval && ( + + )} - {mode.type === "panel_choice" ? ( - + {usagePanel.visible ? ( + + ) : mode.type === "panel_choice" ? ( + ) : mode.type === "panel_content" ? ( - + + ) : mode.type === "panel_debug" ? ( + ) : inputState.showSlashMenu ? ( ) : inputState.showMentionMenu ? ( @@ -280,7 +254,7 @@ export function Shell({ modelName={modelName} workDir={workDir} status={wire.status} isStreaming={wire.isStreaming} stepCount={wire.stepCount} isCompacting={wire.isCompacting} planMode={wire.status?.plan_mode ?? false} - yolo={yolo} shellMode={inputState.shellMode} thinking={thinking} + yolo={wire.status?.yolo ?? yolo} shellMode={inputState.shellMode} thinking={thinking} gitBranch={gitStatus.branch} gitDirty={gitStatus.dirty} gitAhead={gitStatus.ahead} gitBehind={gitStatus.behind} toasts={wire.notifications} onDismissToast={wire.dismissNotification} diff --git a/src/kimi_cli_ts/ui/shell/UsagePanel.tsx b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx index f61b9c735..d59fc1437 100644 --- a/src/kimi_cli_ts/ui/shell/UsagePanel.tsx +++ b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx @@ -1,15 +1,20 @@ /** * UsagePanel.tsx — API usage and quota display panel. - * Corresponds to Python's ui/shell/usage.py. + * Matches Python's ui/shell/usage.py rendering character-for-character. * - * Features: - * - API quota usage display - * - Progress bars - * - Reset timer + * Python uses Rich Panel(expand=False, border_style="wheat4", padding=(0,2)) + * with ProgressBar(width=20, complete_style=color) using ━╺╸ characters. + * + * Uses PanelShell for borders and usePanelKeyboard for Esc dismiss. */ import React from "react"; -import { Box, Text } from "ink"; +import { Text } from "ink"; +import { PanelShell, PanelRow } from "../components/PanelShell.tsx"; +import { usePanelKeyboard } from "../hooks/usePanelKeyboard.ts"; + +const BAR_BG_COLOR = "#3a3a3a"; // Rich color 237 +const HINT_COLOR = "#808080"; // Rich grey50 / color 244 export interface UsageRow { label: string; @@ -23,9 +28,14 @@ export interface UsagePanelProps { limits: UsageRow[]; loading?: boolean; error?: string | null; + onClose?: () => void; } -function ProgressBar({ +/** + * Rich-style progress bar using ━╺╸ characters. + * Exactly matches Python's rich.progress_bar.ProgressBar.__rich_console__. + */ +function RichProgressBar({ completed, total, width = 20, @@ -34,17 +44,43 @@ function ProgressBar({ total: number; width?: number; }) { - const ratio = total > 0 ? Math.min(completed / total, 1) : 0; - const filledWidth = Math.round(ratio * width); - const emptyWidth = width - filledWidth; - const color = ratioColor(total > 0 ? (total - completed) / total : 0); + const remainingRatio = total > 0 ? (total - completed) / total : 0; + const color = ratioColor(remainingRatio); - return ( - - {"█".repeat(filledWidth)} - {"░".repeat(emptyWidth)} - - ); + if (completed <= 0) { + return {"\u2501".repeat(width)}; + } + if (completed >= total) { + return {"\u2501".repeat(width)}; + } + + const completeHalves = Math.floor(width * 2 * completed / total); + const barCount = Math.floor(completeHalves / 2); + const halfBarCount = completeHalves % 2; + + const parts: React.ReactNode[] = []; + + if (barCount > 0) { + parts.push({"\u2501".repeat(barCount)}); + } + + let remainingBars = width - barCount - halfBarCount; + + if (halfBarCount > 0) { + parts.push({"\u2578"}); + } + + if (remainingBars > 0) { + if (halfBarCount === 0 && barCount > 0) { + parts.push({"\u257A"}); + remainingBars -= 1; + } + if (remainingBars > 0) { + parts.push({"\u2501".repeat(remainingBars)}); + } + } + + return {parts}; } function ratioColor(ratio: number): string { @@ -53,81 +89,93 @@ function ratioColor(ratio: number): string { return "green"; } -function UsageRowView({ row, labelWidth }: { row: UsageRow; labelWidth: number }) { +function UsageRowView({ row, labelWidth, contentWidth }: { + row: UsageRow; + labelWidth: number; + contentWidth: number; +}) { const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; const percent = remaining * 100; + const pctText = ` ${percent.toFixed(0)}% left`; + const hintText = row.resetHint ? ` (${row.resetHint})` : ""; + const labelText = row.label.padEnd(labelWidth) + " "; + + const usedWidth = labelText.length + 20 + pctText.length + hintText.length; + const rightPad = Math.max(0, contentWidth - usedWidth); + return ( - - - {row.label.padEnd(labelWidth)} - - - - - - {` ${percent.toFixed(0)}% left`} - {row.resetHint && ( - {` (${row.resetHint})`} - )} - - + + {labelText} + + {pctText} + {hintText && {hintText}} + {rightPad > 0 && {" ".repeat(rightPad)}} + ); } -export function UsagePanel({ summary, limits, loading, error }: UsagePanelProps) { +export function UsagePanel({ summary, limits, loading, error, onClose }: UsagePanelProps) { + // Keyboard: only Esc to close + usePanelKeyboard({ + onEscape: () => onClose?.(), + }); + if (loading) { + const loadingText = "Fetching usage..."; return ( - - Fetching usage... - + + + {loadingText} + + ); } if (error) { return ( - - {error} - + + + {error} + + ); } const rows = [...(summary ? [summary] : []), ...limits]; if (rows.length === 0) { + const noData = "No usage data"; return ( - - No usage data - + + + {noData} + + ); } const labelWidth = Math.max(6, ...rows.map((r) => r.label.length)); + const contentWidth = Math.max(...rows.map((row) => { + const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; + const pctText = ` ${(remaining * 100).toFixed(0)}% left`; + const hintText = row.resetHint ? ` (${row.resetHint})` : ""; + const labelText = row.label.padEnd(labelWidth) + " "; + return labelText.length + 20 + pctText.length + hintText.length; + })); + return ( - - API Usage - {rows.map((row, idx) => ( - + + + ))} - + ); } diff --git a/src/kimi_cli_ts/ui/shell/Visualize.tsx b/src/kimi_cli_ts/ui/shell/Visualize.tsx index d16f23df6..4c166a6af 100644 --- a/src/kimi_cli_ts/ui/shell/Visualize.tsx +++ b/src/kimi_cli_ts/ui/shell/Visualize.tsx @@ -25,6 +25,7 @@ import type { ToolCallSegment, } from "./events.ts"; import type { ToolResult, DisplayBlock } from "../../wire/types.ts"; +import { extractKeyArgument } from "../../tools/types.ts"; // ── MessageList ──────────────────────────────────────────── @@ -95,6 +96,19 @@ function MessageView({ message, isLast, isStreaming, stepCount }: MessageViewPro ); } + // For system messages (slash command feedback), render with bullet prefix to match Python + if (message.role === "system") { + const sysText = message.segments + .filter((s): s is TextSegment => s.type === "text") + .map((s) => s.text) + .join(""); + return ( + + • {sysText} + + ); + } + // For assistant messages, no role label return ( @@ -180,8 +194,13 @@ export function StreamingText({ text, isStreaming }: StreamingTextProps) { return ( - {rendered} - {isStreaming && } + + + + {rendered} + {isStreaming && } + + ); } @@ -194,16 +213,11 @@ interface ThinkingViewProps { export function ThinkingView({ text }: ThinkingViewProps) { const colors = getMessageColors(); - // Truncate thinking to max 6 lines for preview - const lines = text.split("\n"); - const preview = lines.length > 6 - ? lines.slice(0, 6).join("\n") + `\n… ${lines.length - 6} more lines` - : text; - + // Render thinking text with bullet, matching Python's BulletColumns format return ( - + - 💭 {preview} + • {text} ); @@ -216,39 +230,121 @@ interface ToolCallViewProps { } export function ToolCallView({ toolCall }: ToolCallViewProps) { - const [collapsed, setCollapsed] = useState(toolCall.collapsed); + // Derive collapsed from prop — when result has display blocks, stay expanded + const collapsed = toolCall.collapsed; const colors = getMessageColors(); - const statusIcon = toolCall.result - ? toolCall.result.return_value.isError - ? "✗" - : "✓" - : "⟳"; - const statusColor = toolCall.result - ? toolCall.result.return_value.isError - ? colors.error - : colors.highlight - : colors.dim; // Format arguments for display — extract key argument let argsPreview = ""; try { - const parsed = JSON.parse(toolCall.arguments); - const key = extractKeyArgument(toolCall.name, parsed); + // extractKeyArgument takes (jsonString, toolName) and returns string | null + const key = extractKeyArgument(toolCall.arguments, toolCall.name); argsPreview = key || truncate(toolCall.arguments, 60); } catch { // Streaming JSON: show partial arguments argsPreview = renderStreamingJson(toolCall.arguments); } + // Python formatting: "Using ToolName (arg)" or "Used ToolName (arg)" + const isFinished = toolCall.result !== undefined; + const stateLabel = isFinished ? "Used " : "Using "; + const isError = isFinished && toolCall.result?.return_value.isError; + + // Bullet: ⟳ while pending, • when finished (matches Python) + // Color: green for success, dark_red for error/rejected, dim for pending + const bulletColor = isFinished + ? isError ? colors.darkRed : colors.highlight + : colors.dim; + const bullet = isFinished ? "•" : "⟳"; + + // Subagent data + const hasSubagent = !!(toolCall.subagentId && toolCall.subagentType); + const finishedSubs = toolCall.finishedSubCalls ?? []; + const nExtra = toolCall.nExtraSubCalls ?? 0; + return ( + {/* Tool call headline: Using/Used ToolName (argument) */} - {statusIcon} - + {bullet} + {stateLabel} + {toolCall.name} - {argsPreview} + {argsPreview && ( + <> + ( + {argsPreview} + ) + + )} + {/* Subagent metadata header — matches Python: "subagent {type} ({id})" in grey50 */} + {hasSubagent && ( + + + subagent {toolCall.subagentType} ({toolCall.subagentId}) + + + )} + {/* Hidden call count — matches Python: "N more tool calls ..." */} + {nExtra > 0 && ( + + + {nExtra} more tool call{nExtra > 1 ? "s" : ""} ... + + + )} + {/* Finished subagent tool calls — matches Python BulletColumns pattern */} + {finishedSubs.length > 0 && ( + + {finishedSubs.map((sub) => { + const subBullet = "•"; + const subColor = sub.isError ? colors.darkRed : colors.highlight; + return ( + + {subBullet} + Used + {sub.toolName} + {sub.arguments ? ( + <> + ( + {sub.arguments} + ) + + ) : null} + + ); + })} + + )} + {/* Rejected feedback line — matches Python's " Rejected: {feedback}" in dark_red */} + {isFinished && isError && toolCall.result?.return_value.output && (() => { + const output = toolCall.result!.return_value.output; + // Check for rejection feedback pattern from approval.ts + const rejectMatch = output.match(/rejected by the user\.\s*User feedback:\s*(.+)/i); + const briefBlock = toolCall.result!.display.find( + (b) => b.type === "brief" && (b as Record).brief, + ); + const briefText = briefBlock ? (briefBlock as Record).brief as string : null; + // Show "Rejected: feedback" if we have a brief that starts with "Rejected:" + if (briefText?.startsWith("Rejected:")) { + return ( + + {briefText} + + ); + } + // Or extract from output message + if (rejectMatch?.[1]) { + return ( + + Rejected: {rejectMatch[1]} + + ); + } + return null; + })()} {!collapsed && toolCall.result && ( @@ -273,7 +369,7 @@ function ToolResultView({ result }: ToolResultViewProps) { return ( {result.display.map((block, idx) => ( - + ))} {!result.display.length && ( {truncated} @@ -286,16 +382,17 @@ function ToolResultView({ result }: ToolResultViewProps) { interface DisplayBlockViewProps { block: DisplayBlock; + isError?: boolean; } -function DisplayBlockView({ block }: DisplayBlockViewProps) { +function DisplayBlockView({ block, isError }: DisplayBlockViewProps) { const colors = getMessageColors(); const diffColors = getDiffColors(); const b = block as Record; switch (block.type) { case "brief": - return {b.brief as string}; + return {b.brief as string}; case "diff": return ( chalk.strikethrough(p1)) // Inline code - .replace(/`(.+?)`/g, (_, p1) => chalk.cyan(p1)) + .replace(/`(.+?)`/g, (_, p1) => chalk.bold.cyanBright(p1)) // Links [text](url) .replace(/\[(.+?)\]\((.+?)\)/g, (_, text, url) => chalk.underline.hex("#56a4ff")(text) + chalk.hex("#6b7280")(` (${url})`), @@ -920,26 +1017,3 @@ function truncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return `${text.slice(0, maxLen)}…`; } - -/** - * Extract the most relevant argument from a tool call for preview. - */ -function extractKeyArgument( - toolName: string, - args: Record, -): string { - // Try common key argument names - const keyNames = ["path", "file_path", "command", "query", "url", "name", "pattern", "description"]; - for (const key of keyNames) { - if (key in args && typeof args[key] === "string") { - return truncate(args[key] as string, 80); - } - } - // Fall back to first string argument - for (const [_, val] of Object.entries(args)) { - if (typeof val === "string" && val.length < 100) { - return val; - } - } - return ""; -} diff --git a/src/kimi_cli_ts/ui/shell/commands/add_dir.ts b/src/kimi_cli_ts/ui/shell/commands/add_dir.ts index 5d804f537..c5acc96cd 100644 --- a/src/kimi_cli_ts/ui/shell/commands/add_dir.ts +++ b/src/kimi_cli_ts/ui/shell/commands/add_dir.ts @@ -5,7 +5,6 @@ */ import { resolve } from "node:path"; -import { logger } from "../../../utils/logging.ts"; import type { Session } from "../../../session.ts"; import { saveSessionState } from "../../../session.ts"; @@ -13,21 +12,20 @@ export async function handleAddDir( session: Session, workDir: string, args: string, -): Promise { +): Promise { const arg = args.trim(); // No args: list currently added directories if (!arg) { const dirs = session.state.additional_dirs; if (!dirs.length) { - logger.info("No additional directories. Usage: /add-dir "); - } else { - logger.info("Additional directories:"); - for (const d of dirs) { - logger.info(` - ${d}`); - } + return "No additional directories. Usage: /add-dir "; } - return null; + const lines = ["Additional directories:"]; + for (const d of dirs) { + lines.push(` - ${d}`); + } + return lines.join("\n"); } // Resolve the path @@ -38,31 +36,26 @@ export async function handleAddDir( try { const stat = await Bun.$`test -d ${dirPath}`.quiet(); if (stat.exitCode !== 0) { - logger.info(`Not a directory: ${dirPath}`); - return null; + return `Not a directory: ${dirPath}`; } } catch { - logger.info(`Directory does not exist: ${dirPath}`); - return null; + return `Directory does not exist: ${dirPath}`; } // Check if already added if (session.state.additional_dirs.includes(dirPath)) { - logger.info(`Directory already in workspace: ${dirPath}`); - return null; + return `Directory already in workspace: ${dirPath}`; } // Check if within work dir if (dirPath.startsWith(workDir + "/") || dirPath === workDir) { - logger.info(`Directory is already within the working directory: ${dirPath}`); - return null; + return `Directory is already within the working directory: ${dirPath}`; } // Check if within an already-added directory for (const existing of session.state.additional_dirs) { if (dirPath.startsWith(existing + "/") || dirPath === existing) { - logger.info(`Directory is already within added directory ${existing}: ${dirPath}`); - return null; + return `Directory is already within added directory ${existing}: ${dirPath}`; } } @@ -71,21 +64,12 @@ export async function handleAddDir( try { lsOutput = await Bun.$`ls -la ${dirPath}`.quiet().text(); } catch (e) { - logger.info(`Cannot read directory: ${dirPath}`); - return null; + return `Cannot read directory: ${dirPath}`; } // Add the directory session.state.additional_dirs.push(dirPath); await saveSessionState(session.state, session.dir); - logger.info(`Added directory to workspace: ${dirPath}`); - - // Return info string for injecting into context - return ( - `The user has added an additional directory to the workspace: \`${dirPath}\`\n\n` + - `Directory listing:\n\`\`\`\n${lsOutput.trim()}\n\`\`\`\n\n` + - "You can now read, write, search, and glob files in this directory " + - "as if it were part of the working directory." - ); + return `Added directory to workspace: ${dirPath}`; } diff --git a/src/kimi_cli_ts/ui/shell/commands/editor.ts b/src/kimi_cli_ts/ui/shell/commands/editor.ts index b36182c8c..f7e6f4f28 100644 --- a/src/kimi_cli_ts/ui/shell/commands/editor.ts +++ b/src/kimi_cli_ts/ui/shell/commands/editor.ts @@ -1,6 +1,5 @@ import { loadConfig, saveConfig, type Config, type ConfigMeta } from "../../../config.ts"; import type { CommandPanelConfig } from "../../../types.ts"; -import { logger } from "../../../utils/logging.ts"; import { which } from "bun"; type Notify = (title: string, body: string) => void; @@ -29,20 +28,22 @@ export function createEditorPanel(config: Config, configMeta: ConfigMeta, notify }; } -export async function handleEditor(config: Config, configMeta: ConfigMeta, args: string): Promise { +export async function handleEditor(config: Config, configMeta: ConfigMeta, args: string): Promise { const currentEditor = config.default_editor; if (!args.trim()) { // Show current and available options - logger.info(`Current editor: ${currentEditor || "auto-detect"}`); - logger.info(""); - logger.info("Available editors:"); - logger.info(" /editor code --wait (VS Code)"); - logger.info(" /editor vim"); - logger.info(" /editor nano"); - logger.info(" /editor (any editor command)"); - logger.info(' /editor "" (auto-detect from $VISUAL/$EDITOR)'); - return; + const lines = [ + `Current editor: ${currentEditor || "auto-detect"}`, + "", + "Available editors:", + " /editor code --wait (VS Code)", + " /editor vim", + " /editor nano", + " /editor (any editor command)", + ' /editor "" (auto-detect from $VISUAL/$EDITOR)', + ]; + return lines.join("\n"); } const newEditor = args.trim(); @@ -52,13 +53,12 @@ export async function handleEditor(config: Config, configMeta: ConfigMeta, args: const binary = newEditor.split(/\s+/)[0]!; const found = which(binary); if (!found) { - logger.info(`Warning: '${binary}' not found in PATH. Saving anyway.`); + return `Warning: '${binary}' not found in PATH. Saving anyway.`; } } if (newEditor === currentEditor) { - logger.info(`Editor is already set to: ${newEditor || "auto-detect"}`); - return; + return `Editor is already set to: ${newEditor || "auto-detect"}`; } // Save to config @@ -67,8 +67,8 @@ export async function handleEditor(config: Config, configMeta: ConfigMeta, args: freshConfig.default_editor = newEditor; await saveConfig(freshConfig, configMeta.sourceFile ?? undefined); config.default_editor = newEditor; - logger.info(`Editor set to: ${newEditor || "auto-detect"}`); + return `Editor set to: ${newEditor || "auto-detect"}`; } catch (err) { - logger.info(`Failed to save config: ${err instanceof Error ? err.message : err}`); + return `Failed to save config: ${err instanceof Error ? err.message : err}`; } } diff --git a/src/kimi_cli_ts/ui/shell/commands/export_import.ts b/src/kimi_cli_ts/ui/shell/commands/export_import.ts index 87fe40654..c830b480e 100644 --- a/src/kimi_cli_ts/ui/shell/commands/export_import.ts +++ b/src/kimi_cli_ts/ui/shell/commands/export_import.ts @@ -3,18 +3,18 @@ import type { Session } from "../../../session.ts"; import type { ContentPart } from "../../../types.ts"; import { join } from "node:path"; import { homedir } from "node:os"; -import { logger } from "../../../utils/logging.ts"; -export async function handleExport(context: Context, session: Session, args: string): Promise { +export async function handleExport(context: Context, session: Session, args: string): Promise { const history = context.history; if (!history.length) { - logger.info("Nothing to export - context is empty."); - return; + return "Nothing to export - context is empty."; } // Determine output path const outputDir = args.trim() || session.workDir; - const filename = `kimi-export-${session.id.slice(0, 8)}.md`; + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const filename = `kimi-export-${session.id.slice(0, 8)}-${ts}.md`; const outputPath = join(outputDir, filename); // Build markdown @@ -56,18 +56,19 @@ export async function handleExport(context: Context, session: Session, args: str await Bun.write(outputPath, lines.join("\n")); // Shorten home dir for display const display = outputPath.replace(homedir(), "~"); - logger.info(`Exported ${history.length} messages to ${display}`); - logger.info("Note: The exported file may contain sensitive information."); + return ( + `Exported ${history.length} messages to ${display}\n` + + "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally." + ); } catch (err) { - logger.info(`Failed to export: ${err instanceof Error ? err.message : err}`); + return `Failed to export: ${err instanceof Error ? err.message : err}`; } } -export async function handleImport(context: Context, session: Session, args: string): Promise { +export async function handleImport(context: Context, session: Session, args: string): Promise { const target = args.trim(); if (!target) { - logger.info("Usage: /import "); - return; + return "Usage: /import "; } // Check if it's a file path @@ -80,26 +81,23 @@ export async function handleImport(context: Context, session: Session, args: str role: "user", content: `[Imported from ${target}]\n\n${content}`, }); - logger.info(`Imported ${content.length} chars from ${target}`); + return `Imported ${content.length} chars from ${target}`; } catch (err) { - logger.info(`Failed to import: ${err instanceof Error ? err.message : err}`); + return `Failed to import: ${err instanceof Error ? err.message : err}`; } - return; } // Try as session ID const { Session: SessionClass } = await import("../../../session.ts"); const otherSession = await SessionClass.find(session.workDir, target); if (!otherSession) { - logger.info(`File not found and no session with ID: ${target}`); - return; + return `File not found and no session with ID: ${target}`; } // Read other session's context const contextFile = Bun.file(otherSession.contextFile); if (!(await contextFile.exists())) { - logger.info("Target session has no context."); - return; + return "Target session has no context."; } const text = await contextFile.text(); @@ -108,5 +106,5 @@ export async function handleImport(context: Context, session: Session, args: str role: "user", content: `[Imported context from session ${target}]\n\n${text}`, }); - logger.info(`Imported context from session ${target} (~${messageCount} entries)`); + return `Imported context from session ${target} (~${messageCount} entries)`; } diff --git a/src/kimi_cli_ts/ui/shell/commands/feedback.ts b/src/kimi_cli_ts/ui/shell/commands/feedback.ts index 4830535d7..26656bb69 100644 --- a/src/kimi_cli_ts/ui/shell/commands/feedback.ts +++ b/src/kimi_cli_ts/ui/shell/commands/feedback.ts @@ -1,7 +1,6 @@ import { loadTokens } from "../../../auth/oauth.ts"; import type { Config } from "../../../config.ts"; import type { CommandPanelConfig } from "../../../types.ts"; -import { logger } from "../../../utils/logging.ts"; import { platform, release } from "node:os"; const ISSUE_URL = "https://github.com/MoonshotAI/kimi-cli/issues"; @@ -11,12 +10,10 @@ export async function handleFeedback( args: string, sessionId: string, modelKey: string | undefined, -): Promise { +): Promise { const content = args.trim(); if (!content) { - logger.info("Usage: /feedback "); - logger.info(`Or submit at: ${ISSUE_URL}`); - return; + return `Usage: /feedback \nOr submit at: ${ISSUE_URL}`; } // Try to find a provider with OAuth for posting feedback @@ -35,8 +32,7 @@ export async function handleFeedback( } if (!apiKey || !baseUrl) { - logger.info(`No authenticated platform found. Please submit feedback at: ${ISSUE_URL}`); - return; + return `No authenticated platform found. Please submit feedback at: ${ISSUE_URL}`; } const payload = { @@ -57,13 +53,12 @@ export async function handleFeedback( body: JSON.stringify(payload), }); if (res.ok) { - logger.info(`Feedback submitted, thank you! Session ID: ${sessionId}`); + return `Feedback submitted, thank you! Session ID: ${sessionId}`; } else { - logger.info(`Failed to submit feedback (HTTP ${res.status}). Try: ${ISSUE_URL}`); + return `Failed to submit feedback (HTTP ${res.status}). Try: ${ISSUE_URL}`; } } catch (err) { - logger.info(`Failed to submit feedback: ${err instanceof Error ? err.message : err}`); - logger.info(`Please submit at: ${ISSUE_URL}`); + return `Failed to submit feedback: ${err instanceof Error ? err.message : err}\nPlease submit at: ${ISSUE_URL}`; } } diff --git a/src/kimi_cli_ts/ui/shell/commands/info.ts b/src/kimi_cli_ts/ui/shell/commands/info.ts index 79e143066..71073b123 100644 --- a/src/kimi_cli_ts/ui/shell/commands/info.ts +++ b/src/kimi_cli_ts/ui/shell/commands/info.ts @@ -8,38 +8,34 @@ import type { Config } from "../../../config.ts"; import type { Context } from "../../../soul/context.ts"; import type { ContentPart, CommandPanelConfig } from "../../../types.ts"; import { CHANGELOG } from "../../../utils/changelog.ts"; -import { logger } from "../../../utils/logging.ts"; -export function handleHooks(hookEngine: HookEngine): void { +export function handleHooks(hookEngine: HookEngine): string { const summary = hookEngine.summary; if (!Object.keys(summary).length) { - logger.info("No hooks configured. Add [[hooks]] sections to config.toml."); - return; + return "No hooks configured. Add [[hooks]] sections to config.toml."; } - logger.info("\nConfigured Hooks:"); + const lines = ["Configured Hooks:"]; for (const [event, count] of Object.entries(summary)) { - logger.info(` ${event}: ${count} hook(s)`); + lines.push(` ${event}: ${count} hook(s)`); } - logger.info(""); + return lines.join("\n"); } -export function handleMcp(config: Config): void { - logger.info("MCP Configuration:"); - logger.info(` Client timeout: ${config.mcp.client.tool_call_timeout_ms}ms`); - logger.info("\nNote: MCP server management available via 'kimi mcp' CLI commands."); +export function handleMcp(config: Config): string { + return ( + "MCP Configuration:\n" + + ` Client timeout: ${config.mcp.client.tool_call_timeout_ms}ms\n` + + "\nNote: MCP server management available via 'kimi mcp' CLI commands." + ); } -export function handleDebug(context: Context): void { +export function handleDebug(context: Context): string { const history = context.history; if (!history.length) { - logger.info("Context is empty - no messages yet."); - return; + return "Context is empty - no messages yet."; } - logger.info(`\n=== Context Debug ===`); - logger.info(`Total messages: ${history.length}`); - logger.info(`Token count: ${context.tokenCountWithPending}`); - logger.info(`---`); + const lines = ["=== Context Debug ===", `Total messages: ${history.length}`, `Token count: ${context.tokenCountWithPending}`, "---"]; for (let i = 0; i < history.length; i++) { const msg = history[i]!; @@ -50,7 +46,7 @@ export function handleDebug(context: Context): void { msg.content.length > 200 ? msg.content.slice(0, 200) + "..." : msg.content; - logger.info(`#${i + 1} [${role}] ${preview}`); + lines.push(`#${i + 1} [${role}] ${preview}`); } else if (Array.isArray(msg.content)) { const parts = msg.content as ContentPart[]; const summary = parts @@ -63,21 +59,23 @@ export function handleDebug(context: Context): void { return `[${p.type}]`; }) .join(" | "); - logger.info(`#${i + 1} [${role}] ${summary}`); + lines.push(`#${i + 1} [${role}] ${summary}`); } } - logger.info(`=== End Debug ===\n`); + lines.push("=== End Debug ==="); + return lines.join("\n"); } -export function handleChangelog(): void { - logger.info("\n Release Notes:\n"); +export function handleChangelog(): string { + const lines = [" Release Notes:", ""]; for (const [version, entry] of Object.entries(CHANGELOG)) { - logger.info(` ${version}: ${entry.description}`); + lines.push(` ${version}: ${entry.description}`); for (const item of entry.entries) { - logger.info(` \u2022 ${item}`); + lines.push(` \u2022 ${item}`); } - logger.info(""); + lines.push(""); } + return lines.join("\n"); } // ── Panel factory functions ───────────────────────────── @@ -96,43 +94,19 @@ export function createChangelogPanel(): CommandPanelConfig { export function createDebugPanel(context: Context): CommandPanelConfig { const history = context.history; - if (!history.length) { - return { type: "content", title: "Context Debug", content: "Context is empty - no messages yet." }; - } - - const lines: string[] = []; - lines.push(`=== Context Debug ===`); - lines.push(`Total messages: ${history.length}`); - lines.push(`Token count: ${context.tokenCountWithPending}`); - lines.push(`---`); - - for (let i = 0; i < history.length; i++) { - const msg = history[i]!; - const role = msg.role.toUpperCase(); - - if (typeof msg.content === "string") { - const preview = - msg.content.length > 200 - ? msg.content.slice(0, 200) + "..." - : msg.content; - lines.push(`#${i + 1} [${role}] ${preview}`); - } else if (Array.isArray(msg.content)) { - const parts = msg.content as ContentPart[]; - const summary = parts - .map((p: any) => { - if (p.type === "text") - return p.text.length > 100 ? p.text.slice(0, 100) + "..." : p.text; - if (p.type === "tool_use") return `[tool_use: ${p.name}]`; - if (p.type === "tool_result") return `[tool_result]`; - if (p.type === "image") return `[image]`; - return `[${p.type}]`; - }) - .join(" | "); - lines.push(`#${i + 1} [${role}] ${summary}`); - } - } - lines.push(`=== End Debug ===`); - return { type: "content", title: "Context Debug", content: lines.join("\n") }; + + return { + type: "debug", + data: { + context: { + totalMessages: history.length, + tokenCount: context.tokenCountWithPending, + checkpoints: context.nCheckpoints, + trajectory: context.filePath, + }, + messages: history as any, // KMessage[] from context.jsonl + }, + }; } export function createHooksPanel(hookEngine: HookEngine): CommandPanelConfig { diff --git a/src/kimi_cli_ts/ui/shell/commands/init.ts b/src/kimi_cli_ts/ui/shell/commands/init.ts index 1e7516934..e5bf92de8 100644 --- a/src/kimi_cli_ts/ui/shell/commands/init.ts +++ b/src/kimi_cli_ts/ui/shell/commands/init.ts @@ -4,27 +4,20 @@ * Corresponds to Python soul/slash.py init command. */ -import { logger } from "../../../utils/logging.ts"; - /** * Handle /init command — trigger codebase analysis and AGENTS.md generation. * In the Python version this creates a temporary context, runs a prompt through the LLM, * then reloads the generated AGENTS.md. For now we provide a simplified version. */ -export async function handleInit(workDir: string): Promise { +export async function handleInit(workDir: string): Promise { const agentsMdPath = `${workDir}/AGENTS.md`; // Check if AGENTS.md already exists const existing = Bun.file(agentsMdPath); if (await existing.exists()) { - logger.info(`AGENTS.md already exists at ${agentsMdPath}`); - logger.info("To regenerate, delete it first and run /init again."); - return null; + return `AGENTS.md already exists at ${agentsMdPath}\nTo regenerate, delete it first and run /init again.`; } - logger.info("Analyzing codebase to generate AGENTS.md..."); - logger.info("Note: Full /init requires an LLM call. Generating a basic template."); - // Generate a basic template let lsOutput = ""; try { @@ -54,11 +47,8 @@ export async function handleInit(workDir: string): Promise { try { await Bun.write(agentsMdPath, template); - logger.info(`Generated AGENTS.md at ${agentsMdPath}`); - logger.info("Edit it to describe your project for better AI assistance."); - return template; + return `Generated AGENTS.md at ${agentsMdPath}\nEdit it to describe your project for better AI assistance.`; } catch (err) { - logger.info(`Failed to generate AGENTS.md: ${err instanceof Error ? err.message : err}`); - return null; + return `Failed to generate AGENTS.md: ${err instanceof Error ? err.message : err}`; } } diff --git a/src/kimi_cli_ts/ui/shell/commands/misc.ts b/src/kimi_cli_ts/ui/shell/commands/misc.ts index ac491f37e..131ce5b6c 100644 --- a/src/kimi_cli_ts/ui/shell/commands/misc.ts +++ b/src/kimi_cli_ts/ui/shell/commands/misc.ts @@ -1,20 +1,24 @@ -import { logger } from "../../../utils/logging.ts"; - -export function handleWeb(sessionId: string): void { - logger.info("Web UI is not yet available in the TypeScript version."); - logger.info("Use 'kimi web' CLI command to start the web server."); +export function handleWeb(sessionId: string): string { + return ( + "Web UI is not yet available in the TypeScript version.\n" + + "Use 'kimi web' CLI command to start the web server." + ); } -export function handleVis(sessionId: string): void { - logger.info("Visualizer is not yet available in the TypeScript version."); - logger.info("Use 'kimi vis' CLI command to start the visualizer."); +export function handleVis(sessionId: string): string { + return ( + "Visualizer is not yet available in the TypeScript version.\n" + + "Use 'kimi vis' CLI command to start the visualizer." + ); } -export function handleReload(): void { - logger.info("Configuration reloaded. If changes don't take effect, please restart the CLI."); +export function handleReload(): string { + return "Configuration reloaded. If changes don't take effect, please restart the CLI."; } -export function handleTask(): void { - logger.info("Background task browser is not yet available in the TypeScript version."); - logger.info("Background tasks are managed automatically during agent execution."); +export function handleTask(): string { + return ( + "Background task browser is not yet available in the TypeScript version.\n" + + "Background tasks are managed automatically during agent execution." + ); } diff --git a/src/kimi_cli_ts/ui/shell/commands/model.ts b/src/kimi_cli_ts/ui/shell/commands/model.ts index 0aa7416bc..28a5a3b2e 100644 --- a/src/kimi_cli_ts/ui/shell/commands/model.ts +++ b/src/kimi_cli_ts/ui/shell/commands/model.ts @@ -4,6 +4,21 @@ import type { CommandPanelConfig } from "../../../types.ts"; type Notify = (title: string, body: string) => void; +/** + * Determine thinking mode capabilities for a model. + * Corresponds to Python derive_model_capabilities() + */ +function getThinkingCapabilities(modelCfg: any): { + alwaysThinking: boolean; + supportsThinking: boolean; +} { + const caps = modelCfg.capabilities || []; + return { + alwaysThinking: caps.includes("always_thinking"), + supportsThinking: caps.includes("thinking") || caps.includes("always_thinking"), + }; +} + /** * Handle /model when typed directly (no panel) — show current model as notification. */ @@ -21,10 +36,99 @@ export async function handleModel(config: Config, configMeta: ConfigMeta, notify } } +/** + * Create a panel for selecting thinking mode. + * Corresponds to Python ui/shell/slash.py thinking mode selection (lines 213-228) + */ +function createThinkingModePanel( + config: Config, + configMeta: ConfigMeta, + selectedModelName: string, + currentThinking: boolean, + notify: Notify, + sessionId?: string, + onReload?: (sessionId: string, prefillText?: string) => void, +): CommandPanelConfig { + const selectedModelCfg = config.models[selectedModelName]!; + + // Build thinking mode items + const currentLabel = (mode: "off" | "on") => mode === (currentThinking ? "on" : "off") ? ` (current)` : ""; + const items = [ + { + label: `off${currentLabel("off")}`, + value: "off", + current: !currentThinking, + description: "Disable thinking mode", + }, + { + label: `on${currentLabel("on")}`, + value: "on", + current: currentThinking, + description: "Enable thinking mode", + }, + ]; + + return { + type: "choice", + title: "Enable thinking mode?", + items, + onSelect: async (thinkingSelection: string) => { + const newThinking = thinkingSelection === "on"; + + // Check if anything changed + if (selectedModelName === config.default_model && newThinking === currentThinking) { + notify("Model", "No changes."); + return; + } + + try { + // Save config with new model and thinking setting + const prevModel = config.default_model; + const prevThinking = config.default_thinking; + + config.default_model = selectedModelName; + config.default_thinking = newThinking; + + try { + await saveConfig(config, configMeta.sourceFile ?? undefined); + } catch (err: any) { + // Rollback on save failure + config.default_model = prevModel; + config.default_thinking = prevThinking; + notify("Model", `Failed to save config: ${err?.message ?? err}`); + return; + } + + // Success — show message and trigger reload + notify( + "Model", + `Switched to: ${selectedModelCfg.model} with thinking ${newThinking ? "on" : "off"}. Reloading...`, + ); + + if (sessionId && onReload) { + onReload(sessionId); + } else if (!sessionId) { + notify("Model", "Warning: sessionId not available for reload"); + } + return; + } catch (err: any) { + notify("Model", `Error: ${err?.message ?? err}`); + } + }, + }; +} + /** * Create a panel for /model that lists all available models for selection. + * Corresponds to Python ui/shell/slash.py model() command (lines 146-264) */ -export function createModelPanel(config: Config, configMeta: ConfigMeta, notify: Notify): CommandPanelConfig { +export function createModelPanel( + config: Config, + configMeta: ConfigMeta, + notify: Notify, + sessionId?: string, + onReload?: (sessionId: string, prefillText?: string) => void, +): CommandPanelConfig { const modelNames = Object.keys(config.models).sort(); if (!modelNames.length) { return { @@ -35,12 +139,16 @@ export function createModelPanel(config: Config, configMeta: ConfigMeta, notify: } const currentModel = config.default_model; + const currentThinking = config.default_thinking; + + // Build model items const items = modelNames.map((name) => { const modelCfg = config.models[name]!; const providerName = modelCfg.provider; const capabilities = modelCfg.capabilities?.join(", ") || "none"; + const label = name === currentModel ? `${modelCfg.model} (${providerName}) [current]` : `${modelCfg.model} (${providerName})`; return { - label: `${modelCfg.model} (${providerName})`, + label, value: name, current: name === currentModel, description: `caps: ${capabilities}`, @@ -51,18 +159,85 @@ export function createModelPanel(config: Config, configMeta: ConfigMeta, notify: type: "choice", title: "Select Model", items, - onSelect: async (value: string) => { - if (value === currentModel) { + onSelect: async (selectedModelName: string) => { + // Step 1: Check if model changed + if (selectedModelName === currentModel) { notify("Model", "Already using this model."); return; } - config.default_model = value; - try { - await saveConfig(config, configMeta.sourceFile ?? undefined); - const modelCfg = config.models[value]; - notify("Model", `Switched to: ${modelCfg?.model ?? value}. Restart to apply.`); - } catch (err: any) { - notify("Model", `Failed to save: ${err?.message ?? err}`); + + // Step 2: Determine thinking mode options for selected model + const selectedModelCfg = config.models[selectedModelName]!; + const { alwaysThinking, supportsThinking } = getThinkingCapabilities(selectedModelCfg); + + // If model always has thinking, auto-enable and proceed with save + if (alwaysThinking) { + const newThinking = true; + + try { + config.default_model = selectedModelName; + config.default_thinking = newThinking; + + try { + await saveConfig(config, configMeta.sourceFile ?? undefined); + } catch (err: any) { + config.default_model = currentModel; + config.default_thinking = currentThinking; + notify("Model", `Failed to save config: ${err?.message ?? err}`); + return; + } + + notify("Model", `Switched to: ${selectedModelCfg.model} with thinking on. Reloading...`); + + if (sessionId && onReload) { + onReload(sessionId); + } else if (!sessionId) { + notify("Model", "Warning: sessionId not available for reload"); + } + return; + } catch (err: any) { + notify("Model", `Error: ${err?.message ?? err}`); + } + } else if (supportsThinking) { + // Model supports optional thinking — show thinking mode panel + // Return the thinking mode panel to display to user + return createThinkingModePanel( + config, + configMeta, + selectedModelName, + currentThinking, + notify, + sessionId, + onReload, + ); + } else { + // Model doesn't support thinking + const newThinking = false; + + try { + config.default_model = selectedModelName; + config.default_thinking = newThinking; + + try { + await saveConfig(config, configMeta.sourceFile ?? undefined); + } catch (err: any) { + config.default_model = currentModel; + config.default_thinking = currentThinking; + notify("Model", `Failed to save config: ${err?.message ?? err}`); + return; + } + + notify("Model", `Switched to: ${selectedModelCfg.model}. Reloading...`); + + if (sessionId && onReload) { + onReload(sessionId); + } else if (!sessionId) { + notify("Model", "Warning: sessionId not available for reload"); + } + return; + } catch (err: any) { + notify("Model", `Error: ${err?.message ?? err}`); + } } }, }; diff --git a/src/kimi_cli_ts/ui/shell/commands/session.ts b/src/kimi_cli_ts/ui/shell/commands/session.ts index 35337e183..c9618852b 100644 --- a/src/kimi_cli_ts/ui/shell/commands/session.ts +++ b/src/kimi_cli_ts/ui/shell/commands/session.ts @@ -4,7 +4,6 @@ import { Session, loadSessionState, saveSessionState } from "../../../session.ts"; import type { CommandPanelConfig } from "../../../types.ts"; -import { logger } from "../../../utils/logging.ts"; import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { createHash } from "node:crypto"; @@ -63,11 +62,13 @@ export function createSessionsPanel( // Try state file for custom_title const stateFile = join(sessionDir, "state.json"); + let hasCustomTitle = false; if (existsSync(stateFile)) { try { const stateData = JSON.parse(readFileSync(stateFile, "utf-8")); if (stateData.custom_title) { title = stateData.custom_title; + hasCustomTitle = true; } } catch { /* ignore */ } } @@ -96,6 +97,9 @@ export function createSessionsPanel( } } + // Skip empty sessions (session with no custom title and no wire events) + if (title === "Untitled" && !hasCustomTitle) continue; + sessions.push({ id: entry, title, updatedAt }); } @@ -106,7 +110,7 @@ export function createSessionsPanel( const items = sessions.map((s) => ({ label: s.title, value: s.id, - description: `${formatRelativeTimeMs(s.updatedAt)}${s.id === session.id ? " (current)" : ""}`, + description: formatRelativeTimeMs(s.updatedAt), current: s.id === session.id, })); @@ -125,32 +129,32 @@ export function createSessionsPanel( }; } -export async function handleNew(session: Session): Promise { +export async function handleNew(session: Session): Promise { const workDir = session.workDir; if (await session.isEmpty()) { await session.delete(); } const newSession = await Session.create(workDir); - logger.info(`New session created: ${newSession.id}. Please restart to switch.`); + return `New session created: ${newSession.id}. Please restart to switch.`; } -export async function handleSessions(session: Session): Promise { +export async function handleSessions(session: Session): Promise { const sessions = await Session.list(session.workDir); if (sessions.length === 0) { - logger.info("No sessions found."); - return; + return "No sessions found."; } + const lines: string[] = []; for (const s of sessions) { const current = s.id === session.id ? " (current)" : ""; const timeAgo = formatRelativeTime(s.updatedAt); - logger.info(` ${s.title} (${s.id}) - ${timeAgo}${current}`); + lines.push(` ${s.title}, ${timeAgo}${current}`); } + return lines.join("\n"); } -export async function handleTitle(session: Session, args: string): Promise { +export async function handleTitle(session: Session, args: string): Promise { if (!args.trim()) { - logger.info(`Session title: ${session.title}`); - return; + return `Session title: ${session.title}`; } const newTitle = args.trim().slice(0, 200); const freshState = await loadSessionState(session.dir); @@ -159,17 +163,18 @@ export async function handleTitle(session: Session, args: string): Promise await saveSessionState(freshState, session.dir); session.state.custom_title = newTitle; session.title = newTitle; - logger.info(`Session title set to: ${newTitle}`); + return `Session title set to: ${newTitle}`; } export function createTitlePanel( session: Session, notify: Notify, ): CommandPanelConfig { + const currentTitle = session.title || "Untitled"; return { type: "input", - title: "Set Session Title", - placeholder: session.title || "Enter a title...", + title: `Session Title — ${currentTitle}`, + placeholder: "Enter a new title...", onSubmit: (value: string) => { const newTitle = value.trim().slice(0, 200); if (!newTitle) return; diff --git a/src/kimi_cli_ts/ui/shell/commands/usage.ts b/src/kimi_cli_ts/ui/shell/commands/usage.ts index 04fdc85b3..0bad77427 100644 --- a/src/kimi_cli_ts/ui/shell/commands/usage.ts +++ b/src/kimi_cli_ts/ui/shell/commands/usage.ts @@ -1,17 +1,32 @@ import { loadTokens } from "../../../auth/oauth.ts"; import type { Config } from "../../../config.ts"; -import { logger } from "../../../utils/logging.ts"; +import type { UsageRow } from "../UsagePanel.tsx"; -export async function handleUsage(config: Config, modelKey: string | undefined): Promise { +/** + * Structured result from fetching usage data. + */ +export interface UsageResult { + summary: UsageRow | null; + limits: UsageRow[]; + error?: string; +} + +/** + * Fetch API usage data and return structured results for rendering. + * Mirrors Python's _parse_usage_payload behavior. + */ +export async function fetchAndParseUsage( + config: Config, + modelKey: string | undefined, +): Promise { if (!modelKey || !config.models[modelKey]) { - logger.info("No model selected. Run /login first."); - return; + return { summary: null, limits: [], error: "No model selected. Run /login first." }; } + const modelCfg = config.models[modelKey]!; const providerCfg = config.providers[modelCfg.provider]; if (!providerCfg) { - logger.info("Provider not found."); - return; + return { summary: null, limits: [], error: "Provider not found." }; } // Resolve API key (try OAuth token first) @@ -28,42 +43,171 @@ export async function handleUsage(config: Config, modelKey: string | undefined): const res = await fetch(usageUrl, { headers: { "Authorization": `Bearer ${apiKey}` }, }); + if (!res.ok) { - if (res.status === 401) logger.info("Authorization failed. Please check your API key."); - else if (res.status === 404) logger.info("Usage endpoint not available."); - else logger.info(`Failed to fetch usage (HTTP ${res.status}).`); - return; + let errorMsg = `Failed to fetch usage (HTTP ${res.status}).`; + if (res.status === 401) errorMsg = "Authorization failed. Please check your API key."; + else if (res.status === 404) errorMsg = "Usage endpoint not available."; + return { summary: null, limits: [], error: errorMsg }; } + const data = await res.json() as Record; + return parseUsagePayload(data); + } catch (err) { + const errorMsg = `Failed to fetch usage: ${err instanceof Error ? err.message : err}`; + return { summary: null, limits: [], error: errorMsg }; + } +} + +/** + * Parse usage API payload into structured UsageRow objects. + * Mirrors Python's _parse_usage_payload + _to_usage_row logic. + */ +function parseUsagePayload(payload: Record): UsageResult { + let summary: UsageRow | null = null; + const limits: UsageRow[] = []; + + const usage = payload.usage; + if (usage && typeof usage === "object") { + summary = toUsageRow(usage as Record, "Weekly limit"); + } - // Parse and display usage - const usage = data.usage; - if (usage) { - const limit = usage.limit || 0; - const used = usage.used ?? (limit - (usage.remaining || 0)); - const pct = limit > 0 ? ((limit - used) / limit * 100).toFixed(0) : "?"; - const label = usage.name || usage.title || "Weekly limit"; - logger.info(`\n API Usage:`); - logger.info(` ${label}: ${used}/${limit} used (${pct}% remaining)`); + const rawLimits = payload.limits; + if (Array.isArray(rawLimits)) { + for (let idx = 0; idx < rawLimits.length; idx++) { + const item = rawLimits[idx]; + if (!item || typeof item !== "object") continue; + const itemMap = item as Record; + const detail = + itemMap.detail && typeof itemMap.detail === "object" + ? (itemMap.detail as Record) + : itemMap; + const window = + itemMap.window && typeof itemMap.window === "object" + ? (itemMap.window as Record) + : {}; + const label = limitLabel(itemMap, detail, window, idx); + const row = toUsageRow(detail, label); + if (row) limits.push(row); } + } + + return { summary, limits }; +} - // Parse limits array - const limits = data.limits; - if (Array.isArray(limits) && limits.length > 0) { - for (const item of limits) { - const detail = item.detail || item; - const limit = detail.limit || 0; - const used = detail.used ?? (limit - (detail.remaining || 0)); - const pct = limit > 0 ? ((limit - used) / limit * 100).toFixed(0) : "?"; - const name = item.name || item.title || detail.name || "Limit"; - logger.info(` ${name}: ${used}/${limit} used (${pct}% remaining)`); - } +function toUsageRow( + data: Record, + defaultLabel: string, +): UsageRow | null { + const limit = toInt(data.limit); + let used = toInt(data.used); + if (used == null) { + const remaining = toInt(data.remaining); + if (remaining != null && limit != null) { + used = limit - remaining; } + } + if (used == null && limit == null) return null; + return { + label: String(data.name || data.title || defaultLabel), + used: used || 0, + limit: limit || 0, + resetHint: resetHint(data), + }; +} - if (!usage && (!limits || !limits.length)) { - logger.info("No usage data available."); +function limitLabel( + item: Record, + detail: Record, + window: Record, + idx: number, +): string { + for (const key of ["name", "title", "scope"]) { + const val = item[key] || detail[key]; + if (val) return String(val); + } + + const duration = toInt(window.duration || item.duration || detail.duration); + const timeUnit = String( + window.timeUnit || item.timeUnit || detail.timeUnit || "", + ); + + if (duration) { + if (timeUnit.includes("MINUTE")) { + if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`; + return `${duration}m limit`; } - } catch (err) { - logger.info(`Failed to fetch usage: ${err instanceof Error ? err.message : err}`); + if (timeUnit.includes("HOUR")) return `${duration}h limit`; + if (timeUnit.includes("DAY")) return `${duration}d limit`; + return `${duration}s limit`; + } + + return `Limit #${idx + 1}`; +} + +function resetHint(data: Record): string | null { + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + if (data[key]) return formatResetTime(String(data[key])); + } + + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = toInt(data[key]); + if (seconds) return `resets in ${formatDuration(seconds)}`; + } + + return null; +} + +function formatResetTime(val: string): string { + try { + const dt = new Date(val); + const now = Date.now(); + const delta = dt.getTime() - now; + if (delta <= 0) return "reset"; + return `resets in ${formatDuration(Math.floor(delta / 1000))}`; + } catch { + return `resets at ${val}`; + } +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; +} + +function toInt(value: any): number | null { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? Math.floor(n) : null; +} + +/** + * Legacy: return plain text for terminal output. + * Kept for backward compatibility. + */ +export async function handleUsage(config: Config, modelKey: string | undefined): Promise { + const result = await fetchAndParseUsage(config, modelKey); + if (result.error) return result.error; + + const lines: string[] = []; + const rows = [...(result.summary ? [result.summary] : []), ...result.limits]; + + if (rows.length === 0) { + return "No usage data available."; } + + lines.push("API Usage:"); + for (const row of rows) { + const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; + const pct = (remaining * 100).toFixed(0); + lines.push(` ${row.label}: ${row.used}/${row.limit} used (${pct}% remaining)`); + if (row.resetHint) { + lines.push(` (${row.resetHint})`); + } + } + + return lines.join("\n"); } diff --git a/src/kimi_cli_ts/ui/shell/context-types.ts b/src/kimi_cli_ts/ui/shell/context-types.ts new file mode 100644 index 000000000..82ba597c1 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/context-types.ts @@ -0,0 +1,172 @@ +/** + * context-types.ts — Types for context.jsonl deserialization. + * + * These types correspond to Python's kosong library message types + * (packages/kosong/src/kosong/message.py). Context.jsonl may contain + * messages in either Python (kosong) or TS (Anthropic-style) format, + * so we define a union that covers both. + * + * This is the single source of truth for what /debug renders. + */ + +// ── Content Part types (kosong-compatible) ────────────── + +/** + * Text content. + * Python: TextPart(type="text", text="...") + * TS: { type: "text", text: "..." } + */ +export interface KTextPart { + type: "text"; + text: string; +} + +/** + * Thinking/reasoning content. + * Python: ThinkPart(type="think", think="...", encrypted=None) + * TS: stored as reasoning_content field on message (not as ContentPart) + */ +export interface KThinkPart { + type: "think"; + think: string; + encrypted?: string | null; +} + +/** + * Image URL content (Python/kosong format). + * Python: ImageURLPart(type="image_url", image_url={url, id?}) + */ +export interface KImageURLPart { + type: "image_url"; + image_url: { + url: string; + id?: string | null; + }; +} + +/** + * Image content (TS/Anthropic format). + * TS: { type: "image", source: { type: "base64"|"url", data: "...", mediaType?: "..." } } + */ +export interface KImagePart { + type: "image"; + source: { + type: "base64" | "url"; + data: string; + mediaType?: string; + }; +} + +/** + * Audio URL content (Python/kosong format). + * Python: AudioURLPart(type="audio_url", audio_url={url, id?}) + */ +export interface KAudioURLPart { + type: "audio_url"; + audio_url: { + url: string; + id?: string | null; + }; +} + +/** + * Video URL content (Python/kosong format). + * Python: VideoURLPart(type="video_url", video_url={url, id?}) + */ +export interface KVideoURLPart { + type: "video_url"; + video_url: { + url: string; + id?: string | null; + }; +} + +/** + * Tool use content (TS/Anthropic format, stored in content array). + * TS: { type: "tool_use", id: "...", name: "...", input: {...} } + */ +export interface KToolUsePart { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +/** + * Tool result content (TS/Anthropic format, stored in content array). + * TS: { type: "tool_result", toolUseId: "...", content: "...", isError?: bool } + */ +export interface KToolResultPart { + type: "tool_result"; + toolUseId: string; + content: string; + isError?: boolean; +} + +/** + * Union of all known content part types from both Python and TS formats. + * The debug panel must handle all of these. + */ +export type KContentPart = + | KTextPart + | KThinkPart + | KImageURLPart + | KImagePart + | KAudioURLPart + | KVideoURLPart + | KToolUsePart + | KToolResultPart; + +// ── Tool Call (Python/kosong format, separate from content) ── + +/** + * Tool call as stored in Python's message.tool_calls array. + * Python: ToolCall(type="function", id="...", function={name, arguments}) + */ +export interface KToolCall { + type: "function"; + id: string; + function: { + name: string; + arguments: string | null; + }; + extras?: Record | null; +} + +// ── Message (union of Python and TS formats) ───────────── + +export type KRole = "system" | "developer" | "user" | "assistant" | "tool"; + +/** + * A message as stored in context.jsonl. + * Covers both Python (kosong) and TS (Anthropic-style) formats. + */ +export interface KMessage { + role: KRole; + + /** Message content: string (single text) or array of content parts. */ + content: string | KContentPart[]; + + // ── Python/kosong format fields ── + /** Display name (Python: msg.name) */ + name?: string | null; + /** Tool calls requested by assistant (Python format). */ + tool_calls?: KToolCall[] | null; + /** Tool call ID this message responds to (Python format). */ + tool_call_id?: string | null; + /** Whether this is a partial/streaming message. */ + partial?: boolean | null; + + // ── TS-specific fields ── + /** Thinking content stored separately (TS format, not in content array). */ + reasoning_content?: string; +} + +// ── Context info for debug display ─────────────────────── + +export interface KContextInfo { + totalMessages: number; + tokenCount: number; + checkpoints: number; + trajectory?: string; +} diff --git a/src/kimi_cli_ts/ui/shell/events.ts b/src/kimi_cli_ts/ui/shell/events.ts index 62d70284b..c812f30aa 100644 --- a/src/kimi_cli_ts/ui/shell/events.ts +++ b/src/kimi_cli_ts/ui/shell/events.ts @@ -24,6 +24,14 @@ export interface ThinkSegment { text: string; } +/** A sub-tool-call from a subagent, displayed as a nested entry. */ +export interface FinishedSubCall { + callId: string; + toolName: string; + arguments: string; + isError: boolean; +} + export interface ToolCallSegment { type: "tool_call"; id: string; @@ -31,6 +39,15 @@ export interface ToolCallSegment { arguments: string; result?: ToolResult; collapsed: boolean; + // Subagent tracking (matches Python _ToolCallBlock) + subagentId?: string; + subagentType?: string; + /** In-flight subagent tool calls keyed by tool_call_id. */ + ongoingSubCalls?: Record; + /** Most recent MAX_SUBAGENT_TOOL_CALLS_TO_SHOW completed sub-calls. */ + finishedSubCalls?: FinishedSubCall[]; + /** Number of finished sub-calls hidden (overflow). */ + nExtraSubCalls?: number; } export type MessageSegment = TextSegment | ThinkSegment | ToolCallSegment; @@ -62,7 +79,7 @@ export type WireUIEvent = | { type: "compaction_begin" } | { type: "compaction_end" } | { type: "notification"; title: string; body: string; severity?: string } - | { type: "slash_result"; userInput: string; text: string } + | { type: "slash_result"; text: string } | { type: "plan_display"; content: string; filePath: string } | { type: "hook_triggered"; event: string; target: string; hookCount: number } | { type: "hook_resolved"; event: string; target: string; action: string; reason: string; durationMs: number } diff --git a/src/kimi_cli_ts/ui/shell/index.ts b/src/kimi_cli_ts/ui/shell/index.ts index 30748bd62..cbb0af53e 100644 --- a/src/kimi_cli_ts/ui/shell/index.ts +++ b/src/kimi_cli_ts/ui/shell/index.ts @@ -21,7 +21,8 @@ export type { ApprovalPanelProps } from "./ApprovalPanel.tsx"; export { QuestionPanel } from "./QuestionPanel.tsx"; export type { QuestionPanelProps } from "./QuestionPanel.tsx"; export { DebugPanel } from "./DebugPanel.tsx"; -export type { DebugPanelProps, DebugMessage, ContextInfo } from "./DebugPanel.tsx"; +export type { DebugPanelProps, ContextInfo } from "./DebugPanel.tsx"; +export type { KMessage, KContextInfo, KContentPart, KToolCall } from "./context-types.ts"; export { UsagePanel, parseUsagePayload } from "./UsagePanel.tsx"; export type { UsagePanelProps, UsageRow } from "./UsagePanel.tsx"; export { TaskBrowser } from "./TaskBrowser.tsx"; diff --git a/src/kimi_cli_ts/ui/shell/input-stack.ts b/src/kimi_cli_ts/ui/shell/input-stack.ts index b391cca1d..954db2e75 100644 --- a/src/kimi_cli_ts/ui/shell/input-stack.ts +++ b/src/kimi_cli_ts/ui/shell/input-stack.ts @@ -16,7 +16,7 @@ * handler if the stack is empty. */ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef } from "react"; // ── Types ─────────────────────────────────────────────── @@ -95,7 +95,6 @@ export function useInputLayer(handler: InputHandler): void { const idRef = useRef(null); useEffect(() => { - // Stable wrapper that delegates to the latest handler ref const stableHandler: InputHandler = (input, key) => { handlerRef.current(input, key); }; diff --git a/src/kimi_cli_ts/ui/shell/input-state.ts b/src/kimi_cli_ts/ui/shell/input-state.ts index 21d1ce2af..8dba99699 100644 --- a/src/kimi_cli_ts/ui/shell/input-state.ts +++ b/src/kimi_cli_ts/ui/shell/input-state.ts @@ -27,11 +27,46 @@ import { } from "../components/SlashMenu.tsx"; import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; +// Paste handling thresholds (matches Python version) +const TEXT_PASTE_CHAR_THRESHOLD = 1000; +const TEXT_PASTE_LINE_THRESHOLD = 15; + +/** Normalize pasted text: convert \r\n and \r to \n (matches Python's normalize_pasted_text). */ +function normalizePastedText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function countTextLines(text: string): number { + if (!text) return 1; + return text.split("\n").length; +} + +function shouldPlaceholderizePastedText(text: string): boolean { + return text.length >= TEXT_PASTE_CHAR_THRESHOLD || countTextLines(text) >= TEXT_PASTE_LINE_THRESHOLD; +} + +function buildPastedTextPlaceholder(pasteId: number, text: string): string { + const lineCount = countTextLines(text); + return lineCount <= 1 ? `[Pasted text #${pasteId}]` : `[Pasted text #${pasteId} +${lineCount} lines]`; +} + +function resolvePastedTextPlaceholders( + text: string, + pastedTexts: Map, +): string { + // Replace [Pasted text #N +lines] with actual content + return text.replace(/\[Pasted text #(\d+)(?: \+\d+ lines?)?\]/g, (match, id) => { + const actualText = pastedTexts.get(parseInt(id)); + return actualText ?? match; + }); +} + // ── UI Mode ───────────────────────────────────────────── type ChoiceConfig = Extract; type InputConfig = Extract; type ContentConfig = Extract; +type DebugConfig = Extract; export type UIMode = | { type: "normal" } @@ -39,7 +74,8 @@ export type UIMode = | { type: "mention_menu" } | { type: "panel_choice"; config: ChoiceConfig; index: number } | { type: "panel_input"; config: InputConfig } - | { type: "panel_content"; config: ContentConfig; scrollOffset: number }; + | { type: "panel_content"; config: ContentConfig; scrollOffset: number } + | { type: "panel_debug"; config: DebugConfig }; // ── Hook Return ───────────────────────────────────────── @@ -57,6 +93,7 @@ export interface ShellInputState { showSlashMenu: boolean; showMentionMenu: boolean; openPanel: (config: CommandPanelConfig) => void; + closePanel: () => void; } // ── Hook Options ──────────────────────────────────────── @@ -79,6 +116,8 @@ interface UseShellInputOptions { onOpenEditor: () => void; /** Called to push a notification to the UI */ onNotify: (title: string, body: string) => void; + /** Called to trigger a reload (e.g., on model change) */ + onReload: (sessionId: string, prefillText?: string) => void; } // ── Hotkey Constants ──────────────────────────────────── @@ -98,6 +137,7 @@ export function useShellInput({ onPlanModeToggle, onOpenEditor, onNotify, + onReload, }: UseShellInputOptions): ShellInputState { // ── Input value + history ── const { value, setValue, historyPrev, historyNext, addToHistory, isBrowsingHistory, exitHistory } = @@ -105,6 +145,16 @@ export function useShellInput({ const [cursorOffset, setCursorOffset] = useState(0); const [bufferedLines, setBufferedLines] = useState([]); + // ── Pasted text storage ── + const pastedTexts = useRef>(new Map()); + const nextPasteId = useRef(1); + + // ── Bracketed paste state machine ── + // When terminal sends \x1b[200~...text...\x1b[201~, Ink splits it into + // multiple input events. We buffer them and emit one paste at the end. + const pasteBuffering = useRef(false); + const pasteBuffer = useRef([]); + // ── Shell mode (toggled by Ctrl+X) ── const [shellMode, setShellMode] = useState(false); @@ -127,6 +177,10 @@ export function useShellInput({ const ctrlCCount = useRef(0); const ctrlCTimer = useRef | null>(null); + // ── Hotkey: Esc double-press tracking (for clearing input) ── + const escCount = useRef(0); + const escTimer = useRef | null>(null); + // ── Stable callback refs (avoid stale closures in useInput) ── const onExitRef = useRef(onExit); onExitRef.current = onExit; @@ -142,6 +196,8 @@ export function useShellInput({ onSubmitRef.current = onSubmit; const onSlashExecuteRef = useRef(onSlashExecute); onSlashExecuteRef.current = onSlashExecute; + const onReloadRef = useRef(onReload); + onReloadRef.current = onReload; // ── Derived: slash menu (suppressed when browsing history) ── const isSlashMode = @@ -213,6 +269,8 @@ export function useShellInput({ setMode({ type: "panel_input", config }); } else if (config.type === "content") { setMode({ type: "panel_content", config, scrollOffset: 0 }); + } else if (config.type === "debug") { + setMode({ type: "panel_debug", config }); } }, [setValue]); @@ -238,15 +296,44 @@ export function useShellInput({ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" }); const text = await new Response(proc.stdout).text(); if ((await proc.exited) === 0 && text) { - const next = value.slice(0, cursorOffset) + text + value.slice(cursorOffset); + // Check if we should placeholderize this text + let insertText = text; + if (shouldPlaceholderizePastedText(text)) { + const pasteId = nextPasteId.current; + nextPasteId.current += 1; + pastedTexts.current.set(pasteId, text); + insertText = buildPastedTextPlaceholder(pasteId, text); + } + + const next = value.slice(0, cursorOffset) + insertText + value.slice(cursorOffset); setValue(next); - setCursorOffset((prev) => prev + text.length); + setCursorOffset((prev) => prev + insertText.length); return; } } catch { /* try next */ } } }, [value, cursorOffset, setValue]); + // ── Insert pasted text (with optional placeholderization) ── + const insertPastedText = useCallback( + (pastedText: string) => { + exitHistory(); + // Normalize \r\n and \r to \n (terminal may send \r in raw mode) + const normalized = normalizePastedText(pastedText); + let text = normalized; + if (shouldPlaceholderizePastedText(normalized)) { + const pasteId = nextPasteId.current; + nextPasteId.current += 1; + pastedTexts.current.set(pasteId, normalized); + text = buildPastedTextPlaceholder(pasteId, normalized); + } + const next = value.slice(0, cursorOffset) + text + value.slice(cursorOffset); + setValue(next); + setCursorOffset((prev) => prev + text.length); + }, + [value, cursorOffset, setValue, exitHistory], + ); + // ── Text editing (exit history browsing on any edit) ── const insertChar = useCallback( (input: string) => { @@ -266,21 +353,67 @@ export function useShellInput({ } }, [value, cursorOffset, setValue, exitHistory]); - // ── Hotkey: handle interrupt (Ctrl+C / Esc in normal mode) ── - const handleInterrupt = useCallback(() => { + // ── Hotkey: handle Ctrl+C (interrupt / double-press exit) ── + const handleCtrlC = useCallback(() => { ctrlCCount.current += 1; if (ctrlCCount.current >= 2) { + // Ctrl+C pressed twice: force exit ctrlCCount.current = 0; if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); onExitRef.current(); return; } + // Ctrl+C pressed once: interrupt if (ctrlCTimer.current) clearTimeout(ctrlCTimer.current); ctrlCTimer.current = setTimeout(() => { ctrlCCount.current = 0; }, CTRLC_WINDOW_MS); onInterruptRef.current(); onNotifyRef.current("Ctrl-C", "Press Ctrl-C again to exit"); }, []); + // ── Hotkey: handle Escape (close panel / clear input on double-press) ── + const handleEscape = useCallback(() => { + const m = mode.type; + + // panel_input doesn't use useInputLayer, so Esc still comes here + if (m === "panel_input") { + setValue(""); + setCursorOffset(0); + setMode({ type: "normal" }); + escCount.current = 0; + if (escTimer.current) clearTimeout(escTimer.current); + return; + } + + // In normal mode: track double-press to clear input + if (m === "normal") { + escCount.current += 1; + if (escCount.current >= 2) { + // Esc pressed twice: clear input + escCount.current = 0; + if (escTimer.current) clearTimeout(escTimer.current); + setValue(""); + setCursorOffset(0); + setBufferedLines([]); + onNotifyRef.current("Input", "Cleared"); + return; + } + // Esc pressed once: interrupt streaming + if (escTimer.current) clearTimeout(escTimer.current); + escTimer.current = setTimeout(() => { escCount.current = 0; }, CTRLC_WINDOW_MS); + onInterruptRef.current(); + onNotifyRef.current("Esc", "Press Esc again to clear input"); + return; + } + + // If in a menu (slash/mention), just return to normal + if (m === "slash_menu" || m === "mention_menu") { + setMode({ type: "normal" }); + escCount.current = 0; + if (escTimer.current) clearTimeout(escTimer.current); + return; + } + }, [mode.type, setValue]); + // ── Submit handler ── const doSubmit = useCallback(() => { // Panel input mode @@ -324,11 +457,15 @@ export function useShellInput({ ? [...bufferedLines, value].join("\n") : value; const final = fullInput.trim(); if (!final) return; + + // Resolve paste placeholders before submitting + const resolved = resolvePastedTextPlaceholders(final, pastedTexts.current); + addToHistory(final); setValue(""); setCursorOffset(0); setBufferedLines([]); - onSubmitRef.current(final); + onSubmitRef.current(resolved); }, [ mode, value, commands, slashFilter, slashMenuIndex, mention.suggestions, mentionMenuIndex, applyMention, @@ -338,22 +475,53 @@ export function useShellInput({ // ── Single useInput dispatcher ── useInput( (input, key) => { + // ── Bracketed paste buffering (must be checked before any key handling) ── + // When paste mode is active, buffer everything except the end marker. + // Terminal sends \x1b[200~...text...\x1b[201~ which Ink splits into + // multiple events. The markers arrive as "[200~" / "[201~" (ESC stripped). + if (pasteBuffering.current) { + // Check for paste end marker + if (input && input.includes("[201~")) { + const beforeMarker = input.split("[201~")[0]; + if (beforeMarker) pasteBuffer.current.push(beforeMarker); + pasteBuffering.current = false; + const fullPaste = pasteBuffer.current.join(""); + pasteBuffer.current = []; + if (fullPaste) insertPastedText(fullPaste); + return; + } + // Buffer text content; restore newlines from return/enter keys + if (key.return || key.enter) { + pasteBuffer.current.push("\n"); + } else if (input) { + pasteBuffer.current.push(input); + } + return; + } + // Check for paste start marker (outside paste mode) + if (input && input.includes("[200~")) { + pasteBuffering.current = true; + pasteBuffer.current = []; + const afterMarker = input.split("[200~").slice(1).join("[200~"); + if (afterMarker) pasteBuffer.current.push(afterMarker); + return; + } + // ── Global keys: always fire regardless of input stack ── - // Ctrl+C: interrupt / double-press exit - if (key.ctrl && input === "c") { handleInterrupt(); return; } - // Esc: close panel or interrupt (global escape hatch) + // Ctrl+C: interrupt / double-press exit (separate from Esc) + if (key.ctrl && input === "c") { handleCtrlC(); return; } + + // Ctrl+D: direct exit (no double-press required) + if (key.ctrl && input === "d") { onExitRef.current(); return; } + + // Esc: if input stack layer is active, let it handle Esc first if (key.escape) { - const m = mode.type; - if (m === "panel_choice" || m === "panel_input" || m === "panel_content") { - setValue(""); - setCursorOffset(0); - setMode({ type: "normal" }); + const topHandler = getTopHandler(); + if (topHandler) { + topHandler(input, key); return; } - // If a stack layer is active, let it handle Esc too - const topHandler = getTopHandler(); - if (topHandler) { topHandler(input, key); return; } - handleInterrupt(); + handleEscape(); return; } @@ -393,26 +561,6 @@ export function useShellInput({ // ── Escape already handled above as global key ── - // ── Panel choice ── - if (m === "panel_choice" && mode.type === "panel_choice") { - if (key.upArrow) { setMode({ ...mode, index: Math.max(0, mode.index - 1) }); return; } - if (key.downArrow) { setMode({ ...mode, index: Math.min(mode.config.items.length - 1, mode.index + 1) }); return; } - if (key.return) { - const item = mode.config.items[mode.index]; - if (item) handlePanelResult(mode.config.onSelect(item.value)); - return; - } - return; - } - - // ── Panel content ── - if (m === "panel_content" && mode.type === "panel_content") { - const maxScroll = Math.max(0, mode.config.content.split("\n").length - 15); - if (key.upArrow) { setMode({ ...mode, scrollOffset: Math.max(0, mode.scrollOffset - 1) }); return; } - if (key.downArrow) { setMode({ ...mode, scrollOffset: Math.min(maxScroll, mode.scrollOffset + 1) }); return; } - return; - } - // ── Normal / slash / mention / panel_input ── if (key.shift && key.tab && m !== "panel_input") { @@ -431,7 +579,7 @@ export function useShellInput({ return; } - if (key.return) { doSubmit(); return; } + if (key.return || key.enter) { doSubmit(); return; } if (key.upArrow) { if (m === "mention_menu") setMentionMenuIndex((i) => Math.max(0, i - 1)); @@ -449,7 +597,22 @@ export function useShellInput({ if (key.leftArrow) { setCursorOffset((prev) => Math.max(0, prev - 1)); return; } if (key.rightArrow) { setCursorOffset((prev) => Math.min(value.length, prev + 1)); return; } if (key.backspace || key.delete) { backspace(); return; } - if (input) { insertChar(input); } + + // Handle input which may contain newlines from screen/tmux bulk paste + if (input) { + // Filter out CR/LF from the end and treat as Enter submission trigger + const hasNewline = /[\r\n]$/.test(input); + const cleanInput = input.replace(/[\r\n]+$/, ""); + + if (cleanInput) { + insertChar(cleanInput); + } + + // If input ended with newline, treat as Enter submission + if (hasNewline) { + doSubmit(); + } + } }, { isActive: !disabled }, ); @@ -468,5 +631,6 @@ export function useShellInput({ showSlashMenu, showMentionMenu, openPanel: openPanelConfig, + closePanel: useCallback(() => setMode({ type: "normal" }), []), }; } diff --git a/src/kimi_cli_ts/ui/shell/shell-commands.ts b/src/kimi_cli_ts/ui/shell/shell-commands.ts new file mode 100644 index 000000000..7c27eb6bf --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/shell-commands.ts @@ -0,0 +1,29 @@ +/** + * shell-commands.ts — Slash command management utilities. + * + * - Deduplication of slash commands + * - Aggregation of shell + extra commands + * - Shell-mode command whitelist + */ + +import type { SlashCommand } from "../../types.ts"; + +/** Commands available in shell mode ($ prompt). */ +export const SHELL_MODE_COMMANDS = new Set([ + "clear", "exit", "help", "theme", "version", "quit", "q", "cls", "reset", "h", "?", +]); + +/** Deduplicate commands by name, keeping first occurrence. */ +export function deduplicateCommands(commands: SlashCommand[]): SlashCommand[] { + const seen = new Map(); + for (const cmd of commands) if (!seen.has(cmd.name)) seen.set(cmd.name, cmd); + return [...seen.values()]; +} + +/** Merge shell + extra commands with deduplication. Shell commands take priority (matching Python). */ +export function createAllCommands( + shellCommands: SlashCommand[], + extraCommands: SlashCommand[] = [], +): SlashCommand[] { + return deduplicateCommands([...shellCommands, ...extraCommands]); +} diff --git a/src/kimi_cli_ts/ui/shell/shell-executor.ts b/src/kimi_cli_ts/ui/shell/shell-executor.ts new file mode 100644 index 000000000..d19bf434b --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/shell-executor.ts @@ -0,0 +1,49 @@ +/** + * shell-executor.ts — Shell and editor command execution. + * + * - runShellCommand: spawns a shell subprocess + * - openExternalEditor: opens $EDITOR and returns content + */ + +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Run a shell command in a subprocess. */ +export async function runShellCommand( + command: string, + notify: (title: string, body: string) => void, +): Promise { + const trimmed = command.trim(); + if (!trimmed) return; + if (trimmed.split(/\s+/)[0] === "cd") { + notify("Shell", "Warning: Directory changes are not preserved."); + return; + } + try { + const proc = Bun.spawn(["sh", "-c", trimmed], { stdio: ["inherit", "inherit", "inherit"], env: process.env }); + await proc.exited; + } catch (err: any) { + notify("Shell", `Failed: ${err?.message ?? err}`); + } +} + +/** Open an external editor, return submitted text via callback. */ +export async function openExternalEditor( + notify: (title: string, body: string) => void, + onSubmit?: (input: string) => void, +): Promise { + const editor = process.env.VISUAL || process.env.EDITOR || "vim"; + const tmpFile = join(tmpdir(), `kimi-input-${Date.now()}.md`); + try { + await Bun.write(tmpFile, ""); + const proc = Bun.spawn(editor.split(/\s+/).concat(tmpFile), { stdio: ["inherit", "inherit", "inherit"] }); + if ((await proc.exited) !== 0) { notify("Editor", "Editor exited with error"); return; } + const content = (await Bun.file(tmpFile).text()).trim(); + if (content && onSubmit) onSubmit(content); + else if (!content) notify("Editor", "Empty input, nothing submitted."); + } catch (err: any) { + notify("Editor", `Failed: ${err?.message ?? err}`); + } finally { + try { require("node:fs").unlinkSync(tmpFile); } catch { /* ignore */ } + } +} diff --git a/src/kimi_cli_ts/ui/shell/slash.ts b/src/kimi_cli_ts/ui/shell/slash.ts index b12db45f2..0ea5a3833 100644 --- a/src/kimi_cli_ts/ui/shell/slash.ts +++ b/src/kimi_cli_ts/ui/shell/slash.ts @@ -5,6 +5,7 @@ import type { SlashCommand, CommandPanelConfig } from "../../types"; import { getActiveTheme } from "../theme.ts"; +import type { Config } from "../../config.ts"; export type SlashCommandHandler = (args: string) => Promise; @@ -18,6 +19,14 @@ export interface ShellSlashContext { getSessionInfo?: () => { sessionDir: string; workDir: string; title: string } | null; /** Trigger a reload with a new session (and optional prefill text). */ triggerReload?: (sessionId: string, prefillText?: string) => void; + /** Current session ID for same-session reload (used by /clear). */ + sessionId?: string; + /** Show usage panel (called by /usage command). */ + showUsage?: (config: Config) => Promise; + /** Soul-level context clear: clears context + rewrites system prompt + sends status update. */ + soulClear?: () => Promise; + /** Get the current line count of the dynamic viewport below (prompt + bottom slot). */ + getDynamicViewportHeight?: () => number; } /** @@ -32,7 +41,26 @@ export function createShellSlashCommands( description: "Clear conversation history", aliases: ["cls", "reset"], handler: async () => { - ctx.clearMessages(); + // Match Python: soul clears context, then shell triggers same-session reload. + // Step 1: Clear the wire messages FIRST (before unmounting Ink) + ctx.clearMessages?.(); + // Step 2: Clear the soul context + await ctx.soulClear?.(); + // Step 3: Snapshot the dynamic viewport height BEFORE triggerReload unmounts Ink + const height = ctx.getDynamicViewportHeight?.() ?? 5; + // Step 4: Trigger same-session reload — calls inkUnmount() which does a final render. + if (ctx.triggerReload && ctx.sessionId) { + ctx.triggerReload(ctx.sessionId); + } + // Step 5: AFTER Ink unmount, erase the residual dynamic viewport lines it left behind, + // then write the feedback message in their place. + const eraseLine = "\x1b[2K"; + const cursorUp = "\x1b[A"; + process.stdout.write( + (eraseLine + cursorUp).repeat(height) + + eraseLine + "\r" + + "• The context has been cleared.\n" + ); }, }, { @@ -98,7 +126,8 @@ export function createShellSlashCommands( name: "version", description: "Show version information", handler: async () => { - ctx.pushNotification("Version", "kimi-cli v2.0.0 (TypeScript)"); + const { VERSION } = await import("../../constant.ts"); + return `kimi, version ${VERSION}`; }, }, { @@ -192,8 +221,22 @@ export function createShellSlashCommands( }); } - ctx.pushNotification("Undo", `Forked at turn ${turnIndex}. Switching to new session...`); + // Save old session ID and viewport height before reload + const oldSessionId = ctx.sessionId; + const height = ctx.getDynamicViewportHeight?.() ?? 5; + ctx.triggerReload!(newSessionId, userText); + + // After Ink unmount: erase viewport + write green message + resume hint + const eraseLine = "\x1b[2K"; + const cursorUp = "\x1b[A"; + process.stdout.write( + (eraseLine + cursorUp).repeat(height) + + eraseLine + "\r" + + `\x1b[32mForked at turn ${turnIndex}. Switching to new session...\x1b[39m\n` + + "\n" + + `To resume this session: kimi -r ${oldSessionId}\n` + ); } catch (err: any) { ctx.pushNotification("Undo", `Error: ${err.message ?? String(err)}`); } @@ -224,13 +267,49 @@ export function createShellSlashCommands( sourceTitle: info.title, }); - ctx.pushNotification("Fork", "Session forked. Switching to new session..."); + // Save old session ID before reload + const oldSessionId = ctx.sessionId; + + // Snapshot the dynamic viewport height BEFORE triggerReload unmounts Ink + const height = ctx.getDynamicViewportHeight?.() ?? 5; + + // Trigger reload — calls inkUnmount() internally ctx.triggerReload(newSessionId); + + // AFTER Ink unmount: erase residual viewport lines, then write feedback. + // Matches Python: green message + blank line + resume hint. + const eraseLine = "\x1b[2K"; + const cursorUp = "\x1b[A"; + process.stdout.write( + (eraseLine + cursorUp).repeat(height) + + eraseLine + "\r" + + "\x1b[32mSession forked. Switching to new session...\x1b[39m\n" + + "\n" + + `To resume this session: kimi -r ${oldSessionId}\n` + ); } catch (err: any) { ctx.pushNotification("Fork", `Error: ${err.message ?? String(err)}`); } }, }, + { + name: "usage", + description: "Display API usage and quota information", + aliases: ["status"], + handler: async () => { + if (!ctx.showUsage) { + ctx.pushNotification("Usage", "Usage panel not available."); + return; + } + try { + const { loadConfig } = await import("../../config.ts"); + const { config } = await loadConfig(); + await ctx.showUsage(config); + } catch (err: any) { + ctx.pushNotification("Usage", `Error: ${err.message ?? String(err)}`); + } + }, + }, ]; } @@ -266,6 +345,8 @@ export function findSlashCommand( ); } +const SKILL_COMMAND_PREFIX = "skill:"; + function formatHelp(commands: SlashCommand[]): string { const lines = [ "Kimi Code CLI — Help", @@ -279,25 +360,42 @@ function formatHelp(commands: SlashCommand[]): string { " Ctrl+D Exit", " Ctrl+C Interrupt", "", - "Slash Commands:", ]; - // Deduplicate by name and sort + // Separate skills from regular commands const seen = new Set(); - const sorted = commands - .filter((c) => { - if (seen.has(c.name)) return false; - seen.add(c.name); - return true; - }) - .sort((a, b) => a.name.localeCompare(b.name)); + const regularCmds: SlashCommand[] = []; + const skillCmds: SlashCommand[] = []; - for (const cmd of sorted) { + for (const cmd of commands) { + if (seen.has(cmd.name)) continue; + seen.add(cmd.name); + if (cmd.name.startsWith(SKILL_COMMAND_PREFIX)) { + skillCmds.push(cmd); + } else { + regularCmds.push(cmd); + } + } + + regularCmds.sort((a, b) => a.name.localeCompare(b.name)); + skillCmds.sort((a, b) => a.name.localeCompare(b.name)); + + lines.push("Slash Commands:"); + for (const cmd of regularCmds) { const aliases = cmd.aliases?.length ? `, /${cmd.aliases.join(", /")}` : ""; const nameStr = `/${cmd.name}${aliases}`; lines.push(` ${nameStr.padEnd(22)} ${cmd.description}`); } + if (skillCmds.length > 0) { + lines.push(""); + lines.push("Skills:"); + for (const cmd of skillCmds) { + const nameStr = `/${cmd.name}`; + lines.push(` ${nameStr.padEnd(30)} ${cmd.description}`); + } + } + lines.push(""); return lines.join("\n"); } diff --git a/src/kimi_cli_ts/ui/shell/usePromptSymbol.ts b/src/kimi_cli_ts/ui/shell/usePromptSymbol.ts new file mode 100644 index 000000000..ef230f14e --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/usePromptSymbol.ts @@ -0,0 +1,25 @@ +/** + * usePromptSymbol.ts — Derives the prompt symbol from UI state. + * + * Matches Python's _render_agent_prompt_label logic: + * shell mode → "$" + * plan mode → "📋" + * thinking model (capability flag, not streaming state) → "💫" + * otherwise → "✨" + */ + +import type { UIMode } from "./input-state.ts"; + +/** Compute the prompt symbol based on current mode and state. */ +export function getPromptSymbol( + mode: UIMode, + shellMode: boolean, + thinking: boolean, + planMode: boolean, +): string { + if (mode.type === "panel_input") return "▸ "; + if (shellMode) return "$ "; + if (planMode) return "📋 "; + if (thinking) return "💫 "; + return "✨ "; +} diff --git a/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts new file mode 100644 index 000000000..4602ac4aa --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts @@ -0,0 +1,216 @@ +/** + * useShellCallbacks.ts — Callback bundle for useShellInput and approval handling. + * + * Extracts all callback logic from Shell.tsx into a single hook that returns + * the options object for useShellInput plus handleApprovalResponse. + */ + +import { useCallback, useRef } from "react"; +import { parseSlashCommand, findSlashCommand } from "./slash.ts"; +import { runShellCommand, openExternalEditor } from "./shell-executor.ts"; +import { SHELL_MODE_COMMANDS } from "./shell-commands.ts"; +import type { WireUIEvent } from "./events.ts"; +import type { ApprovalRequest, ApprovalResponseKind } from "../../wire/types.ts"; +import type { SlashCommand, CommandPanelConfig } from "../../types.ts"; + +interface WireLike { + pushEvent: (event: WireUIEvent) => void; + isStreaming: boolean; + pendingApproval: ApprovalRequest | null; +} + +export interface UseShellCallbacksOptions { + wire: WireLike; + allCommands: SlashCommand[]; + shellCommands: SlashCommand[]; + exit: () => void; + pushNotification: (title: string, body: string) => void; + /** External onSubmit from ShellProps — sends input to the agent loop. */ + onSubmitExternal?: (input: string) => void; + onInterruptExternal?: () => void; + onPlanModeToggleExternal?: () => Promise; + onApprovalResponseExternal?: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => void; + /** External onReload from ShellProps — triggers reload with new session. */ + onReloadExternal?: (sessionId: string, prefillText?: string) => void; +} + +export interface ShellCallbacks { + /** Callbacks to spread into useShellInput options. */ + inputCallbacks: { + onSubmit: (input: string) => void; + onSlashExecute: (cmd: SlashCommand) => void; + onExit: () => void; + onInterrupt: () => void; + onPlanModeToggle: () => void; + onOpenEditor: () => void; + onNotify: (title: string, body: string) => void; + onReload: (sessionId: string, prefillText?: string) => void; + }; + /** Approval response handler for . */ + handleApprovalResponse: (decision: ApprovalResponseKind, feedback?: string) => void; + /** Set this after useShellInput returns — provides access to shellMode/openPanel. */ + setInputStateAccessor: (accessor: InputStateAccessor) => void; +} + +interface InputStateAccessor { + shellMode: boolean; + openPanel: (config: CommandPanelConfig) => void; +} + +/** + * Commands that are purely UI-side and never pass through soul.run(). + * Mirrors Python: shell_slash_registry commands that are NOT also soul commands. + */ +const PURE_SHELL_COMMANDS = new Set(["exit", "quit", "q", "theme"]); + +export function useShellCallbacks({ + wire, + allCommands, + shellCommands, + exit, + pushNotification, + onSubmitExternal, + onInterruptExternal, + onPlanModeToggleExternal, + onApprovalResponseExternal, + onReloadExternal, +}: UseShellCallbacksOptions): ShellCallbacks { + // Use a ref so onSubmit can access inputState without circular dependency. + const inputRef = useRef({ shellMode: false, openPanel: () => {} }); + const setInputStateAccessor = useCallback((accessor: InputStateAccessor) => { + inputRef.current = accessor; + }, []); + + const onSubmit = useCallback( + (input: string) => { + const parsed = parseSlashCommand(input); + if (parsed) { + // Shell mode: only whitelisted commands + if (inputRef.current.shellMode && !SHELL_MODE_COMMANDS.has(parsed.name)) { + wire.pushEvent({ type: "notification", title: "Shell mode", body: `/${parsed.name} is not available in shell mode.` }); + return; + } + + // Check if this is a pure shell command (handled locally, never reaches soul) + // Mirrors Python: shell_slash_registry.find_command(name) is not None + const shellCmd = findSlashCommand(shellCommands, parsed.name); + if (shellCmd && PURE_SHELL_COMMANDS.has(parsed.name)) { + if (shellCmd.panel && !parsed.args) { + const pc = shellCmd.panel(); + if (pc) { inputRef.current.openPanel(pc); return; } + } + const result = shellCmd.handler(parsed.args); + if (result && typeof result.then === "function") { + result.then((feedback: void | string) => { + if (typeof feedback === "string") { + wire.pushEvent({ type: "slash_result", text: feedback }); + } + }); + } + return; + } + + // Check if it's a known command (for panel support + unknown detection) + const knownCmd = findSlashCommand(allCommands, parsed.name); + if (knownCmd) { + // Panel support: if command has a panel and no args, open it locally + if (knownCmd.panel && !parsed.args) { + const pc = knownCmd.panel(); + if (pc) { inputRef.current.openPanel(pc); return; } + } + // Route to soul.run() — mirrors Python: await self.run_soul_command(raw_input) + onSubmitExternal?.(input); + return; + } + + // Unknown command + wire.pushEvent({ type: "notification", title: "Unknown command", body: `/${parsed.name} is not recognized. Type /help.` }); + return; + } + if (inputRef.current.shellMode) { runShellCommand(input, pushNotification); return; } + onSubmitExternal?.(input); + }, + [allCommands, shellCommands, onSubmitExternal, wire, pushNotification], + ); + + const onSlashExecute = useCallback((cmd: SlashCommand) => { + const result = cmd.handler(""); + if (result && typeof result.then === "function") { + result.then((feedback: void | string) => { + if (typeof feedback === "string") { + wire.pushEvent({ type: "slash_result", text: feedback }); + } + }); + } + }, [wire]); + + const onExit = useCallback(() => exit(), [exit]); + + const onInterrupt = useCallback(() => { + if (wire.isStreaming) { + onInterruptExternal?.(); + wire.pushEvent({ type: "error", message: "Interrupted by user" }); + } + }, [wire, onInterruptExternal]); + + const onPlanModeToggle = useCallback(() => { + onPlanModeToggleExternal?.() + .then((s) => pushNotification("Plan mode", s ? "ON" : "OFF")) + .catch((e: unknown) => pushNotification("Plan mode", `Error: ${String(e)}`)); + }, [onPlanModeToggleExternal, pushNotification]); + + const onOpenEditor = useCallback( + () => openExternalEditor(pushNotification, onSubmitExternal), + [pushNotification, onSubmitExternal], + ); + + const onReload = useCallback( + (sessionId: string, prefillText?: string) => { + onReloadExternal?.(sessionId, prefillText); + }, + [onReloadExternal], + ); + + // Track which approval request IDs we've already responded to, so that + // a second keypress (e.g. number key followed by Enter) does not + // accidentally approve the NEXT queued request. We use a Set rather + // than a single ref because React state may update between keystrokes, + // making the current pendingApproval point to the next queued request. + const respondedIdsRef = useRef(new Set()); + // Debounce: ignore rapid-fire responses within a short window to prevent + // a stale Enter keypress from approving the next request in the queue. + const lastRespondTimeRef = useRef(0); + const APPROVAL_DEBOUNCE_MS = 400; + + const handleApprovalResponse = useCallback( + (decision: ApprovalResponseKind, feedback?: string) => { + const current = wire.pendingApproval; + if (!current) return; + // Guard: don't respond to a request we already handled + if (respondedIdsRef.current.has(current.id)) return; + // Debounce: ignore if too soon after last response + const now = Date.now(); + if (now - lastRespondTimeRef.current < APPROVAL_DEBOUNCE_MS) return; + respondedIdsRef.current.add(current.id); + lastRespondTimeRef.current = now; + onApprovalResponseExternal?.(current.id, decision, feedback); + wire.pushEvent({ type: "approval_response", requestId: current.id, response: decision }); + }, + [wire.pendingApproval, onApprovalResponseExternal, wire], + ); + + return { + inputCallbacks: { + onSubmit, + onSlashExecute, + onExit, + onInterrupt, + onPlanModeToggle, + onOpenEditor, + onNotify: pushNotification, + onReload, + }, + handleApprovalResponse, + setInputStateAccessor, + }; +} diff --git a/src/kimi_cli_ts/ui/shell/useShellLayout.ts b/src/kimi_cli_ts/ui/shell/useShellLayout.ts new file mode 100644 index 000000000..085415811 --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/useShellLayout.ts @@ -0,0 +1,36 @@ +/** + * useShellLayout.ts — Terminal height tracking and static items computation. + */ + +import React, { useEffect, useState } from "react"; +import { useStdout } from "ink"; +import type { UIMessage } from "./events.ts"; + +type WelcomeItem = { id: string; _isWelcome: true }; +type StaticItem = WelcomeItem | UIMessage; + +/** Prepare the static (already-flushed) items for Ink's . */ +export function buildStaticItems(messages: UIMessage[], isStreaming: boolean): StaticItem[] { + const welcome: WelcomeItem = { id: "__welcome__", _isWelcome: true as const }; + const msgs = isStreaming ? messages.slice(0, -1) : messages; + return [welcome, ...msgs]; +} + +/** Hook that tracks terminal height and exposes layout helpers. */ +export function useShellLayout(messages: UIMessage[], isStreaming: boolean) { + const { stdout } = useStdout(); + const [termHeight, setTermHeight] = useState(stdout?.rows || 24); + + useEffect(() => { + const onResize = () => setTermHeight(stdout?.rows || 24); + stdout?.on("resize", onResize); + return () => { stdout?.off("resize", onResize); }; + }, [stdout]); + + const staticItems = React.useMemo( + () => buildStaticItems(messages, isStreaming), + [messages, isStreaming], + ); + + return { termHeight, staticItems }; +} diff --git a/src/kimi_cli_ts/ui/theme.ts b/src/kimi_cli_ts/ui/theme.ts index 324abcac1..bb38a5ae9 100644 --- a/src/kimi_cli_ts/ui/theme.ts +++ b/src/kimi_cli_ts/ui/theme.ts @@ -102,6 +102,7 @@ export interface MessageColors { system: string; tool: string; error: string; + darkRed: string; dim: string; thinking: string; highlight: string; @@ -111,10 +112,11 @@ const MESSAGE_DARK: MessageColors = { user: "#56d364", // Rich "green" assistant: "#e0e0e0", // bright text for readability system: "#d670d6", // Rich "magenta" - tool: "#C8C5F4", // Rich "blue" — lavender as seen in Python + tool: "#0000d7", // Rich "blue" — ANSI basic blue (\e[34m) error: "#ff7b72", // Rich "dark_red" - dim: "#808080", // Rich "grey50" - thinking: "#7c8594", + darkRed: "#870000", // Rich "dark_red" — 256-color idx 88, for rejected/error bullets + dim: "#b2b2b2", // Rich "grey50" — 256-color idx 244 + thinking: "#b2b2b2", // Rich "grey50" — matches Python's grey50 italic highlight: "#56d364", // Rich "green" }; @@ -124,6 +126,7 @@ const MESSAGE_LIGHT: MessageColors = { system: "#7c3aed", // dark purple tool: "#1d4ed8", // dark blue error: "#dc2626", + darkRed: "#991b1b", // dark red for light theme dim: "#6b7280", thinking: "#6b7280", highlight: "#166534", diff --git a/src/kimi_cli_ts/wire/types.ts b/src/kimi_cli_ts/wire/types.ts index 5b3c01a84..9886e571d 100644 --- a/src/kimi_cli_ts/wire/types.ts +++ b/src/kimi_cli_ts/wire/types.ts @@ -172,6 +172,7 @@ export const StatusUpdate = z.object({ token_usage: TokenUsage.nullable().default(null), message_id: z.string().nullable().default(null), plan_mode: z.boolean().nullable().default(null), + yolo: z.boolean().nullable().default(null), mcp_status: MCPStatusSnapshot.nullable().default(null), }); export type StatusUpdate = z.infer; From 090fa31e529adc964a7a98af7c8df1f987a78381 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sun, 5 Apr 2026 17:56:14 +0800 Subject: [PATCH 18/28] feat(ts): refactor kimisoul loop, enhance grep/ask_user tools, simplify UI panels Refactor the main agent loop in kimisoul.ts for clarity, enhance grep tool with advanced options and ask_user with multi-question support, simplify QuestionPanel/SelectionPanel UI, and improve subagent runner and CLI option handling. --- bun.lock | 19 + src/kimi_cli_ts/app.ts | 5 +- src/kimi_cli_ts/cli/index.ts | 263 +++++----- src/kimi_cli_ts/cli/plugin.ts | 4 +- src/kimi_cli_ts/soul/agent.ts | 9 - src/kimi_cli_ts/soul/index.ts | 282 +++++++++++ src/kimi_cli_ts/soul/kimisoul.ts | 450 ++++++++---------- src/kimi_cli_ts/subagents/runner.ts | 107 ++--- src/kimi_cli_ts/tools/ask_user/ask_user.ts | 144 +++++- src/kimi_cli_ts/tools/file/grep.ts | 178 ++++++- src/kimi_cli_ts/tools/file/read.ts | 2 +- src/kimi_cli_ts/ui/CLAUDE.md | 201 +++++++- src/kimi_cli_ts/ui/components/PanelShell.tsx | 4 +- src/kimi_cli_ts/ui/components/TitleBox.tsx | 196 ++++++++ src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 6 +- src/kimi_cli_ts/ui/hooks/useWire.ts | 45 +- src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx | 2 +- src/kimi_cli_ts/ui/shell/Prompt.tsx | 6 +- src/kimi_cli_ts/ui/shell/QuestionPanel.tsx | 388 +++++---------- src/kimi_cli_ts/ui/shell/SelectionPanel.tsx | 154 +----- src/kimi_cli_ts/ui/shell/SetupWizard.tsx | 8 +- src/kimi_cli_ts/ui/shell/Shell.tsx | 19 +- src/kimi_cli_ts/ui/shell/input-state.ts | 4 +- src/kimi_cli_ts/ui/shell/slash.ts | 47 +- src/kimi_cli_ts/ui/shell/useSelectionInput.ts | 232 +++++++++ src/kimi_cli_ts/ui/shell/useShellCallbacks.ts | 76 ++- src/kimi_cli_ts/ui/theme.ts | 4 +- src/kimi_cli_ts/wire/file.ts | 8 +- src/kimi_cli_ts/wire/serde.ts | 14 +- 29 files changed, 1914 insertions(+), 963 deletions(-) create mode 100644 src/kimi_cli_ts/soul/index.ts create mode 100644 src/kimi_cli_ts/ui/components/TitleBox.tsx create mode 100644 src/kimi_cli_ts/ui/shell/useSelectionInput.ts diff --git a/bun.lock b/bun.lock index 2b62cb02b..a3ff0e508 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "ink": "^6.8.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", + "log4js": "^6.9.1", "micromatch": "^4.0.8", "nanoid": "^5.1.7", "openai": "^6.33.0", @@ -143,6 +144,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://mirrors.tencent.com/npm/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-format": ["date-format@4.0.14", "https://mirrors.tencent.com/npm/date-format/-/date-format-4.0.14.tgz", {}, "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg=="], + "debug": ["debug@4.4.3", "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "https://mirrors.tencent.com/npm/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -165,8 +168,12 @@ "fill-range": ["fill-range@7.1.1", "https://mirrors.tencent.com/npm/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "flatted": ["flatted@3.4.2", "https://mirrors.tencent.com/npm/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "https://mirrors.tencent.com/npm/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fs-extra": ["fs-extra@8.1.0", "https://mirrors.tencent.com/npm/fs-extra/-/fs-extra-8.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "gaxios": ["gaxios@7.1.4", "https://mirrors.tencent.com/npm/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], "gcp-metadata": ["gcp-metadata@8.1.2", "https://mirrors.tencent.com/npm/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], @@ -181,6 +188,8 @@ "google-logging-utils": ["google-logging-utils@1.1.3", "https://mirrors.tencent.com/npm/google-logging-utils/-/google-logging-utils-1.1.3.tgz", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://mirrors.tencent.com/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://mirrors.tencent.com/npm/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "ignore": ["ignore@7.0.5", "https://mirrors.tencent.com/npm/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -209,10 +218,14 @@ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://mirrors.tencent.com/npm/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "jsonfile": ["jsonfile@4.0.0", "https://mirrors.tencent.com/npm/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jwa": ["jwa@2.0.1", "https://mirrors.tencent.com/npm/jwa/-/jwa-2.0.1.tgz", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "https://mirrors.tencent.com/npm/jws/-/jws-4.0.1.tgz", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "log4js": ["log4js@6.9.1", "https://mirrors.tencent.com/npm/log4js/-/log4js-6.9.1.tgz", { "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", "flatted": "^3.2.7", "rfdc": "^1.3.0", "streamroller": "^3.1.5" } }, "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g=="], + "long": ["long@5.3.2", "https://mirrors.tencent.com/npm/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "merge2": ["merge2@1.4.1", "https://mirrors.tencent.com/npm/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -255,6 +268,8 @@ "reusify": ["reusify@1.1.0", "https://mirrors.tencent.com/npm/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "https://mirrors.tencent.com/npm/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "run-parallel": ["run-parallel@1.2.0", "https://mirrors.tencent.com/npm/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -273,6 +288,8 @@ "stack-utils": ["stack-utils@2.0.6", "https://mirrors.tencent.com/npm/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "streamroller": ["streamroller@3.1.5", "https://mirrors.tencent.com/npm/streamroller/-/streamroller-3.1.5.tgz", { "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", "fs-extra": "^8.1.0" } }, "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw=="], + "string-width": ["string-width@8.2.0", "https://mirrors.tencent.com/npm/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "strip-ansi": ["strip-ansi@7.2.0", "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -293,6 +310,8 @@ "unicorn-magic": ["unicorn-magic@0.4.0", "https://mirrors.tencent.com/npm/unicorn-magic/-/unicorn-magic-0.4.0.tgz", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], + "universalify": ["universalify@0.1.2", "https://mirrors.tencent.com/npm/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://mirrors.tencent.com/npm/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "widest-line": ["widest-line@6.0.0", "https://mirrors.tencent.com/npm/widest-line/-/widest-line-6.0.0.tgz", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], diff --git a/src/kimi_cli_ts/app.ts b/src/kimi_cli_ts/app.ts index 0836bacb1..e16f8de5a 100644 --- a/src/kimi_cli_ts/app.ts +++ b/src/kimi_cli_ts/app.ts @@ -10,7 +10,7 @@ import { Session } from "./session.ts"; import { HookEngine } from "./hooks/engine.ts"; import { Context } from "./soul/context.ts"; import { Runtime, Agent, loadAgent } from "./soul/agent.ts"; -import { KimiSoul, type SoulCallbacks } from "./soul/kimisoul.ts"; +import { KimiSoul } from "./soul/kimisoul.ts"; import { logger } from "./utils/logging.ts"; // ── KimiCLI ───────────────────────────────────────── @@ -53,7 +53,7 @@ export class KimiCLI { sessionId?: string; continueSession?: boolean; maxStepsPerTurn?: number; - callbacks?: SoulCallbacks; + // callbacks removed — now uses Wire architecture }): Promise { const workDir = opts.workDir ?? process.cwd(); @@ -243,7 +243,6 @@ export class KimiCLI { const soul = new KimiSoul({ agent, context, - callbacks: opts.callbacks ?? {}, }); // Wire slash commands diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index 64ae0d349..3383280ea 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -7,10 +7,13 @@ import { Command } from "commander"; import React from "react"; import { render } from "ink"; import { KimiCLI } from "../app.ts"; -import type { SoulCallbacks } from "../soul/kimisoul.ts"; +import { runSoul, type UILoopFn } from "../soul/index.ts"; +import type { ContentPart } from "../types.ts"; +import { WireFile } from "../wire/file.ts"; import { Shell } from "../ui/shell/Shell.tsx"; import type { WireUIEvent } from "../ui/shell/events.ts"; import type { ApprovalResponseKind } from "../wire/types.ts"; +import { QueueShutDown } from "../utils/queue.ts"; import chalk from "chalk"; import { patchInkLogUpdate } from "../ui/renderer/index.ts"; @@ -276,22 +279,34 @@ program try { if (options.print) { - // ── Print mode: callbacks write directly to stdout/stderr ── - const callbacks: SoulCallbacks = { - onTextDelta: (text) => process.stdout.write(text), - onThinkDelta: (text) => process.stderr.write(chalk.dim(text)), - onError: (err) => - process.stderr.write(chalk.red(`[ERROR] ${err.message}\n`)), - onTurnEnd: () => process.stdout.write("\n"), - onStatusUpdate: (status) => { - if (options.verbose && status.tokenUsage) { - process.stderr.write( - chalk.dim( - `[tokens] in=${status.tokenUsage.inputTokens} out=${status.tokenUsage.outputTokens}\n`, - ), - ); + // ── Print mode: UILoopFn writes directly to stdout/stderr ── + const printUILoopFn: UILoopFn = async (wire) => { + const uiSide = wire.uiSide(true); + while (true) { + let msg: any; + try { + msg = await uiSide.receive(); + } catch (err) { + if (err instanceof QueueShutDown) break; + throw err; } - }, + const t = msg.__wireType ?? ""; + if (t === "TextPart") { + process.stdout.write(msg.text); + } else if (t === "ThinkPart") { + process.stderr.write(chalk.dim(msg.text)); + } else if (t === "TurnEnd") { + process.stdout.write("\n"); + } else if (t === "StatusUpdate") { + if (options.verbose && msg.token_usage) { + process.stderr.write( + chalk.dim( + `[tokens] in=${msg.token_usage.inputTokens} out=${msg.token_usage.outputTokens}\n`, + ), + ); + } + } + } }; const app = await KimiCLI.create({ @@ -305,13 +320,16 @@ program sessionId: resolvedSessionId, continueSession: options.continue, maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, - callbacks, }); - // Wire subagent event sink for print mode (output logging only) - app.agent.runtime.subagentEventSink = () => {}; - - if (prompt) await app.runPrint(prompt); + if (prompt) { + const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; + const cancelController = new AbortController(); + await runSoul(app.soul, prompt, printUILoopFn, cancelController, { + wireFile, + runtime: app.soul.runtime, + }); + } printResumeHint(app.session); await app.shutdown(); } else if (options.wire) { @@ -327,7 +345,6 @@ program sessionId: resolvedSessionId, continueSession: options.continue, maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, - callbacks: {}, }); // Wire mode not yet implemented console.error("Wire mode is not yet implemented."); @@ -352,97 +369,78 @@ program // inkUnmount will be set after render() — captured here so callbacks can trigger reload let inkUnmountFn: (() => void) | null = null; - // Helper: trigger reload from anywhere (SoulCallbacks or Shell prop) + // Helper: trigger reload from anywhere (UILoopFn or Shell prop) const triggerReload = (sessionId: string, prefillText?: string) => { pendingReload = { sessionId, prefillText }; inkUnmountFn?.(); }; - const callbacks: SoulCallbacks = { - onTurnBegin: (userInput) => { - const text = - typeof userInput === "string" - ? userInput - : "[complex input]"; - pushEvent?.({ type: "turn_begin", userInput: text }); - }, - onTurnEnd: () => { - pushEvent?.({ type: "turn_end" }); - }, - onStepBegin: (n) => { - pushEvent?.({ type: "step_begin", n }); - }, - onTextDelta: (text) => { - pushEvent?.({ type: "text_delta", text }); - }, - onThinkDelta: (text) => { - pushEvent?.({ type: "think_delta", text }); - }, - onToolCall: (tc) => { - pushEvent?.({ - type: "tool_call", - id: tc.id, - name: tc.name, - arguments: tc.arguments, - }); - }, - onToolResult: (toolCallId, result) => { - // Build display blocks — include brief for rejected-with-feedback - const display: unknown[] = result.display ?? []; - if (result.isError && result.message?.includes("User feedback:") && display.length === 0) { - const match = result.message.match(/User feedback: (.+)$/); - if (match) { - display.push({ type: "brief", brief: `Rejected: ${match[1]}` }); + // Create a UILoopFn that translates Wire messages → WireUIEvent for Shell + const createShellUILoopFn = (pe: (event: WireUIEvent) => void): UILoopFn => { + return async (wire) => { + const uiSide = wire.uiSide(true); + while (true) { + let msg: any; + try { + msg = await uiSide.receive(); + } catch (err) { + if (err instanceof QueueShutDown) break; + throw err; + } + const t = msg.__wireType ?? ""; + if (t === "TurnBegin") { + const raw = msg.user_input; + let userInput: string; + if (typeof raw === "string") { + userInput = raw; + } else if (Array.isArray(raw)) { + userInput = (raw as any[]) + .filter((p: any) => p?.type === "text" && typeof p.text === "string") + .map((p: any) => p.text) + .join("") || "[complex input]"; + } else { + userInput = "[complex input]"; + } + // Slash commands: skip user input echo (matches Python — visualize.py + // only does flush_content() for TurnBegin, never renders user message). + // Pass empty string so useWire still creates assistant message container. + pe({ type: "turn_begin", userInput: userInput.startsWith("/") ? "" : userInput }); + } else if (t === "TurnEnd") { + pe({ type: "turn_end" }); + } else if (t === "StepBegin") { + pe({ type: "step_begin", n: msg.n }); + } else if (t === "TextPart") { + pe({ type: "text_delta", text: msg.text }); + } else if (t === "ThinkPart") { + pe({ type: "think_delta", text: msg.text }); + } else if (t === "ToolCall") { + pe({ type: "tool_call", id: msg.id, name: msg.name, arguments: msg.arguments }); + } else if (t === "ToolResult") { + pe({ type: "tool_result", toolCallId: msg.tool_call_id, result: msg }); + } else if (t === "StatusUpdate") { + pe({ type: "status_update", status: msg }); + } else if (t === "CompactionBegin") { + pe({ type: "compaction_begin" }); + } else if (t === "CompactionEnd") { + pe({ type: "compaction_end" }); + } else if (t === "Notification") { + pe({ type: "notification", title: msg.title, body: msg.body }); + } else if (t === "SubagentEvent") { + pe({ type: "subagent_event", parentToolCallId: msg.parent_tool_call_id, agentId: msg.agent_id, subagentType: msg.subagent_type, event: msg.event }); + } else if (t === "ApprovalRequest") { + pe({ type: "approval_request", request: msg }); + } else if (t === "QuestionRequest") { + pe({ type: "question_request", request: msg }); + } else if (t === "StepInterrupted") { + pe({ type: "step_interrupted" }); } - } else if (result.isError && result.message?.includes("rejected by the user") && display.length === 0) { - display.push({ type: "brief", brief: "Rejected by user" }); } - pushEvent?.({ - type: "tool_result", - toolCallId, - result: { - tool_call_id: toolCallId, - return_value: { - isError: result.isError, - output: result.output, - message: result.message, - }, - display, - }, - }); - }, - onStatusUpdate: (status) => { - pushEvent?.({ - type: "status_update", - status: { - context_usage: status.contextUsage ?? null, - context_tokens: status.contextTokens ?? null, - max_context_tokens: status.maxContextTokens ?? null, - token_usage: status.tokenUsage ?? null, - message_id: null, - plan_mode: status.planMode ?? null, - yolo: status.yoloEnabled ?? null, - mcp_status: null, - }, - }); - }, - onCompactionBegin: () => { - pushEvent?.({ type: "compaction_begin" }); - }, - onCompactionEnd: () => { - pushEvent?.({ type: "compaction_end" }); - }, - onError: (err) => { - pushEvent?.({ type: "error", message: err.message }); - }, - onNotification: (title, body) => { - pushEvent?.({ type: "notification", title, body }); - }, - onReload: (sessionId, prefillText) => { - triggerReload(sessionId, prefillText); - }, + }; }; + // Cancel controller for the current soul run — shared between onSubmit and onInterrupt + let currentCancelController: AbortController | null = null; + const app = await KimiCLI.create({ workDir: options.workDir, additionalDirs: options.addDir, @@ -454,15 +452,8 @@ program continueSession: !currentSessionId ? options.continue : undefined, resumed: !!currentSessionId, maxStepsPerTurn: options.maxStepsPerTurn ?? options.maxRetriesPerStep, - callbacks, }); - // Wire subagent event sink so subagent runner can forward events to UI. - // This bridges the subagent's SoulCallbacks → parent pushEvent as SubagentEvent. - app.agent.runtime.subagentEventSink = (event) => { - pushEvent?.(event as any); - }; - // Patch Ink's log-update with our cell-level diffing renderer. // This must happen before render() creates the Ink instance. patchInkLogUpdate(); @@ -494,13 +485,27 @@ program thinking: app.soul.thinking, yolo: options.yolo ?? false, prefillText: currentPrefillText, - onSubmit: (input: string | ContentPart[]) => { - app.soul.run(input).catch((err: Error) => { - pushEvent?.({ type: "error", message: err.message }); - }); + onSubmit: async (input: string | ContentPart[]) => { + const cancelController = new AbortController(); + currentCancelController = cancelController; + const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; + try { + await runSoul(app.soul, input, createShellUILoopFn(pushEvent!), cancelController, { + wireFile, + runtime: app.soul.runtime, + }); + } catch (err) { + if (err instanceof Reload) { + // Soul handler requested reload (e.g., /model, /sessions panel) + // Translate to shell-level triggerReload, matching Python pattern + triggerReload(err.sessionId ?? app.session.id, err.prefillText ?? undefined); + return; + } + pushEvent?.({ type: "error", message: err instanceof Error ? err.message : String(err) }); + } }, onInterrupt: () => { - app.soul.abort(); + currentCancelController?.abort(); }, onPlanModeToggle: async () => { return app.soul.togglePlanModeFromManual(); @@ -538,7 +543,9 @@ program unmount = inkUnmount; inkUnmountFn = inkUnmount; - // Start background task to forward RootWireHub events to UI + // Start background task to forward RootWireHub events to UI. + // ApprovalRequests flow through ApprovalRuntime → RootWireHub, + // NOT through the soul's Wire, so we need a separate subscriber. let rootHubQueue: any = null; if (app.soul.runtime.rootWireHub) { rootHubQueue = app.soul.runtime.rootWireHub.subscribe(); @@ -554,7 +561,6 @@ program "sender" in msg ) { // ApprovalRequest — enrich with source_description from subagent store - // (mirrors Python's _enrich_approval_request_for_ui) const request = msg as any; if (request.agent_id && !request.source_description && app.soul.runtime.subagentStore) { const record = app.soul.runtime.subagentStore.getInstance(request.agent_id); @@ -562,16 +568,13 @@ program request.source_description = record.description; } } - pushEvent?.({ + (pushEvent as ((e: WireUIEvent) => void) | null)?.({ type: "approval_request", request, }); } - // NOTE: ApprovalResponse is NOT forwarded here. - // Responses flow through handleApprovalResponse → wire.pushEvent - // to avoid double-processing. } - } catch (err) { + } catch { // Queue shutdown or error if (rootHubQueue) { app.soul.runtime.rootWireHub?.unsubscribe(rootHubQueue); @@ -582,8 +585,14 @@ program // Run initial prompt if provided (only on first iteration) if (currentPrompt) { - app.soul.run(currentPrompt).catch((err: Error) => { - pushEvent?.({ type: "error", message: err.message }); + const cancelController = new AbortController(); + currentCancelController = cancelController; + const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; + runSoul(app.soul, currentPrompt, createShellUILoopFn(pushEvent!), cancelController, { + wireFile, + runtime: app.soul.runtime, + }).catch((err) => { + pushEvent?.({ type: "error", message: err instanceof Error ? err.message : String(err) }); }); } @@ -596,8 +605,8 @@ program // Check if this was a reload (/undo or /fork) if (pendingReload) { - currentSessionId = pendingReload.sessionId; - currentPrefillText = pendingReload.prefillText; + currentSessionId = (pendingReload as { sessionId: string; prefillText?: string }).sessionId; + currentPrefillText = (pendingReload as { sessionId: string; prefillText?: string }).prefillText; currentPrompt = undefined; // Don't re-run the initial prompt continue; } diff --git a/src/kimi_cli_ts/cli/plugin.ts b/src/kimi_cli_ts/cli/plugin.ts index acd90b9ee..e541089e4 100644 --- a/src/kimi_cli_ts/cli/plugin.ts +++ b/src/kimi_cli_ts/cli/plugin.ts @@ -267,8 +267,8 @@ pluginCommand const { OAuthManager } = await import("../auth/oauth.ts"); const oauth = new OAuthManager(config); hostValues = collectHostValues( - config as Parameters[0], - oauth, + config as unknown as Parameters[0], + oauth as unknown as Parameters[1], ); } catch { // OAuth may not be available diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts index 2095b0076..43d03712d 100644 --- a/src/kimi_cli_ts/soul/agent.ts +++ b/src/kimi_cli_ts/soul/agent.ts @@ -59,12 +59,6 @@ export class Runtime { subagentId: string | null; subagentType: string | null; skills: Map; - /** - * Callback for forwarding subagent events to the parent UI. - * Set by cli/index.ts when wiring the parent soul's callbacks. - * Used by ForegroundSubagentRunner to emit SubagentEvent to the shell. - */ - subagentEventSink: ((event: Record) => void) | null; constructor(opts: { config: Config; @@ -82,7 +76,6 @@ export class Runtime { subagentId?: string | null; subagentType?: string | null; skills?: Map; - subagentEventSink?: ((event: Record) => void) | null; }) { this.config = opts.config; this.llm = opts.llm; @@ -99,7 +92,6 @@ export class Runtime { this.subagentId = opts.subagentId ?? null; this.subagentType = opts.subagentType ?? null; this.skills = opts.skills ?? new Map(); - this.subagentEventSink = opts.subagentEventSink ?? null; } get loopControl(): LoopControl { @@ -239,7 +231,6 @@ export class Runtime { subagentId: opts.agentId, subagentType: opts.subagentType, skills: this.skills, - subagentEventSink: this.subagentEventSink, }); } } diff --git a/src/kimi_cli_ts/soul/index.ts b/src/kimi_cli_ts/soul/index.ts new file mode 100644 index 000000000..063c28e00 --- /dev/null +++ b/src/kimi_cli_ts/soul/index.ts @@ -0,0 +1,282 @@ +/** + * Soul module — corresponds to Python soul/__init__.py + * + * Provides: + * - Wire context via AsyncLocalStorage (replaces Python ContextVar) + * - wireSend() for emitting wire messages from anywhere in the agent loop + * - runSoul() wrapper that connects a Soul to a Wire + UI loop + * - Soul protocol interface + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +import { Wire } from "../wire/wire_core.ts"; +import type { WireMessage } from "../wire/types.ts"; +import type { WireFile } from "../wire/file.ts"; +import type { ContentPart, StatusSnapshot, ModelCapability, SlashCommand } from "../types.ts"; +import type { HookEngine } from "../hooks/engine.ts"; +import type { Runtime } from "./agent.ts"; +import { QueueShutDown } from "../utils/queue.ts"; +import { logger } from "../utils/logging.ts"; + +// ── Errors ───────────────────────────────────────── + +export class LLMNotSet extends Error { + constructor() { + super("LLM not set"); + this.name = "LLMNotSet"; + } +} + +export class LLMNotSupported extends Error { + readonly modelName: string; + readonly capabilities: ModelCapability[]; + constructor(modelName: string, capabilities: ModelCapability[]) { + const word = capabilities.length === 1 ? "capability" : "capabilities"; + super( + `LLM model '${modelName}' does not support required ${word}: ${capabilities.join(", ")}`, + ); + this.name = "LLMNotSupported"; + this.modelName = modelName; + this.capabilities = capabilities; + } +} + +export class MaxStepsReached extends Error { + readonly nSteps: number; + constructor(nSteps: number) { + super(`Max number of steps reached: ${nSteps}`); + this.name = "MaxStepsReached"; + this.nSteps = nSteps; + } +} + +export class RunCancelled extends Error { + constructor(message = "The run was cancelled") { + super(message); + this.name = "RunCancelled"; + } +} + +// ── Status helpers ──────────────────────────────── + +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) { + const value = n / 1_000_000; + return `${Number(value.toFixed(1))}m`; + } + if (n >= 1_000) { + const value = n / 1_000; + return `${Number(value.toFixed(1))}k`; + } + return String(n); +} + +export function formatContextStatus( + contextUsage: number, + contextTokens = 0, + maxContextTokens = 0, +): string { + const bounded = Math.max(0, Math.min(contextUsage, 1)); + if (maxContextTokens > 0) { + const used = formatTokenCount(contextTokens); + const total = formatTokenCount(maxContextTokens); + return `context: ${(bounded * 100).toFixed(1)}% (${used}/${total})`; + } + return `context: ${(bounded * 100).toFixed(1)}%`; +} + +// ── Soul protocol ───────────────────────────────── + +export interface Soul { + readonly name: string; + readonly modelName: string; + readonly modelCapabilities: Set | null; + readonly thinking: boolean; + readonly status: StatusSnapshot; + readonly hookEngine: HookEngine; + readonly availableSlashCommands: SlashCommand[]; + run(userInput: string | ContentPart[]): Promise; +} + +// ── UILoopFn ────────────────────────────────────── + +/** + * A long-running async function to visualize the agent behavior. + * Reads from a Wire and updates the UI. Corresponds to Python's UILoopFn. + */ +export type UILoopFn = (wire: Wire) => Promise; + +// ── Wire context (AsyncLocalStorage) ────────────── + +const _currentWire = new AsyncLocalStorage(); + +/** + * Get the current wire or null. + * Expect to be not null when called from anywhere in the agent loop. + */ +export function getWireOrNull(): Wire | null { + return _currentWire.getStore() ?? null; +} + +/** + * A wire message tagged with its type name for efficient serialization. + * The __wireType field acts as a fast-path hint for serde, avoiding slow + * trial-parsing in detectTypeName(). Compatible with WireMessage. + */ +export interface TaggedWireMessage extends Record { + readonly __wireType: string; +} + +/** + * Create a tagged wire message with explicit type name. + * This is the primary way souls should construct wire messages. + */ +export function wireMsg(typeName: string, payload: Record = {}): TaggedWireMessage { + return { __wireType: typeName, ...payload }; +} + +/** + * Send a wire message to the current wire. + * Take this as `print` and `input` for souls. + * Souls should always use this function to send wire messages. + */ +export function wireSend(msg: TaggedWireMessage | WireMessage): void { + const wire = getWireOrNull(); + if (!wire) { + throw new Error("Wire is expected to be set when soul is running"); + } + wire.soulSide.send(msg as WireMessage); +} + +// ── runSoul ─────────────────────────────────────── + +/** + * Run the soul with the given user input, connecting it to the UI loop with a Wire. + * + * `cancelController` is an outside handle that can be used to cancel the run. + * When it is aborted, the run will be gracefully stopped and a RunCancelled + * will be raised. + * + * Corresponds to Python run_soul() in soul/__init__.py:167-238. + */ +export async function runSoul( + soul: Soul, + userInput: string | ContentPart[], + uiLoopFn: UILoopFn, + cancelController: AbortController, + opts?: { + wireFile?: WireFile; + runtime?: Runtime; + }, +): Promise { + const wire = new Wire({ fileBackend: opts?.wireFile }); + + await _currentWire.run(wire, async () => { + logger.debug("Starting UI loop"); + const uiTask = uiLoopFn(wire); + + logger.debug("Starting soul run"); + const soulTask = soul.run(userInput); + const notificationTask = _pumpNotificationsToWire(opts?.runtime ?? null, wire); + + // Create a cancellation promise + const cancelPromise = new Promise<"cancelled">((resolve) => { + if (cancelController.signal.aborted) { + resolve("cancelled"); + return; + } + cancelController.signal.addEventListener("abort", () => resolve("cancelled"), { + once: true, + }); + }); + + // Wait for either soul to complete or cancellation + const result = await Promise.race([ + soulTask.then(() => "done" as const), + cancelPromise, + ]); + + try { + if (result === "cancelled") { + logger.debug("Cancelling the run"); + // The soul should check the abort signal and stop + // We need to wait for it to actually stop + throw new RunCancelled(); + } + // Soul task is done — check if it threw + await soulTask; + } finally { + // Cancel notification pump + notificationTask.cancel(); + + // Flush any remaining notifications + try { + await _deliverNotificationsToWireOnce(opts?.runtime ?? null, wire); + } catch (err) { + logger.error(`Failed to flush notifications to wire during shutdown: ${err}`); + } + + logger.debug("Shutting down the UI loop"); + // Shutting down the wire should break the UI loop + wire.shutdown(); + await wire.join(); + try { + await Promise.race([ + uiTask, + new Promise((resolve) => setTimeout(resolve, 500)), + ]); + } catch (err) { + if (err instanceof QueueShutDown) { + logger.debug("UI loop shut down"); + } else { + logger.warn(`UI loop error: ${err}`); + } + } + } + }); +} + +// ── Notification pump ───────────────────────────── + +interface CancellableTask { + cancel(): void; +} + +function _pumpNotificationsToWire( + runtime: Runtime | null, + wire: Wire, +): CancellableTask { + let cancelled = false; + + const run = async () => { + while (!cancelled) { + try { + await _deliverNotificationsToWireOnce(runtime, wire); + } catch (err) { + if (!cancelled) { + logger.error(`Notification wire pump failed: ${err}`); + } + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + }; + + // Start in background — don't await + run().catch(() => {}); + + return { + cancel() { + cancelled = true; + }, + }; +} + +async function _deliverNotificationsToWireOnce( + runtime: Runtime | null, + _wire: Wire, +): Promise { + if (!runtime || runtime.role !== "root") return; + // TODO: Implement notification delivery when TS has notification system + // Python calls runtime.notifications.deliver_pending() here +} diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 1810fb6dd..1e0bdfbaa 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -19,6 +19,9 @@ import type { DynamicInjection, DynamicInjectionProvider } from "./dynamic_injec import { normalizeHistory } from "./dynamic_injection.ts"; import { PlanModeInjectionProvider } from "./dynamic_injections/plan_mode.ts"; import { YoloModeInjectionProvider } from "./dynamic_injections/yolo_mode.ts"; +import { wireSend, wireMsg, getWireOrNull } from "./index.ts"; +import { MaxStepsReached } from "./index.ts"; +import { Reload } from "../cli/index.ts"; import { handleNew, handleSessions, handleTitle, createSessionsPanel, createTitlePanel } from "../ui/shell/commands/session.ts"; import { handleModel, createModelPanel } from "../ui/shell/commands/model.ts"; import { handleLogin, handleLogout, createLoginPanel } from "../ui/shell/commands/login.ts"; @@ -33,44 +36,9 @@ import { handleAddDir } from "../ui/shell/commands/add_dir.ts"; import { logger } from "../utils/logging.ts"; import { readSkillText, type Skill } from "../skill/index.ts"; -// ── Errors ───────────────────────────────────────── +// ── Errors (re-export from soul/index.ts) ─────────────────── -export class LLMNotSet extends Error { - constructor() { - super("LLM not set"); - this.name = "LLMNotSet"; - } -} - -export class LLMNotSupported extends Error { - readonly modelName: string; - readonly capabilities: ModelCapability[]; - constructor(modelName: string, capabilities: ModelCapability[]) { - const word = capabilities.length === 1 ? "capability" : "capabilities"; - super( - `LLM model '${modelName}' does not support required ${word}: ${capabilities.join(", ")}`, - ); - this.name = "LLMNotSupported"; - this.modelName = modelName; - this.capabilities = capabilities; - } -} - -export class MaxStepsReached extends Error { - readonly maxSteps: number; - constructor(maxSteps: number) { - super(`Reached max steps per turn: ${maxSteps}`); - this.name = "MaxStepsReached"; - this.maxSteps = maxSteps; - } -} - -export class RunCancelled extends Error { - constructor() { - super("The run was cancelled"); - this.name = "RunCancelled"; - } -} +export { LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled } from "./index.ts"; export class BackToTheFuture extends Error { readonly checkpointId: number; @@ -83,25 +51,6 @@ export class BackToTheFuture extends Error { } } -// ── Wire event callbacks ──────────────────────────── - -export interface SoulCallbacks { - onTurnBegin?: (userInput: string | ContentPart[]) => void; - onTurnEnd?: () => void; - onStepBegin?: (stepNum: number) => void; - onStepInterrupted?: () => void; - onTextDelta?: (text: string) => void; - onThinkDelta?: (text: string) => void; - onToolCall?: (toolCall: ToolCall) => void; - onToolResult?: (toolCallId: string, result: ToolResult) => void; - onStatusUpdate?: (status: Partial) => void; - onCompactionBegin?: () => void; - onCompactionEnd?: () => void; - onError?: (error: Error) => void; - onNotification?: (title: string, body: string) => void; - onReload?: (sessionId: string, prefillText?: string) => void; -} - // ── Retry helpers ─────────────────────────────────── function isRetryableError(err: unknown): boolean { @@ -153,7 +102,6 @@ async function withRetry( export class KimiSoul { private agent: Agent; private context: Context; - private callbacks: SoulCallbacks; private abortController: AbortController | null = null; private _isRunning = false; private _planMode = false; @@ -173,11 +121,9 @@ export class KimiSoul { constructor(opts: { agent: Agent; context: Context; - callbacks?: SoulCallbacks; }) { this.agent = opts.agent; this.context = opts.context; - this.callbacks = opts.callbacks ?? {}; // Restore plan mode from session state this._planMode = opts.agent.runtime.session.state.plan_mode ?? false; @@ -196,14 +142,6 @@ export class KimiSoul { this._buildSkillSlashCommands(); } - /** - * Replace the current callbacks. - * Used by subagent runner to forward events as SubagentEvents to the parent UI. - */ - setCallbacks(cb: SoulCallbacks): void { - this.callbacks = cb; - } - /** * Build slash commands from discovered skills. * Mirrors Python KimiSoul._build_slash_commands() @@ -352,9 +290,25 @@ export class KimiSoul { return this.agent.runtime.hookEngine; } - /** Push a notification to the UI (appears in message list). */ + /** Push a notification to the wire. */ notify(title: string, body: string): void { - this.callbacks.onNotification?.(title, body); + try { + wireSend(wireMsg("Notification", { + id: crypto.randomUUID(), + category: "system", + type: "info", + source_kind: "soul", + source_id: this.name, + title, + body, + severity: "info", + created_at: Date.now() / 1000, + payload: {}, + })); + } catch { + // Wire may not be available (e.g. during construction) + logger.warn(`Notification dropped (no wire): ${title}`); + } } get availableSlashCommands(): SlashCommand[] { @@ -393,9 +347,8 @@ export class KimiSoul { this._planSessionId = null; this.agent.runtime.session.state.plan_session_id = null; } - // Persist to session state + // Persist to session state (matches Python — no wire_send here) this.agent.runtime.session.state.plan_mode = this._planMode; - this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); return this._planMode; } @@ -463,6 +416,21 @@ export class KimiSoul { this._injectionProviders.push(provider); } + /** Send a full StatusUpdate over the wire. */ + private _sendStatusUpdate(): void { + const snap = this.status; + wireSend(wireMsg("StatusUpdate", { + context_usage: snap.contextUsage ?? null, + context_tokens: snap.contextTokens ?? null, + max_context_tokens: snap.maxContextTokens ?? null, + token_usage: snap.tokenUsage ?? null, + message_id: null, + plan_mode: snap.planMode ?? false, + yolo: snap.yoloEnabled ?? false, + mcp_status: null, + })); + } + // ── Yolo mode ──────────────────────────────────── setYolo(yolo: boolean): void { @@ -484,8 +452,9 @@ export class KimiSoul { let turnStarted = false; let turnFinished = false; try { - this.callbacks.onTurnBegin?.(userInput); - await this._wireLog({ type: "TurnBegin", user_input: typeof userInput === "string" ? [{ type: "text", text: userInput }] : userInput }); + wireSend(wireMsg("TurnBegin", { + user_input: userInput, + })); turnStarted = true; // Slash command dispatch — inside turn lifecycle, matching Python soul/kimisoul.py:505-521. @@ -503,28 +472,22 @@ export class KimiSoul { await this._turn(userInput); } - await this._wireLog({ type: "TurnEnd" }); - this.callbacks.onTurnEnd?.(); + wireSend(wireMsg("TurnEnd")); turnFinished = true; } catch (err) { if (err instanceof Error && err.name === "AbortError") { logger.info("Turn aborted"); - this.callbacks.onStepInterrupted?.(); + wireSend(wireMsg("StepInterrupted")); } else if (err instanceof MaxStepsReached) { logger.warn(err.message); - this.callbacks.onError?.(err); - this.callbacks.onTurnEnd?.(); + wireSend(wireMsg("TurnEnd")); turnFinished = true; } else { logger.error(`Turn error: ${err}`); - this.callbacks.onError?.( - err instanceof Error ? err : new Error(String(err)), - ); } } finally { if (turnStarted && !turnFinished) { - await this._wireLog({ type: "TurnEnd" }); - this.callbacks.onTurnEnd?.(); + wireSend(wireMsg("TurnEnd")); } this._isRunning = false; this.abortController = null; @@ -602,7 +565,7 @@ export class KimiSoul { // Check abort if (this.abortController?.signal.aborted) { - this.callbacks.onStepInterrupted?.(); + wireSend(wireMsg("StepInterrupted")); break; } @@ -614,8 +577,7 @@ export class KimiSoul { // Execute one step this._stepCount++; - await this._wireLog({ type: "StepBegin", n: this._stepCount }); - this.callbacks.onStepBegin?.(this._stepCount); + wireSend(wireMsg("StepBegin", { n: this._stepCount })); const maxRetries = this.agent.runtime.config.loop_control.max_retries_per_step; const toolCalls = await withRetry( @@ -706,15 +668,15 @@ export class KimiSoul { }); // Wire log the tool result (matching Python format) - await this._wireLog({ - type: "ToolResult", + wireSend(wireMsg("ToolResult", { tool_call_id: tc.id, return_value: { - is_error: result.isError, + isError: result.isError, output: result.output, + message: result.message, }, display: result.display ?? [], - }); + })); // Append atomically — even if abort was signaled during tool execution, // we still append the result to keep context consistent @@ -777,12 +739,13 @@ export class KimiSoul { switch (chunk.type) { case "text": assistantText += chunk.text; - this.callbacks.onTextDelta?.(chunk.text); + // Stream text as ContentPart wire event (matches Python on_message_part=wire_send) + wireSend(wireMsg("TextPart", { type: "text", text: chunk.text })); break; case "think": thinkText += chunk.text; - this.callbacks.onThinkDelta?.(chunk.text); + wireSend(wireMsg("ThinkPart", { type: "think", text: chunk.text })); break; case "tool_call": @@ -791,11 +754,12 @@ export class KimiSoul { name: chunk.name, arguments: chunk.arguments, }); - this.callbacks.onToolCall?.({ + // Send ToolCall wire event (flat structure matching ToolCall schema) + wireSend(wireMsg("ToolCall", { id: chunk.id, name: chunk.name, arguments: chunk.arguments, - }); + })); break; case "usage": @@ -847,29 +811,9 @@ export class KimiSoul { await this.context.updateTokenCount(usage); } - // Wire log step results (order matches Python: think → text → toolcalls → status) - if (thinkText) { - await this._wireLog({ type: "ContentPart", payload: { type: "think", think: thinkText } }); - } - if (assistantText) { - await this._wireLog({ type: "ContentPart", payload: { type: "text", text: assistantText } }); - } - for (const tc of toolCalls) { - // Match Python ToolCall format: {type:"function", id, function:{name, arguments}} - await this._wireLog({ - type: "ToolCall", - payload: { - type: "function", - id: tc.id, - function: { name: tc.name, arguments: tc.arguments }, - }, - }); - } - - // Wire log + callback status update + // Wire status update (matches Python: wire_send(StatusUpdate(...))) const snap = this.status; - await this._wireLog({ - type: "StatusUpdate", + wireSend(wireMsg("StatusUpdate", { context_usage: snap.contextUsage ?? null, context_tokens: snap.contextTokens ?? null, max_context_tokens: snap.maxContextTokens ?? null, @@ -878,8 +822,7 @@ export class KimiSoul { plan_mode: snap.planMode ?? false, yolo: snap.yoloEnabled ?? false, mcp_status: null, - }); - this.callbacks.onStatusUpdate?.(snap); + })); return toolCalls; } @@ -916,13 +859,13 @@ export class KimiSoul { lc.compaction_trigger_ratio, ) ) { - this.callbacks.onCompactionBegin?.(); + wireSend(wireMsg("CompactionBegin")); try { await compactContext(this.context, llm); } catch (err) { logger.error(`Compaction failed: ${err}`); } - this.callbacks.onCompactionEnd?.(); + wireSend(wireMsg("CompactionEnd")); } } @@ -931,6 +874,19 @@ export class KimiSoul { wireSlashCommands(): void { const registry = this.agent.slashCommands; + // Helper: wrap a handler that returns string → wireSend(TextPart). + // Matches Python where soul slash handlers use wire_send(TextPart(text=...)). + const wrapTextHandler = ( + fn: (args: string) => Promise | string | void, + ): ((args: string) => Promise) => { + return async (args: string) => { + const result = await fn(args); + if (typeof result === "string" && result) { + wireSend(wireMsg("TextPart", { type: "text", text: result })); + } + }; + }; + // Wire /clear — soul-level handler clears context + wire file. // The shell-level /clear handler orchestrates: clearMessages() + soulClear() + triggerReload(). const clearCmd = registry.get("clear"); @@ -939,8 +895,6 @@ export class KimiSoul { await this.context.clear(); await this.context.writeSystemPrompt(this.agent.systemPrompt); // Truncate wire.jsonl so replay doesn't re-show old messages after reload. - // Python achieves this implicitly: replay_recent_history() checks - // context.history (empty after clear) and returns early. const wireFile = this.agent.runtime.session.wireFile; if (wireFile) { try { @@ -949,9 +903,10 @@ export class KimiSoul { } catch { /* best-effort */ } } logger.info("Context cleared"); - this.callbacks.onStatusUpdate?.(this.status); - // Trigger reload — mirrors Python: shell raises Reload() after run_soul_command("/clear") - this.callbacks.onReload?.(this.agent.runtime.session.id); + wireSend(wireMsg("TextPart", { type: "text", text: "The context has been cleared." })); + this._sendStatusUpdate(); + // NOTE: Reload is raised by the shell-level /clear handler, not here. + // This matches Python: soul handler clears context, shell handler raises Reload(). }; } @@ -960,20 +915,21 @@ export class KimiSoul { if (compactCmd) { compactCmd.handler = async (args: string) => { if (this.context.nCheckpoints === 0) { - return "The context is empty."; + wireSend(wireMsg("TextPart", { type: "text", text: "The context is empty." })); + return; } const llm = this.agent.runtime.llm; if (!llm) return; logger.info("Running `/compact`"); - this.callbacks.onCompactionBegin?.(); + wireSend(wireMsg("CompactionBegin")); try { await compactContext(this.context, llm, this.agent, { focus: args || undefined }); } finally { - this.callbacks.onCompactionEnd?.(); + wireSend(wireMsg("CompactionEnd")); } - this.callbacks.onStatusUpdate?.(this.status); + this._sendStatusUpdate(); logger.info("Context has been compacted."); - return "The context has been compacted."; + wireSend(wireMsg("TextPart", { type: "text", text: "The context has been compacted." })); }; } @@ -984,13 +940,13 @@ export class KimiSoul { if (this.agent.runtime.approval.isYolo()) { this.agent.runtime.approval.setYolo(false); logger.info("YOLO mode: OFF"); - this.callbacks.onStatusUpdate?.(this.status); - return "You only die once! Actions will require approval."; + this._sendStatusUpdate(); + wireSend(wireMsg("TextPart", { type: "text", text: "You only die once! Actions will require approval." })); } else { this.agent.runtime.approval.setYolo(true); logger.info("YOLO mode: ON"); - this.callbacks.onStatusUpdate?.(this.status); - return "You only live once! All actions will be auto-approved."; + this._sendStatusUpdate(); + wireSend(wireMsg("TextPart", { type: "text", text: "You only live once! All actions will be auto-approved." })); } }; } @@ -1003,28 +959,28 @@ export class KimiSoul { if (subcmd === "on") { if (!this._planMode) await this.togglePlanModeFromManual(); const planPath = this.getPlanFilePath(); - this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); - return `Plan mode ON. Plan file: ${planPath}`; + wireSend(wireMsg("TextPart", { type: "text", text: `Plan mode ON. Plan file: ${planPath}` })); + wireSend(wireMsg("StatusUpdate", { plan_mode: this._planMode })); } else if (subcmd === "off") { if (this._planMode) await this.togglePlanModeFromManual(); - this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); - return "Plan mode OFF. All tools are now available."; + wireSend(wireMsg("TextPart", { type: "text", text: "Plan mode OFF. All tools are now available." })); + wireSend(wireMsg("StatusUpdate", { plan_mode: this._planMode })); } else if (subcmd === "view") { const content = this.readCurrentPlan(); - return content ?? "No plan file found for this session."; + wireSend(wireMsg("TextPart", { type: "text", text: content ?? "No plan file found for this session." })); } else if (subcmd === "clear") { this.clearCurrentPlan(); - return "Plan cleared."; + wireSend(wireMsg("TextPart", { type: "text", text: "Plan cleared." })); } else { // Default: toggle const newState = await this.togglePlanModeFromManual(); - this.callbacks.onStatusUpdate?.({ planMode: this._planMode }); if (newState) { const planPath = this.getPlanFilePath(); - return `Plan mode ON. Write your plan to: ${planPath}\nUse ExitPlanMode when done, or /plan off to exit manually.`; + wireSend(wireMsg("TextPart", { type: "text", text: `Plan mode ON. Write your plan to: ${planPath}\nUse ExitPlanMode when done, or /plan off to exit manually.` })); } else { - return "Plan mode OFF. All tools are now available."; + wireSend(wireMsg("TextPart", { type: "text", text: "Plan mode OFF. All tools are now available." })); } + wireSend(wireMsg("StatusUpdate", { plan_mode: this._planMode })); } }; } @@ -1034,7 +990,7 @@ export class KimiSoul { if (modelCmd) { const notify = (t: string, b: string) => this.notify(t, b); const onReload = (sessionId: string, prefillText?: string) => { - this.callbacks.onReload?.(sessionId, prefillText); + throw new Reload(sessionId, prefillText); }; const configMeta = { isFromDefaultLocation: true, sourceFile: null }; modelCmd.handler = async () => { @@ -1053,49 +1009,45 @@ export class KimiSoul { // Wire /export const exportCmd = registry.get("export"); if (exportCmd) { - exportCmd.handler = async (args: string) => { - return await handleExport(this.context, this.agent.runtime.session, args); - }; + exportCmd.handler = wrapTextHandler((args) => + handleExport(this.context, this.agent.runtime.session, args), + ); } // Wire /import const importCmd = registry.get("import"); if (importCmd) { - importCmd.handler = async (args: string) => { - return await handleImport(this.context, this.agent.runtime.session, args); - }; + importCmd.handler = wrapTextHandler((args) => + handleImport(this.context, this.agent.runtime.session, args), + ); } // Wire /web const webCmd = registry.get("web"); if (webCmd) { - webCmd.handler = async () => { - return handleWeb(this.agent.runtime.session.id); - }; + webCmd.handler = wrapTextHandler(() => + handleWeb(this.agent.runtime.session.id), + ); } // Wire /vis const visCmd = registry.get("vis"); if (visCmd) { - visCmd.handler = async () => { - return handleVis(this.agent.runtime.session.id); - }; + visCmd.handler = wrapTextHandler(() => + handleVis(this.agent.runtime.session.id), + ); } // Wire /reload const reloadCmd = registry.get("reload"); if (reloadCmd) { - reloadCmd.handler = async () => { - return handleReload(); - }; + reloadCmd.handler = wrapTextHandler(() => handleReload()); } // Wire /task const taskCmd = registry.get("task"); if (taskCmd) { - taskCmd.handler = async () => { - return handleTask(); - }; + taskCmd.handler = wrapTextHandler(() => handleTask()); } // Wire /login @@ -1119,22 +1071,22 @@ export class KimiSoul { // Wire /usage const usageCmd = registry.get("usage"); if (usageCmd) { - usageCmd.handler = async () => { - return await handleUsage(this.agent.runtime.config, this.agent.runtime.config.default_model || undefined); - }; + usageCmd.handler = wrapTextHandler(() => + handleUsage(this.agent.runtime.config, this.agent.runtime.config.default_model || undefined), + ); } // Wire /feedback const feedbackCmd = registry.get("feedback"); if (feedbackCmd) { - feedbackCmd.handler = async (args: string) => { - return await handleFeedback( + feedbackCmd.handler = wrapTextHandler((args) => + handleFeedback( this.agent.runtime.config, args, this.agent.runtime.session.id, this.agent.runtime.config.default_model || undefined, - ); - }; + ), + ); feedbackCmd.panel = () => createFeedbackPanel( this.agent.runtime.config, this.agent.runtime.session.id, @@ -1148,75 +1100,73 @@ export class KimiSoul { if (editorCmd) { const editorNotify = (t: string, b: string) => this.notify(t, b); const editorConfigMeta = { isFromDefaultLocation: true, sourceFile: null }; - editorCmd.handler = async (args: string) => { - return await handleEditor(this.agent.runtime.config, editorConfigMeta, args); - }; + editorCmd.handler = wrapTextHandler((args) => + handleEditor(this.agent.runtime.config, editorConfigMeta, args), + ); editorCmd.panel = () => createEditorPanel(this.agent.runtime.config, editorConfigMeta, editorNotify); } // Wire /hooks const hooksCmd = registry.get("hooks"); if (hooksCmd) { - hooksCmd.handler = async () => { - return handleHooks(this.agent.runtime.hookEngine); - }; + hooksCmd.handler = wrapTextHandler(() => + handleHooks(this.agent.runtime.hookEngine), + ); hooksCmd.panel = () => createHooksPanel(this.agent.runtime.hookEngine); } // Wire /mcp const mcpCmd = registry.get("mcp"); if (mcpCmd) { - mcpCmd.handler = async () => { - return handleMcp(this.agent.runtime.config); - }; + mcpCmd.handler = wrapTextHandler(() => + handleMcp(this.agent.runtime.config), + ); mcpCmd.panel = () => createMcpPanel(this.agent.runtime.config); } // Wire /debug const debugCmd = registry.get("debug"); if (debugCmd) { - debugCmd.handler = async () => { - return handleDebug(this.context); - }; + debugCmd.handler = wrapTextHandler(() => + handleDebug(this.context), + ); debugCmd.panel = () => createDebugPanel(this.context); } // Wire /changelog const changelogCmd = registry.get("changelog"); if (changelogCmd) { - changelogCmd.handler = async () => { - return handleChangelog(); - }; + changelogCmd.handler = wrapTextHandler(() => handleChangelog()); changelogCmd.panel = () => createChangelogPanel(); } // Wire /new const newCmd = registry.get("new"); if (newCmd) { - newCmd.handler = async () => { - return await handleNew(this.agent.runtime.session); - }; + newCmd.handler = wrapTextHandler(() => + handleNew(this.agent.runtime.session), + ); } // Wire /sessions const sessionsCmd = registry.get("sessions"); if (sessionsCmd) { - sessionsCmd.handler = async () => { - return await handleSessions(this.agent.runtime.session); - }; + sessionsCmd.handler = wrapTextHandler(() => + handleSessions(this.agent.runtime.session), + ); sessionsCmd.panel = () => createSessionsPanel( this.agent.runtime.session, (t, b) => this.notify(t, b), - (id, prefill) => this.callbacks.onReload?.(id, prefill), + (id, prefill) => { throw new Reload(id, prefill); }, ); } // Wire /title const titleCmd = registry.get("title"); if (titleCmd) { - titleCmd.handler = async (args: string) => { - return await handleTitle(this.agent.runtime.session, args); - }; + titleCmd.handler = wrapTextHandler((args) => + handleTitle(this.agent.runtime.session, args), + ); titleCmd.panel = () => createTitlePanel( this.agent.runtime.session, (t, b) => this.notify(t, b), @@ -1226,21 +1176,21 @@ export class KimiSoul { // Wire /init const initCmd = registry.get("init"); if (initCmd) { - initCmd.handler = async () => { - return await handleInit(this.agent.runtime.session.workDir); - }; + initCmd.handler = wrapTextHandler(() => + handleInit(this.agent.runtime.session.workDir), + ); } // Wire /add-dir const addDirCmd = registry.get("add-dir"); if (addDirCmd) { - addDirCmd.handler = async (args: string) => { - return await handleAddDir( + addDirCmd.handler = wrapTextHandler((args) => + handleAddDir( this.agent.runtime.session, this.agent.runtime.session.workDir, args, - ); - }; + ), + ); } } @@ -1251,70 +1201,43 @@ export class KimiSoul { ctx.getPlanMode = () => this._planMode; ctx.getPlanFilePath = () => this.getPlanFilePath() ?? undefined; ctx.togglePlanMode = () => this.togglePlanMode(); - } - - // ── Wire file logging ──────────────────────────── - - /** - * Append a wire event to the session's wire.jsonl file. - * Matches Python's wire.jsonl format exactly: - * - First line: {"type":"metadata","protocol_version":"1.8"} - * - Subsequent lines: {"timestamp":float,"message":{"type":"TypeName","payload":{...}}} - * - * Event format: - * - { type: "TurnBegin", user_input: "..." } - * - { type: "text_part", text: "..." } - * - { type: "ContentPart", payload: { type: "think", thinking: "..." } } - * - { type: "tool_call", name: "...", id: "..." } - */ - private async _wireLog(event: Record): Promise { - const wireFile = this.agent.runtime.session.wireFile; - if (!wireFile) return; - try { - const { appendFile, stat } = await import("node:fs/promises"); - const { dirname } = await import("node:path"); - - // Ensure directory exists - await import("node:fs/promises") - .then(m => m.mkdir(dirname(wireFile), { recursive: true })) - .catch(() => {}); - // Check if file is empty (need metadata header) - let needsMetadata = false; - try { - const stats = await stat(wireFile); - needsMetadata = stats.size === 0; - } catch { - needsMetadata = true; // File doesn't exist - } - - let content = ""; - - // Write metadata header on first append - if (needsMetadata) { - const metadata = { type: "metadata", protocol_version: "1.8" }; - content += JSON.stringify(metadata) + "\n"; - } - - // Extract type and build payload - const { type, payload, ...otherFields } = event; - const finalPayload = payload || otherFields; - - // Write the message record in Python-compatible format - const record = { - timestamp: Date.now() / 1000, // Convert ms to float seconds - message: { - type, - payload: finalPayload, - }, + // Wire ctx.askUser to use QuestionRequest through Wire (matches Python pattern). + // This makes EnterPlanMode, ExitPlanMode, and any tool using ctx.askUser + // show the QuestionPanel to the user. + ctx.askUser = async (question: string, options?: string[]): Promise => { + const { randomUUID } = await import("node:crypto"); + const { PendingQuestionRequest } = await import("../wire/types.ts"); + const { registerPendingQuestion } = await import("../tools/ask_user/ask_user.ts"); + const { getCurrentToolCallOrNull } = await import("./toolset.ts"); + + const wire = getWireOrNull(); + if (!wire) throw new Error("Wire not available"); + + const toolCall = getCurrentToolCallOrNull(); + const requestData = { + id: randomUUID(), + tool_call_id: toolCall?.id ?? "", + questions: [{ + question, + header: "", + options: (options ?? []).map((label) => ({ label, description: "" })), + multi_select: false, + body: "", + other_label: "", + other_description: "", + }], }; - content += JSON.stringify(record) + "\n"; + const pending = new PendingQuestionRequest(requestData); + registerPendingQuestion(requestData.id, pending); - await appendFile(wireFile, content, "utf-8"); - } catch { - // Wire logging is best-effort — don't crash on failure - } + wireSend(wireMsg("QuestionRequest", requestData)); + + const answers = await pending.wait(); + return answers[question] ?? ""; + }; } + } // ── FlowRunner ───────────────────────────────────── @@ -1523,11 +1446,12 @@ export class FlowRunner { soul: KimiSoul, prompt: string, ): Promise { - // TODO: Wire TurnBegin/TurnEnd events once wire_send is available - // For now, drive the soul's internal _turn method + // Send wire events for flow turn (matches Python FlowRunner._flow_turn) + wireSend(wireMsg("TurnBegin", { user_input: [{ type: "text", text: prompt }] })); const stepsBefore = soul["_stepCount"]; await soul["_turn"](prompt); const stepsAfter = soul["_stepCount"]; + wireSend(wireMsg("TurnEnd")); // Extract final assistant text from context const history = soul["context"].history; diff --git a/src/kimi_cli_ts/subagents/runner.ts b/src/kimi_cli_ts/subagents/runner.ts index 8ca2548a3..c5da293ce 100644 --- a/src/kimi_cli_ts/subagents/runner.ts +++ b/src/kimi_cli_ts/subagents/runner.ts @@ -7,7 +7,8 @@ import { randomUUID } from "node:crypto"; import type { Runtime } from "../soul/agent.ts"; import type { Message, ContentPart } from "../types.ts"; -import { KimiSoul, MaxStepsReached, RunCancelled } from "../soul/kimisoul.ts"; +import { KimiSoul } from "../soul/kimisoul.ts"; +import { MaxStepsReached, RunCancelled, getWireOrNull, runSoul, type UILoopFn } from "../soul/index.ts"; import { getCurrentToolCallOrNull } from "../soul/toolset.ts"; import { ToolOk, ToolError, type ToolResult } from "../tools/types.ts"; import { SubagentOutputWriter } from "./output.ts"; @@ -25,6 +26,8 @@ import { QuestionRequest, SubagentEvent, } from "../wire/types.ts"; +import { QueueShutDown } from "../utils/queue.ts"; +import { WireFile } from "../wire/file.ts"; import * as hookEvents from "../hooks/events.ts"; import { logger } from "../utils/logging.ts"; @@ -79,17 +82,28 @@ function extractAssistantText(history: readonly Message[]): string { } /** - * Run a single soul turn and validate the result. + * Run a single soul turn using runSoul() and validate the result. * Returns a SoulRunFailure if the run failed, or null on success. * Corresponds to Python run_soul_checked(). */ export async function runSoulChecked( soul: KimiSoul, prompt: string, + uiLoopFn: UILoopFn, + wirePath: string, phase: string, ): Promise { try { - await soul.run(prompt); + await runSoul( + soul, + prompt, + uiLoopFn, + new AbortController(), + { + wireFile: new WireFile(wirePath), + runtime: soul.runtime, + }, + ); } catch (err) { // RunCancelled must propagate — the caller marks the instance as killed. if (err instanceof RunCancelled) { @@ -98,7 +112,7 @@ export async function runSoulChecked( if (err instanceof MaxStepsReached) { return { message: - `Max steps ${err.maxSteps} reached when ${phase}. ` + + `Max steps ${err.nSteps} reached when ${phase}. ` + "Please try splitting the task into smaller subtasks.", brief: "Max steps reached", }; @@ -131,8 +145,10 @@ export async function runSoulChecked( export async function runWithSummaryContinuation( soul: KimiSoul, prompt: string, + uiLoopFn: UILoopFn, + wirePath: string, ): Promise<[string | null, SoulRunFailure | null]> { - const failure = await runSoulChecked(soul, prompt, "running agent"); + const failure = await runSoulChecked(soul, prompt, uiLoopFn, wirePath, "running agent"); if (failure !== null) return [null, failure]; let finalResponse = extractAssistantText(soul.ctx.history); @@ -143,6 +159,8 @@ export async function runWithSummaryContinuation( const contFailure = await runSoulChecked( soul, SUMMARY_CONTINUATION_PROMPT, + uiLoopFn, + wirePath, "continuing the agent summary", ); if (contFailure !== null) return [null, contFailure]; @@ -152,14 +170,6 @@ export async function runWithSummaryContinuation( return [finalResponse, null]; } -// ── UI Loop Function Type ──────────────────────────────── - -/** - * A function that reads Wire events and forwards them to the parent. - * Corresponds to Python UILoopFn. - */ -export type UILoopFn = (wire: Wire) => Promise; - // ── ForegroundSubagentRunner ───────────────────────────── export class ForegroundSubagentRunner { @@ -224,48 +234,17 @@ export class ForegroundSubagentRunner { const toolCall = getCurrentToolCallOrNull(); const parentToolCallId = toolCall?.id ?? null; - // Wire subagent soul callbacks to forward events as SubagentEvent to the parent UI. - // This is the TS equivalent of Python's _make_ui_loop_fn() + Wire pattern. - // Python captures the parent wire via ContextVar closure; we capture parentToolCallId - // in the closure and forward events to the parent via runtime.subagentEventSink. - const sink = this._runtime.subagentEventSink; - if (sink && parentToolCallId) { - const wrap = (nestedEvent: Record) => { - sink({ - type: "subagent_event", - parentToolCallId, - agentId, - subagentType: actualType, - event: nestedEvent, - }); - }; - // Emit initial metadata event so the UI knows about this subagent - // even before any tool calls (matches Python's set_subagent_metadata timing) - wrap({ type: "_metadata" }); - - soul.setCallbacks({ - onToolCall: (tc) => { - outputWriter.toolCall(tc.name); - wrap({ type: "ToolCall", payload: { id: tc.id, function: { name: tc.name, arguments: tc.arguments } } }); - }, - onToolResult: (toolCallId, result) => { - const isError = result.isError ?? false; - const brief = result.message ?? undefined; - outputWriter.toolResult(isError ? "error" : "success", brief); - wrap({ - type: "ToolResult", - payload: { - tool_call_id: toolCallId, - return_value: { isError, output: result.output, message: result.message }, - display: result.display ?? [], - }, - }); - }, - onTextDelta: (text) => { - outputWriter.text(text); - }, - }); - } + // Capture parent wire (via AsyncLocalStorage) for event forwarding. + // Matches Python: super_wire = get_wire_or_none() in _make_ui_loop_fn. + const superWire = getWireOrNull(); + const uiLoopFn = ForegroundSubagentRunner._makeUiLoopFn({ + parentToolCallId, + agentId, + subagentType: actualType, + outputWriter, + superWireSoulSide: superWire?.soulSide ?? null, + }); + const wirePath = this._store.wirePath(agentId); // approvalSource is created inside the try block so the finally // clause can safely skip cancellation if we never got that far. @@ -297,7 +276,7 @@ export class ForegroundSubagentRunner { outputWriter.stage("run_soul_start"); const [finalResponse, failure] = await runWithApprovalSourceAsync( approvalSource, - () => runWithSummaryContinuation(soul, prompt), + () => runWithSummaryContinuation(soul, prompt, uiLoopFn, wirePath), ); if (failure !== null) { @@ -381,9 +360,6 @@ export class ForegroundSubagentRunner { * - Forwards ApprovalRequest/ToolCallRequest/QuestionRequest directly to parent wire * - Wraps other events as SubagentEvent before forwarding * - Skips HookRequest (handled internally) - * - * TODO: Wire the returned UILoopFn into soul.run() once the TS Wire system - * supports get_wire_or_none() and run_soul() accepts a ui_loop_fn parameter. */ static _makeUiLoopFn(opts: { parentToolCallId: string | null; @@ -400,21 +376,22 @@ export class ForegroundSubagentRunner { let msg: WireMessage; try { msg = await wireUi.receive(); - } catch { - // Queue shut down — wire was closed - break; + } catch (err) { + if (err instanceof QueueShutDown) break; + throw err; } // Always write to output file regardless of wire availability outputWriter.writeWireMessage(msg as Record); - logger.debug(`[subagent:${agentId}] wire event: ${(msg as any)?.type ?? "unknown"}`); if (superWireSoulSide == null || parentToolCallId == null) { continue; } + // Use __wireType tag for efficient type detection (set by wireSend/wireMsg) + const msgType = (msg as Record).__wireType as string | undefined; + // Forward approval/tool call/question requests directly to parent - const msgType = (msg as any)?.type; if ( msgType === "ApprovalRequest" || msgType === "ApprovalResponse" || @@ -432,7 +409,7 @@ export class ForegroundSubagentRunner { // Wrap all other events as SubagentEvent superWireSoulSide.send({ - type: "SubagentEvent", + __wireType: "SubagentEvent", parent_tool_call_id: parentToolCallId, agent_id: agentId, subagent_type: subagentType, diff --git a/src/kimi_cli_ts/tools/ask_user/ask_user.ts b/src/kimi_cli_ts/tools/ask_user/ask_user.ts index eff2ec079..5ce33bc5e 100644 --- a/src/kimi_cli_ts/tools/ask_user/ask_user.ts +++ b/src/kimi_cli_ts/tools/ask_user/ask_user.ts @@ -1,12 +1,48 @@ /** * AskUserQuestion tool — ask the user structured questions. * Corresponds to Python tools/ask_user/__init__.py + * + * Uses Wire to send QuestionRequest → UI shows panel → user answers → resolves. */ import { z } from "zod/v4"; +import { randomUUID } from "node:crypto"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; -import { ToolOk } from "../types.ts"; +import { ToolOk, ToolError } from "../types.ts"; +import { wireSend, wireMsg, getWireOrNull } from "../../soul/index.ts"; +import { getCurrentToolCallOrNull } from "../../soul/toolset.ts"; +import { + PendingQuestionRequest, + QuestionNotSupported, +} from "../../wire/types.ts"; + +// Global registry of pending question requests (keyed by request ID). +// The tool creates and stores here; the UI resolves by ID. +const _pendingQuestions = new Map(); + +/** Register a pending question (used by ctx.askUser wiring in wireToolContext). */ +export function registerPendingQuestion(requestId: string, pending: PendingQuestionRequest): void { + _pendingQuestions.set(requestId, pending); +} + +/** Resolve a pending question request by ID. Called from the UI layer. */ +export function resolveQuestionRequest(requestId: string, answers: Record): void { + const pending = _pendingQuestions.get(requestId); + if (pending) { + pending.resolve(answers); + _pendingQuestions.delete(requestId); + } +} + +/** Reject a pending question request by ID (e.g., user cancelled). */ +export function rejectQuestionRequest(requestId: string): void { + const pending = _pendingQuestions.get(requestId); + if (pending) { + pending.setException(new QuestionNotSupported()); + _pendingQuestions.delete(requestId); + } +} const DESCRIPTION = `Use this tool when you need to ask the user questions with structured options during execution. This allows you to: 1. Collect user preferences or requirements before proceeding @@ -70,30 +106,94 @@ export class AskUserQuestion extends CallableTool { readonly description = DESCRIPTION; readonly schema = ParamsSchema; + private _isYolo?: () => boolean; + + /** Late-bind yolo checker so we can auto-dismiss in non-interactive mode. */ + bindApproval(isYolo: () => boolean): void { + this._isYolo = isYolo; + } + async execute(params: Params, ctx: ToolContext): Promise { - const answers: Record = {}; - - for (const q of params.questions) { - const optionLabels = q.options.map((o) => o.label); - - if (ctx.askUser) { - // Wire-connected: actually ask the user - try { - const answer = await ctx.askUser(q.question, optionLabels); - answers[q.question] = answer; - } catch { - // User didn't respond or error — use first option as default - answers[q.question] = optionLabels[0] ?? "No answer"; - } - } else { - // Not connected (print mode, yolo mode, etc.) — auto-select first option - answers[q.question] = optionLabels[0] ?? "No answer"; + // Auto-dismiss in yolo mode (matches Python) + if (this._isYolo?.()) { + return ToolOk( + '{"answers": {}, "note": "Running in non-interactive (yolo) mode. Make your own decision."}', + "Non-interactive mode, auto-dismissed.", + ); + } + + const wire = getWireOrNull(); + if (!wire) { + // No Wire context (tests, non-interactive) — auto-select first option + const answers: Record = {}; + for (const q of params.questions) { + answers[q.question] = q.options[0]?.label ?? "No answer"; + } + return ToolOk( + JSON.stringify({ answers }, null, 2), + "Auto-selected (no interactive Wire).", + ); + } + + const toolCall = getCurrentToolCallOrNull(); + if (!toolCall) { + // No tool call context — auto-select first option + const answers: Record = {}; + for (const q of params.questions) { + answers[q.question] = q.options[0]?.label ?? "No answer"; } + return ToolOk( + JSON.stringify({ answers }, null, 2), + "Auto-selected (no tool call context).", + ); } - return ToolOk( - JSON.stringify({ answers }, null, 2), - "User responses collected.", - ); + // Build question items matching Python's QuestionItem structure + const questions = params.questions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((o) => ({ + label: o.label, + description: o.description, + })), + multi_select: q.multi_select, + body: "", + other_label: "", + other_description: "", + })); + + // Create pending request with async resolution (matches Python's asyncio.Future pattern) + const requestData = { + id: randomUUID(), + tool_call_id: toolCall.id, + questions, + }; + const pending = new PendingQuestionRequest(requestData); + _pendingQuestions.set(requestData.id, pending); + + // Send through Wire → UI loop receives → shows QuestionPanel + wireSend(wireMsg("QuestionRequest", requestData)); + + // Block until user answers (future resolves when UI calls resolve()) + try { + const answers = await pending.wait(); + _pendingQuestions.delete(requestData.id); + return ToolOk( + JSON.stringify({ answers }, null, 2), + "User responses collected.", + ); + } catch (err) { + _pendingQuestions.delete(requestData.id); + if (err instanceof QuestionNotSupported) { + return ToolError( + "The connected client does not support interactive questions. " + + "Do NOT call this tool again. " + + "Ask the user directly in your text response instead.", + ); + } + return ToolError( + `Failed to get user response: ${err instanceof Error ? err.message : String(err)}`, + ); + } } } diff --git a/src/kimi_cli_ts/tools/file/grep.ts b/src/kimi_cli_ts/tools/file/grep.ts index b62a95400..c1c67b045 100644 --- a/src/kimi_cli_ts/tools/file/grep.ts +++ b/src/kimi_cli_ts/tools/file/grep.ts @@ -1,6 +1,12 @@ /** * Grep tool — regex search using ripgrep. * Corresponds to Python tools/file/grep_local.py + * + * Ripgrep binary resolution: + * 1. ~/.kimi/bin/rg (cached extraction or user-installed) + * 2. /deps/bin/rg (development mode, bun run) + * 3. System PATH (user-installed rg) + * 4. Extract embedded binary → ~/.kimi/bin/rg (compiled mode) */ import { z } from "zod/v4"; @@ -8,11 +14,164 @@ import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; import { ToolError, ToolResultBuilder } from "../types.ts"; import { isSensitiveFile, sensitiveFileWarning } from "../../utils/sensitive.ts"; +import { getShareDir } from "../../config.ts"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; + +// Embed the ripgrep binary into compiled executable via Bun's asset system. +// In dev mode (bun run), this resolves to the file path on disk. +// In compiled mode (bun build --compile), the binary is embedded in the +// executable and this resolves to a `$bunfs/...` internal path that can be +// read with Bun.file() or fs APIs. +// @ts-ignore — import attribute not yet in TS lib +import embeddedRgPath from "../../../kimi_cli/deps/bin/rg" with { type: "file" }; const RG_TIMEOUT = 20_000; // 20 seconds in ms const RG_MAX_BUFFER = 20_000_000; // 20MB const RG_KILL_GRACE = 5_000; // 5 seconds: SIGTERM → SIGKILL +/** Detect the ripgrep binary name for the current platform */ +function getRgBinaryName(): string { + return process.platform === "win32" ? "rg.exe" : "rg"; +} + +/** Check if a file exists and is executable */ +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Extract the embedded ripgrep binary to ~/.kimi/bin/rg. + * Only used when running as a compiled binary (bun build --compile). + * Reads from the Bun-embedded $bunfs/ path and writes to the cache directory. + */ +function extractEmbeddedRg(): string | null { + try { + const binName = getRgBinaryName(); + const targetDir = path.join(getShareDir(), "bin"); + const targetPath = path.join(targetDir, binName); + + // Read the embedded binary content + const file = Bun.file(embeddedRgPath); + const buffer = new Uint8Array(file.size); + const reader = file.stream().getReader(); + let offset = 0; + + // Synchronous-ish extraction: we use a blocking pattern since this + // only runs once on first grep invocation. + // Use writeFileSync with the embedded path directly via fs. + const content = fs.readFileSync(embeddedRgPath); + + // Ensure target directory exists + fs.mkdirSync(targetDir, { recursive: true }); + + // Write the binary + fs.writeFileSync(targetPath, content); + + // Make executable (rwxr-xr-x) + fs.chmodSync(targetPath, 0o755); + + if (isExecutable(targetPath)) { + return targetPath; + } + return null; + } catch { + return null; + } +} + +/** + * Find the ripgrep binary using a priority-based search. + * + * Search order: + * 1. ~/.kimi/bin/rg (cached extraction or user-installed) + * 2. /deps/bin/rg (development mode via bun run) + * 3. System PATH (user-installed ripgrep) + * 4. Extract embedded binary → ~/.kimi/bin/rg (compiled mode fallback) + * + * This matches the Python grep tool's search order in grep_local.py. + */ +function findRipgrepBinary(): string | null { + const binName = getRgBinaryName(); + + // Priority 1: Check user's share directory (~/.kimi/bin/rg) + // This is the canonical cache location — both extracted and user-installed + // binaries live here. + const shareBin = path.join(getShareDir(), "bin", binName); + if (fs.existsSync(shareBin) && isExecutable(shareBin)) { + return shareBin; + } + + // Priority 2: Check package's local deps folder (development mode) + // This works when running with `bun run` from source tree. + // In compiled mode, import.meta.url points to $bunfs/ — the path won't + // resolve to a real directory, so existsSync will simply return false. + try { + const currentDir = path.dirname(import.meta.url.replace("file://", "")); + const packageRoot = path.resolve(currentDir, "../../.."); + const localDep = path.join(packageRoot, "deps", "bin", binName); + if (fs.existsSync(localDep) && isExecutable(localDep)) { + return localDep; + } + } catch { + // Not in dev mode or path resolution failed + } + + // Priority 3: Check system PATH + try { + const whichCmd = process.platform === "win32" ? "where" : "which"; + const result = execSync(`${whichCmd} ${binName}`, { encoding: "utf-8" }).trim(); + if (result) { + return result; + } + } catch { + // ripgrep not found in PATH + } + + // Priority 4: Extract embedded binary to ~/.kimi/bin/rg + // This is the fallback for compiled mode when no cached binary exists yet. + const extracted = extractEmbeddedRg(); + if (extracted) { + return extracted; + } + + return null; +} + +// Cache the rg binary path +let cachedRgPath: string | null | undefined; + +/** Get the ripgrep binary path, with caching */ +function getRipgrepPath(): string { + if (cachedRgPath !== undefined) { + if (cachedRgPath === null) { + throw new Error( + "ripgrep (rg) not found. Install ripgrep or ensure it's in your PATH.\n" + + "Install: https://github.com/BurntSushi/ripgrep/releases", + ); + } + return cachedRgPath; + } + + const rgPath = findRipgrepBinary(); + cachedRgPath = rgPath; + + if (!rgPath) { + throw new Error( + "ripgrep (rg) not found. Install ripgrep or ensure it's in your PATH.\n" + + "Install: https://github.com/BurntSushi/ripgrep/releases", + ); + } + + return rgPath; +} + const DESCRIPTION = `A powerful search tool based on ripgrep. **Tips:** @@ -95,9 +254,10 @@ type Params = z.infer; function buildRgArgs( params: Params, searchPath: string, + rgPath: string, opts?: { singleThreaded?: boolean }, ): string[] { - const args: string[] = ["rg"]; + const args: string[] = [rgPath]; // Fixed args if (params.output_mode !== "content") { @@ -194,7 +354,19 @@ export class Grep extends CallableTool { } searchPath = searchPath.replace(/^~/, process.env.HOME || ""); - const args = buildRgArgs(params, searchPath, { + // Get the ripgrep binary path + let rgPath: string; + try { + rgPath = getRipgrepPath(); + } catch (e) { + // If rg is not found, try fallback to 'rg' in PATH anyway + const err = e instanceof Error ? e.message : String(e); + return ToolError( + `Grep tool requires ripgrep (rg) to be installed.\n\nDetails: ${err}\n\nYou can install ripgrep with: https://github.com/BurntSushi/ripgrep/releases`, + ); + } + + const args = buildRgArgs(params, searchPath, rgPath, { singleThreaded: opts?._retry, }); @@ -295,7 +467,7 @@ export class Grep extends CallableTool { continue; } const m = _RG_LINE_RE.exec(line); - filePath = m ? m[1] : line; + filePath = m ? m[1]! : line; } else if (params.output_mode === "count_matches") { const idx = line.lastIndexOf(":"); filePath = idx > 0 ? line.slice(0, idx) : line; diff --git a/src/kimi_cli_ts/tools/file/read.ts b/src/kimi_cli_ts/tools/file/read.ts index 5888c1901..a08343c25 100644 --- a/src/kimi_cli_ts/tools/file/read.ts +++ b/src/kimi_cli_ts/tools/file/read.ts @@ -320,7 +320,7 @@ export class ReadFile extends CallableTool { let kept = 0; let nBytes = 0; for (let i = candidates.length - 1; i >= 0; i--) { - nBytes += new TextEncoder().encode(candidates[i].line).length; + nBytes += new TextEncoder().encode(candidates[i]!.line).length; if (nBytes > MAX_BYTES) break; kept++; } diff --git a/src/kimi_cli_ts/ui/CLAUDE.md b/src/kimi_cli_ts/ui/CLAUDE.md index a15dce6ba..1f7bf1c02 100644 --- a/src/kimi_cli_ts/ui/CLAUDE.md +++ b/src/kimi_cli_ts/ui/CLAUDE.md @@ -266,4 +266,203 @@ The TS version needs to: you can use screen and bun run start to launch tui and debug ui -debug时需要使用本地磁盘log 不要污染stderr \ No newline at end of file +debug时需要使用本地磁盘log 不要污染stderr + +## Integration Testing: tmux Side-by-Side Verification + +Any new feature or bugfix that touches the TS version **must** be integration-tested against the Python version using tmux to confirm behavioral parity. This is a hard rule — do not skip it. + +### Why + +Unit tests cannot catch UI-level behavioral differences (approval dialog content, subagent event rendering, concurrent execution order, keyboard interaction timing). The only reliable way to verify TS↔Python alignment is to run both side-by-side with identical input and compare the observable output. + +### Procedure + +```bash +# 1. Launch both versions +tmux new-session -d -s py -x 130 -y 50 -c "$(pwd)" 'uv run kimi; exec bash' +tmux new-session -d -s ts -x 130 -y 50 -c "$(pwd)" 'bun run start; exec bash' +sleep 12 # wait for startup + +# 2. Verify ready (💫 prompt visible) +tmux capture-pane -t py -p | grep "💫" +tmux capture-pane -t ts -p | grep "💫" + +# 3. Send identical prompt to both +tmux send-keys -t py "your test prompt here" +tmux send-keys -t py Enter +tmux send-keys -t ts "your test prompt here" +tmux send-keys -t ts Enter + +# 4. Capture and compare output +tmux capture-pane -t py -p -S -200 > /tmp/py-output.txt +tmux capture-pane -t ts -p -S -200 > /tmp/ts-output.txt +diff /tmp/py-output.txt /tmp/ts-output.txt + +# 5. For approval interactions — send ONLY the number key, NOT Enter +# (number key triggers onSelect directly; Enter causes double-fire) +tmux send-keys -t ts "1" # approve once +tmux send-keys -t py "1" + +# 6. Cleanup +tmux kill-session -t py +tmux kill-session -t ts +``` + +### What to Compare + +| Aspect | How to check | +|--------|-------------| +| Approval dialog content | `grep -A 15 "ACTION REQUIRED"` — verify Subagent/Task metadata lines match | +| Concurrent subagent count | Look for `⟳ Using Agent` (TS) / `⠼ Using Agent` (PY) lines — count must match | +| Approval queue behavior | Approve first, wait 3s, check if second approval pops up | +| Tool execution order | Compare tool call/result sequence in output | +| Final result text | Compare the summary after all subagents complete | + +### When to Run + +- Any change to `soul/toolset.ts`, `soul/kimisoul.ts` (tool execution) +- Any change to `ui/hooks/useWire.ts`, `ui/shell/useShellCallbacks.ts` (approval flow) +- Any change to `subagents/runner.ts`, `subagents/builder.ts` (subagent lifecycle) +- Any change to `approval_runtime/`, `soul/approval.ts` (approval system) +- Any change to `cli/index.ts` rootWireHub event forwarding +- Any new tool or wire event type + +### Gotchas + +- **Do NOT send `Enter` after number key in approval panels.** Number keys (`1`/`2`/`3`/`4`) trigger selection directly in `SelectionPanel`. Sending Enter afterwards will approve the NEXT queued request due to React state update timing. This is a tmux testing artifact — real users press either number key OR arrow+Enter, not both. +- **Wait at least 12s after launch** before sending input. Ink TUI needs time to mount. +- **LLM responses are non-deterministic.** The exact wording will differ, but the structural behavior (number of subagents, approval sequence, tool calls) must match. +- **Check session logs** for debugging: `ls -1t ~/.kimi/sessions/*/*/logs.log | head -1 | xargs tail -50` + +## Concurrent Subagent Execution & Approval Queue + +### Problem Solved + +TS version only showed one subagent running when the LLM dispatched multiple Agent tool calls in parallel. Python showed both running concurrently with separate approval dialogs. + +Three layers of issues were found and fixed: + +### Layer 1: Tool Execution Concurrency (`soul/toolset.ts`) + +**Python** uses `ContextVar` + `asyncio.create_task()`: +```python +# toolset.handle() returns Task immediately — all tasks run concurrently +def handle(self, tool_call): + token = current_tool_call.set(tool_call) # ContextVar: per-task isolation + try: + return asyncio.create_task(_call()) # Task inherits ContextVar + finally: + current_tool_call.reset(token) +``` + +**TS had a module-level global** `let _currentToolCall` — concurrent tools clobbered each other's context. Fixed by using `AsyncLocalStorage` (Node.js equivalent of Python's `ContextVar`): + +```typescript +const _toolCallStorage = new AsyncLocalStorage(); + +handle(toolCall: ToolCall): Promise { + return _toolCallStorage.run(toolCall, () => + this._executeToolAsync(toolCall), + ); +} +``` + +`kimisoul.ts` `_executeToolsShielded()` follows 3-phase pattern: +1. **Dispatch**: create all promises immediately (non-blocking) +2. **Collect**: `Promise.all()` — true concurrent execution +3. **Append**: sequential context append (maintains order) + +### Layer 2: Approval Queue (`ui/hooks/useWire.ts`) + +**Python** queues approval requests in a `deque[ApprovalRequest]`, shows them one by one. + +**TS had** `pendingApproval: ApprovalRequest | null` — new requests replaced the old one. Fixed by using an array queue: + +```typescript +const [approvalQueue, setApprovalQueue] = useState([]); + +case "approval_request": + setApprovalQueue(prev => [...prev, event.request]); // enqueue +case "approval_response": + setApprovalQueue(prev => prev.filter(r => r.id !== event.requestId)); // dequeue +``` + +`pendingApproval` is derived as `approvalQueue[0]` — the head of the queue. + +`Shell.tsx` uses `key={wire.pendingApproval.id}` on `` to force React to remount when the head changes (resets all internal state like selection index). + +### Layer 3: Double-Fire Prevention (`useShellCallbacks.ts`, `SelectionPanel.tsx`) + +**Bug**: When user presses number key "1" to approve, `SelectionPanel` calls `onSelect(0)` immediately. 300ms later, the Enter key arrives. By then React has updated the queue — `pendingApproval` points to the NEXT request. The Enter event triggers `onSelect` again on the NEW `SelectionPanel` instance, auto-approving the second request without user intent. + +**Fix**: `handleApprovalResponse` in `useShellCallbacks.ts` uses: +1. `respondedIdsRef` (Set) — tracks already-responded request IDs across renders +2. Time-window debounce (400ms) — ignores rapid-fire responses + +```typescript +const respondedIdsRef = useRef(new Set()); +const lastRespondTimeRef = useRef(0); + +const handleApprovalResponse = useCallback((decision, feedback) => { + const current = wire.pendingApproval; + if (!current) return; + if (respondedIdsRef.current.has(current.id)) return; // already handled + if (Date.now() - lastRespondTimeRef.current < 400) return; // debounce + respondedIdsRef.current.add(current.id); + lastRespondTimeRef.current = Date.now(); + onApprovalResponseExternal?.(current.id, decision, feedback); + wire.pushEvent({ type: "approval_response", ... }); +}, [...]); +``` + +### Layer 4: Subagent Info in Approval Panel (`soul/approval.ts`, `subagents/runner.ts`, `cli/index.ts`) + +**Python** shows `Subagent: coder (agent_id)` and `Task: description` in the approval dialog by: +1. Setting `ApprovalSource` with `agent_id` and `subagent_type` via ContextVar before soul execution +2. Enriching `source_description` from `subagent_store.get_instance(agent_id).description` before UI display + +**TS was missing both**. Fixed by: +1. `subagents/runner.ts`: wraps soul execution in `runWithApprovalSourceAsync(source, fn)` so all inner `approval.request()` calls inherit the source +2. `soul/approval.ts`: reads `getCurrentApprovalSourceOrNull()` from AsyncLocalStorage (mirrors Python's `get_current_approval_source_or_none()`) +3. `cli/index.ts`: enriches `source_description` from `subagentStore.getInstance(agent_id).description` when forwarding approval requests to UI + +### Key Architectural Pattern: AsyncLocalStorage = Python ContextVar + +| Python | TypeScript | Purpose | +|--------|-----------|---------| +| `ContextVar[ToolCall]` | `AsyncLocalStorage` | Per-tool-call context in concurrent execution | +| `ContextVar[ApprovalSource]` | `AsyncLocalStorage` | Per-subagent approval source | +| `asyncio.create_task()` inherits ContextVar | `AsyncLocalStorage.run()` wraps async execution | Context propagation | + +### Files Changed + +| File | Change | +|------|--------| +| `soul/toolset.ts` | `AsyncLocalStorage` for `_currentToolCall`, non-blocking `handle()` | +| `soul/kimisoul.ts` | 3-phase concurrent tool execution | +| `soul/approval.ts` | Read approval source from `AsyncLocalStorage` | +| `subagents/runner.ts` | `runWithApprovalSourceAsync()` wrapping soul execution | +| `ui/hooks/useWire.ts` | Approval queue (array instead of single value) | +| `ui/shell/useShellCallbacks.ts` | Double-fire prevention (Set + debounce) | +| `ui/shell/Shell.tsx` | `key={request.id}` on ApprovalPanel | +| `cli/index.ts` | Enrich `source_description` from subagent store | + +### RootWireHub Event Flow for Approvals + +``` +Subagent Shell tool → approval.request() → ApprovalRuntime.createRequest() + → _publishWireRequest() → rootWireHub.publishNowait() + → cli/index.ts rootHubQueue.get() → enrich source_description + → pushEvent({ type: "approval_request", request }) + → useWire: setApprovalQueue(prev => [...prev, request]) + → Shell.tsx: + +User approves → handleApprovalResponse() + → onApprovalResponseExternal() → approvalRuntime.resolve() + → wire.pushEvent({ type: "approval_response" }) + → useWire: setApprovalQueue(prev => prev.filter(...)) + → Next request becomes queue[0] → ApprovalPanel re-renders +``` + +**Important**: Do NOT forward `ApprovalResponse` from rootWireHub to UI pushEvent. Responses flow only through `handleApprovalResponse → wire.pushEvent`. Forwarding both causes double-processing and phantom auto-approvals. \ No newline at end of file diff --git a/src/kimi_cli_ts/ui/components/PanelShell.tsx b/src/kimi_cli_ts/ui/components/PanelShell.tsx index d7dee90e1..98046072f 100644 --- a/src/kimi_cli_ts/ui/components/PanelShell.tsx +++ b/src/kimi_cli_ts/ui/components/PanelShell.tsx @@ -3,9 +3,9 @@ * * Two variant modes: * - "box": Unicode box border (╭─╮│╰─╯) with title centered in top border. - * Used by UsagePanel, DebugPanel, SelectionPanel. + * Used by UsagePanel. * - "rules": Horizontal rule lines (─) as top/bottom separator with header. - * Used by SlashMenu, ChoicePanel, ContentPanel. + * Used by CommandPanel. * * Pure render component — no useInput. */ diff --git a/src/kimi_cli_ts/ui/components/TitleBox.tsx b/src/kimi_cli_ts/ui/components/TitleBox.tsx new file mode 100644 index 000000000..5a3ada9ca --- /dev/null +++ b/src/kimi_cli_ts/ui/components/TitleBox.tsx @@ -0,0 +1,196 @@ +/** + * TitleBox.tsx — Box with a title riding on the top border. + * + * Renders an ink `` with native borderStyle for left/right/bottom, + * and a custom top border line with the title embedded: + * + * ╭─ TITLE ────────────────────────────╮ + * │ content │ + * ╰───────────────────────────────────╯ + * + * Uses `measureElement` to match the top line width to the actual + * computed Box width from Yoga layout. + * + * Drop-in replacement for `` — just add `title`. + */ + +import React, { useRef, useState, useLayoutEffect } from "react"; +import { Box, Text, measureElement, type DOMElement } from "ink"; + +// ── Border character sets ─────────────────────────────── + +const BORDER_CHARS = { + round: { tl: "╭", tr: "╮", h: "─", bl: "╰", br: "╯", v: "│" }, + single: { tl: "┌", tr: "┐", h: "─", bl: "└", br: "┘", v: "│" }, + bold: { tl: "┏", tr: "┓", h: "━", bl: "┗", br: "┛", v: "┃" }, + double: { tl: "╔", tr: "╗", h: "═", bl: "╚", br: "╝", v: "║" }, + singleDouble: { tl: "╓", tr: "╖", h: "─", bl: "╙", br: "╜", v: "║" }, + doubleSingle: { tl: "╒", tr: "╕", h: "═", bl: "╘", br: "╛", v: "│" }, + classic: { tl: "+", tr: "+", h: "-", bl: "+", br: "+", v: "|" }, +} as const; + +type BorderStyleName = keyof typeof BORDER_CHARS; + +// ── Props ─────────────────────────────────────────────── + +export interface TitleBoxProps { + /** Title text displayed on the top border. If omitted, renders a plain Box. */ + title?: string; + /** Color for the title text. Defaults to borderColor. */ + titleColor?: string; + /** Border color (applied to all sides). */ + borderColor?: string; + /** Border style name. Default: "round". */ + borderStyle?: BorderStyleName; + /** Title alignment within the top border. Default: "left". */ + titleAlign?: "left" | "center"; + + // ── Pass-through Box layout props ── + flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"; + paddingX?: number; + paddingY?: number; + padding?: number; + paddingTop?: number; + paddingBottom?: number; + paddingLeft?: number; + paddingRight?: number; + width?: number | string; + height?: number | string; + minWidth?: number | string; + minHeight?: number | string; + flexGrow?: number; + flexShrink?: number; + gap?: number; + alignItems?: "flex-start" | "center" | "flex-end" | "stretch"; + justifyContent?: "flex-start" | "flex-end" | "space-between" | "space-around" | "space-evenly" | "center"; + overflow?: "visible" | "hidden"; + overflowX?: "visible" | "hidden"; + overflowY?: "visible" | "hidden"; + marginTop?: number; + marginBottom?: number; + marginLeft?: number; + marginRight?: number; + marginX?: number; + marginY?: number; + margin?: number; + + children?: React.ReactNode; +} + +// ── Component ─────────────────────────────────────────── + +export function TitleBox({ + title, + titleColor, + borderColor, + borderStyle = "round", + titleAlign = "left", + children, + // Separate margin props (go on outer wrapper) from inner props + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginLeft, + marginRight, + ...innerProps +}: TitleBoxProps) { + const marginProps = { margin, marginX, marginY, marginTop, marginBottom, marginLeft, marginRight }; + + // No title — just render a standard Box + if (!title) { + return ( + + {children} + + ); + } + + const chars = BORDER_CHARS[borderStyle]; + const effectiveTitleColor = titleColor ?? borderColor; + + // Ref to measure the bordered box's computed width + const boxRef = useRef(null); + const [boxWidth, setBoxWidth] = useState(0); + + useLayoutEffect(() => { + if (boxRef.current) { + const { width } = measureElement(boxRef.current); + if (width !== boxWidth) { + setBoxWidth(width); + } + } + }); + + // Build the top border line with embedded title + // Total width = boxWidth (includes the 2 border chars for left/right) + // Inner width = boxWidth - 2 (left border + right border) + const innerWidth = Math.max(0, boxWidth - 2); + const titleStr = ` ${title} `; + const titleLen = titleStr.length; + + let topLine: React.ReactNode; + if (boxWidth > 0) { + const available = Math.max(0, innerWidth - titleLen); + let dashesLeft: number; + let dashesRight: number; + + if (titleAlign === "center") { + dashesLeft = Math.floor(available / 2); + dashesRight = available - dashesLeft; + } else { + // Left-aligned: small left padding (1 dash), rest on right + dashesLeft = Math.min(1, available); + dashesRight = Math.max(0, available - dashesLeft); + } + + topLine = ( + + + {chars.tl} + {chars.h.repeat(dashesLeft)} + + + {titleStr} + + + {chars.h.repeat(dashesRight)} + {chars.tr} + + + ); + } else { + // Before measurement: render a placeholder top line that will be replaced + topLine = ( + + {chars.tl}{chars.h} {title} {chars.h}{chars.tr} + + ); + } + + return ( + + {/* Custom top border with title */} + {topLine} + + {/* Box with native left/right/bottom borders, no top border */} + + {children} + + + ); +} + +export default TitleBox; diff --git a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx index 916ef3411..34f692a7e 100644 --- a/src/kimi_cli_ts/ui/components/WelcomeBox.tsx +++ b/src/kimi_cli_ts/ui/components/WelcomeBox.tsx @@ -9,8 +9,8 @@ import { modelDisplayName } from "../../llm.ts"; // Python uses 256-color palette index 33 = RGB(0, 135, 255) = #0087ff (dodger_blue1) const KIMI_BLUE = "#0087ff"; -// Python uses palette index 244 = RGB(178, 178, 178) = #b2b2b2 (grey50) -const GREY_50 = "#b2b2b2"; +// Python uses palette index 244 = RGB(128, 128, 128) = #808080 (grey50) +const GREY_50 = "#808080"; interface WelcomeBoxProps { workDir?: string; @@ -46,7 +46,7 @@ export function WelcomeBox({ > {/* Logo + Welcome */} - + ▐█▛█▛█▌ ▐█████▌ diff --git a/src/kimi_cli_ts/ui/hooks/useWire.ts b/src/kimi_cli_ts/ui/hooks/useWire.ts index cfafd2798..ac5bed500 100644 --- a/src/kimi_cli_ts/ui/hooks/useWire.ts +++ b/src/kimi_cli_ts/ui/hooks/useWire.ts @@ -12,7 +12,7 @@ import type { ToolCallSegment, } from "../shell/events"; import { extractKeyArgument } from "../../tools/types.ts"; -import type { StatusUpdate, ApprovalRequest } from "../../wire/types"; +import type { StatusUpdate, ApprovalRequest, QuestionRequest } from "../../wire/types"; import type { Toast } from "../components/NotificationStack"; import { nanoid } from "nanoid"; @@ -24,6 +24,10 @@ export interface WireState { pendingApproval: ApprovalRequest | null; /** Full queue of pending approval requests (including the current one). */ approvalQueue: ApprovalRequest[]; + /** The question request currently being shown to the user (head of queue). */ + pendingQuestion: QuestionRequest | null; + /** Full queue of pending question requests. */ + questionQueue: QuestionRequest[]; status: StatusUpdate | null; stepCount: number; isCompacting: boolean; @@ -48,6 +52,7 @@ export function useWire(options?: UseWireOptions): WireState & { // Approval queue — mirrors Python's deque[ApprovalRequest]. // The first element is the one currently displayed to the user. const [approvalQueue, setApprovalQueue] = useState([]); + const [questionQueue, setQuestionQueue] = useState([]); const [status, setStatus] = useState(null); const [stepCount, setStepCount] = useState(0); const [isCompacting, setIsCompacting] = useState(false); @@ -59,14 +64,16 @@ export function useWire(options?: UseWireOptions): WireState & { const pushEvent = useCallback((event: WireUIEvent) => { switch (event.type) { case "turn_begin": { - // Add user message - const userMsg: UIMessage = { - id: nanoid(), - role: "user", - segments: [{ type: "text", text: event.userInput }], - timestamp: Date.now(), - }; - setMessages((prev) => [...prev, userMsg]); + // Add user message (skip for slash commands where userInput is empty) + if (event.userInput) { + const userMsg: UIMessage = { + id: nanoid(), + role: "user", + segments: [{ type: "text", text: event.userInput }], + timestamp: Date.now(), + }; + setMessages((prev) => [...prev, userMsg]); + } setIsStreaming(true); setStepCount(0); // Start new assistant message @@ -188,6 +195,23 @@ export function useWire(options?: UseWireOptions): WireState & { break; } + case "question_request": { + // Enqueue question request — mirrors approval queue pattern + setQuestionQueue((prev) => { + if (prev.some((r) => r.id === event.request.id)) return prev; + return [...prev, event.request]; + }); + break; + } + + case "question_response": { + // Dequeue the resolved question + setQuestionQueue((prev) => + prev.filter((r) => r.id !== event.requestId), + ); + break; + } + case "status_update": { setStatus(event.status); break; @@ -354,12 +378,15 @@ export function useWire(options?: UseWireOptions): WireState & { // The head of the queue is the approval currently shown to the user const pendingApproval = approvalQueue.length > 0 ? approvalQueue[0]! : null; + const pendingQuestion = questionQueue.length > 0 ? questionQueue[0]! : null; return { messages, isStreaming, pendingApproval, approvalQueue, + pendingQuestion, + questionQueue, status, stepCount, isCompacting, diff --git a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx index 61dc6aafc..1f9d63b05 100644 --- a/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx @@ -107,7 +107,7 @@ function ContentPreview({ const showLines = lines.slice(0, budget); if (lines.length > budget) truncated = true; budget -= showLines.length; - elements.push({showLines.join("\n")}); + elements.push({showLines.join("\n")}); } else if (block.type === "brief") { const briefBlock = block as BriefDisplayBlock; const lines = briefBlock.brief.trim().split("\n"); diff --git a/src/kimi_cli_ts/ui/shell/Prompt.tsx b/src/kimi_cli_ts/ui/shell/Prompt.tsx index 9c107ed54..d57f0914f 100644 --- a/src/kimi_cli_ts/ui/shell/Prompt.tsx +++ b/src/kimi_cli_ts/ui/shell/Prompt.tsx @@ -71,7 +71,7 @@ export function Prompt({ panelInput = null, onPanelInputSubmit, }: PromptProps) { - const { value, setValue, historyPrev, historyNext, addToHistory, isFromHistory } = + const { value, setValue, historyPrev, historyNext, addToHistory, isBrowsingHistory } = useInputHistory(); const [slashMenuIndex, setSlashMenuIndex] = useState(0); @@ -123,7 +123,7 @@ export function Prompt({ // Detect slash completion mode — disabled in panel mode const isSlashMode = !inPanelMode && - value.startsWith("/") && !value.includes(" ") && commands.length > 0 && !isFromHistory; + value.startsWith("/") && !value.includes(" ") && commands.length > 0 && !isBrowsingHistory; const slashFilter = isSlashMode ? value.slice(1) : ""; const menuCount = isSlashMode ? getFilteredCommandCount(commands, slashFilter) @@ -322,7 +322,7 @@ export function Prompt({ } // ── Enter → submit ── - if (key.return || key.enter) { + if (key.return) { doSubmit(); return; } diff --git a/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx index b2869dbde..a191e1c48 100644 --- a/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/QuestionPanel.tsx @@ -2,25 +2,24 @@ * QuestionPanel.tsx — Interactive question panel with React Ink. * Corresponds to Python's ui/shell/question_panel.py. * + * Uses useSelectionInput hook for keyboard logic (shared with SelectionPanel). + * * Features: * - Multi-question tabs (◀/▶ to switch) - * - Number key selection (1-6) - * - Multi-select with space toggle - * - "Other" free text input - * - Body content area + * - Number key selection (1-9) + * - Multi-select with SPACE toggle + * - "Other" free text input (auto-activates when selected) + * - Draft persistence when navigating away from Other */ import React, { useState, useCallback } from "react"; -import { Box, Text, useInput } from "ink"; -import type { QuestionRequest, QuestionItem, QuestionOption } from "../../wire/types"; +import { Box, Text } from "ink"; +import type { QuestionRequest, QuestionItem } from "../../wire/types"; +import { useSelectionInput, type SelectionInputOption } from "./useSelectionInput.ts"; +import { TitleBox } from "../components/TitleBox.tsx"; const OTHER_OPTION_LABEL = "Other"; -interface OptionEntry { - label: string; - description: string; -} - export interface QuestionPanelProps { request: QuestionRequest; onAnswer: (answers: Record) => void; @@ -33,276 +32,112 @@ export function QuestionPanel({ onCancel, }: QuestionPanelProps) { const [questionIndex, setQuestionIndex] = useState(0); - const [selectedIndex, setSelectedIndex] = useState(0); - const [multiSelected, setMultiSelected] = useState>(new Set()); const [answers, setAnswers] = useState>({}); - const [otherMode, setOtherMode] = useState(false); - const [otherText, setOtherText] = useState(""); - const [otherDrafts, setOtherDrafts] = useState>({}); const question: QuestionItem = request.questions[questionIndex]!; - const options: OptionEntry[] = [ - ...question.options.map((o) => ({ - label: o.label, - description: o.description, - })), - { - label: question.other_label || OTHER_OPTION_LABEL, - description: question.other_description || "", - }, - ]; const isMultiSelect = question.multi_select; - const isOtherSelected = selectedIndex === options.length - 1; - const otherIdx = options.length - 1; - const advance = useCallback(() => { - const newAnswers = { ...answers }; - // Find next unanswered - const total = request.questions.length; - if (Object.keys(newAnswers).length >= total) { - onAnswer(newAnswers); - return true; - } - for (let offset = 1; offset <= total; offset++) { - const idx = (questionIndex + offset) % total; - if (!(request.questions[idx]!.question in newAnswers)) { - setQuestionIndex(idx); - setSelectedIndex(0); - setMultiSelected(new Set()); - setOtherMode(false); - setOtherText(""); - return false; - } - } - onAnswer(newAnswers); - return true; - }, [answers, questionIndex, request.questions, onAnswer]); + // Build option list: regular options + "Other" (with inputMode) + const selectionOptions: SelectionInputOption[] = [ + ...question.options.map(() => ({})), + { inputMode: true }, // "Other" option + ]; + const otherIdx = selectionOptions.length - 1; + const optionLabels = [ + ...question.options.map((o) => o.label), + question.other_label || OTHER_OPTION_LABEL, + ]; + const optionDescs = [ + ...question.options.map((o) => o.description), + question.other_description || "", + ]; - const submitCurrent = useCallback(() => { - if (isMultiSelect) { - if (otherIdx in [...multiSelected] && multiSelected.has(otherIdx)) { - // Need other input first - setOtherMode(true); - return; - } - const selected = [...multiSelected] - .filter((i) => i < question.options.length) - .sort() - .map((i) => options[i]!.label); - if (selected.length === 0) return; - const newAnswers = { ...answers, [question.question]: selected.join(", ") }; + // Advance to next unanswered question or submit all + const advanceOrSubmit = useCallback( + (newAnswers: Record) => { setAnswers(newAnswers); - // Check if all answered - if (Object.keys(newAnswers).length >= request.questions.length) { + const total = request.questions.length; + if (Object.keys(newAnswers).length >= total) { onAnswer(newAnswers); - } else { - advance(); - } - } else { - if (isOtherSelected) { - setOtherMode(true); return; } + for (let offset = 1; offset <= total; offset++) { + const idx = (questionIndex + offset) % total; + if (!(request.questions[idx]!.question in newAnswers)) { + setQuestionIndex(idx); + sel.reset(); + return; + } + } + onAnswer(newAnswers); + }, + [questionIndex, request.questions, onAnswer], + ); + + const sel = useSelectionInput({ + options: selectionOptions, + multiSelect: isMultiSelect, + + // Single-select: confirm option + onSelect: (idx) => { const newAnswers = { ...answers, - [question.question]: options[selectedIndex]!.label, + [question.question]: optionLabels[idx]!, }; - setAnswers(newAnswers); - if (Object.keys(newAnswers).length >= request.questions.length) { - onAnswer(newAnswers); - } else { - // Find next unanswered - const total = request.questions.length; - for (let offset = 1; offset <= total; offset++) { - const idx = (questionIndex + offset) % total; - if (!(request.questions[idx]!.question in newAnswers)) { - setQuestionIndex(idx); - setSelectedIndex(0); - setMultiSelected(new Set()); - return; - } - } - onAnswer(newAnswers); - } - } - }, [ - isMultiSelect, - multiSelected, - otherIdx, - question, - options, - selectedIndex, - isOtherSelected, - answers, - request.questions, - questionIndex, - onAnswer, - advance, - ]); + advanceOrSubmit(newAnswers); + }, - const submitOther = useCallback( - (text: string) => { - let newAnswers: Record; - if (isMultiSelect) { - const selected = [...multiSelected] - .filter((i) => i < question.options.length && i !== otherIdx) - .sort() - .map((i) => options[i]!.label); - if (text) selected.push(text); - newAnswers = { - ...answers, - [question.question]: selected.join(", ") || text, - }; - } else { - newAnswers = { ...answers, [question.question]: text }; - } - setAnswers(newAnswers); - setOtherMode(false); - setOtherText(""); - if (Object.keys(newAnswers).length >= request.questions.length) { - onAnswer(newAnswers); - } else { - const total = request.questions.length; - for (let offset = 1; offset <= total; offset++) { - const idx = (questionIndex + offset) % total; - if (!(request.questions[idx]!.question in newAnswers)) { - setQuestionIndex(idx); - setSelectedIndex(0); - setMultiSelected(new Set()); - return; - } - } - onAnswer(newAnswers); - } + // Single-select Other: submit typed text + onInputSubmit: (_idx, text) => { + const newAnswers = { + ...answers, + [question.question]: text, + }; + advanceOrSubmit(newAnswers); }, - [ - isMultiSelect, - multiSelected, - question, - otherIdx, - options, - answers, - request.questions, - questionIndex, - onAnswer, - ], - ); - useInput((input, key) => { - // Other text input mode - if (otherMode) { - if (key.return || key.enter) { - submitOther(otherText.trim()); - return; - } - if (key.escape) { - setOtherMode(false); - setOtherText(""); - onCancel(); - return; - } - if (key.backspace || key.delete) { - setOtherText((t) => t.slice(0, -1)); - return; - } - if (input && !key.ctrl && !key.meta) { - setOtherText((t) => t + input); - } - return; - } + // Multi-select: submit all checked + optional Other text + onMultiSubmit: (selected, inputText) => { + const labels = [...selected] + .filter((i) => i < question.options.length) + .sort() + .map((i) => optionLabels[i]!); + if (inputText) labels.push(inputText); + if (labels.length === 0) return; // nothing selected + const newAnswers = { + ...answers, + [question.question]: labels.join(", "), + }; + advanceOrSubmit(newAnswers); + }, + + onCancel, - // Navigation - if (key.upArrow) { - setSelectedIndex((i) => (i - 1 + options.length) % options.length); - } else if (key.downArrow) { - setSelectedIndex((i) => (i + 1) % options.length); - } else if (key.leftArrow) { - // Previous tab - if (questionIndex > 0) { + // ◄/► for question tabs + onExtraKey: (input, key) => { + if (key.leftArrow && questionIndex > 0) { setQuestionIndex(questionIndex - 1); - setSelectedIndex(0); - setMultiSelected(new Set()); + sel.reset(); + return true; } - } else if (key.rightArrow || key.tab) { - // Next tab - if (questionIndex < request.questions.length - 1) { + if ((key.rightArrow || key.tab) && questionIndex < request.questions.length - 1) { setQuestionIndex(questionIndex + 1); - setSelectedIndex(0); - setMultiSelected(new Set()); + sel.reset(); + return true; } - } else if (input === " " && isMultiSelect) { - // Toggle multi-select - setMultiSelected((prev) => { - const next = new Set(prev); - if (next.has(selectedIndex)) { - next.delete(selectedIndex); - } else { - next.add(selectedIndex); - } - return next; - }); - } else if (key.return || key.enter) { - submitCurrent(); - } else if (key.escape) { - onCancel(); - } else if (input >= "1" && input <= "6") { - const idx = parseInt(input) - 1; - if (idx < options.length) { - setSelectedIndex(idx); - if (isMultiSelect) { - setMultiSelected((prev) => { - const next = new Set(prev); - if (next.has(idx)) { - next.delete(idx); - } else { - next.add(idx); - } - return next; - }); - } else if (idx !== otherIdx) { - // Direct submit for non-other - const newAnswers = { - ...answers, - [question.question]: options[idx]!.label, - }; - setAnswers(newAnswers); - if (Object.keys(newAnswers).length >= request.questions.length) { - onAnswer(newAnswers); - } else { - const total = request.questions.length; - for (let offset = 1; offset <= total; offset++) { - const nextIdx = (questionIndex + offset) % total; - if ( - !(request.questions[nextIdx]!.question in newAnswers) - ) { - setQuestionIndex(nextIdx); - setSelectedIndex(0); - setMultiSelected(new Set()); - return; - } - } - onAnswer(newAnswers); - } - } else { - setOtherMode(true); - } - } - } + return false; + }, }); return ( - - {/* Title */} - - ? QUESTION - - - {/* Tabs for multi-question */} {request.questions.length > 1 && ( <> @@ -344,48 +179,49 @@ export function QuestionPanel({ )} {/* Options */} - {options.map((option, i) => { + {selectionOptions.map((_, i) => { const num = i + 1; - const isSelected = i === selectedIndex; + const isSelected = i === sel.selectedIndex; const isOther = i === otherIdx; + const label = optionLabels[i]!; + const desc = optionDescs[i]; if (isMultiSelect) { - const checked = multiSelected.has(i) ? "✓" : " "; - return ( - - - [{checked}] {option.label} + const checked = sel.multiSelected.has(i) ? "✓" : " "; + // Other with input cursor + if (isOther && isSelected) { + return ( + + [{checked}] {label}: {sel.inputText}█ - {option.description && !isSelected && ( - {" "}{option.description} - )} - + ); + } + return ( + + [{checked}] {label}{desc ? ` — ${desc}` : ""} + ); } - if (isOther && otherMode && isSelected) { + // Single-select: Other with input cursor + if (isOther && isSelected) { return ( - → [{num}] {option.label}: {otherText}█ + → [{num}] {label}: {sel.inputText}█ ); } return ( - - - {isSelected ? "→" : " "} [{num}] {option.label} - - {option.description && !(isOther && otherMode) && ( - {" "}{option.description} - )} - + + {isSelected ? "→" : " "} [{num}] {label}{desc ? ` — ${desc}` : ""} + ); })} {/* Hints */} - {otherMode ? ( + {sel.isInputActive ? ( {" "}Type your answer, then press Enter to submit. @@ -399,7 +235,7 @@ export function QuestionPanel({ {" "}▲/▼ select {" "}↵ submit {" "}esc exit )} - + ); } diff --git a/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx b/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx index 7753d44a4..0ab3d4782 100644 --- a/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx +++ b/src/kimi_cli_ts/ui/shell/SelectionPanel.tsx @@ -1,29 +1,21 @@ /** * SelectionPanel.tsx — Reusable selection panel with optional inline text input. * - * A bordered panel that displays a list of numbered options with keyboard - * navigation. One or more options can be marked as `inputMode`, which turns - * them into inline text fields when selected. - * - * Features: - * - ↑/↓ circular navigation with number-key shortcuts (1-N) - * - Inline text input for options with `inputMode: true` - * - Draft persistence when navigating away from input options - * - Captures ALL keyboard input via useInputLayer (nothing leaks) + * Uses useSelectionInput for keyboard logic. Renders a bordered panel with + * numbered options, optional children slot, and keyboard hints. * * Used by ApprovalPanel and any future panel that needs option selection. */ -import React, { useState, useRef } from "react"; +import React from "react"; import { Box, Text } from "ink"; -import { useInputLayer } from "./input-stack.ts"; +import { useSelectionInput, type SelectionInputOption } from "./useSelectionInput.ts"; +import { TitleBox } from "../components/TitleBox.tsx"; // ── Types ──────────────────────────────────────────────── -export interface SelectionOption { +export interface SelectionOption extends SelectionInputOption { label: string; - /** If true, selecting this option enters inline text input mode. */ - inputMode?: boolean; /** Prefix shown before the input cursor (e.g. "Reject: "). Defaults to label + ": ". */ inputPrefix?: string; } @@ -62,135 +54,25 @@ export function SelectionPanel({ titleColor, extraHint, }: SelectionPanelProps) { - const [selectedIndex, setSelectedIndex] = useState(0); - const [inputText, setInputText] = useState(""); - const inputDraftRef = useRef(""); - - const isInputActive = !!options[selectedIndex]?.inputMode; - const optCount = options.length; - - useInputLayer((input, key) => { - if (isInputActive) { - // ── INPUT MODE ── - if (key.return || key.enter) { - const text = inputText.trim(); - if (text) { - setInputText(""); - inputDraftRef.current = ""; - onInputSubmit?.(selectedIndex, text); - } - // Empty enter: keep editing (matches Python) - return; - } - - if (key.escape) { - setInputText(""); - inputDraftRef.current = ""; - onCancel?.(); - return; - } - - if (key.upArrow) { - inputDraftRef.current = inputText; - setInputText(""); - setSelectedIndex((i) => (i - 1 + optCount) % optCount); - return; - } - - if (key.downArrow) { - inputDraftRef.current = inputText; - setInputText(""); - setSelectedIndex((i) => (i + 1) % optCount); - return; - } - - if (key.backspace || key.delete) { - setInputText((t) => t.slice(0, -1)); - return; - } - - if (input && !key.ctrl && !key.meta) { - setInputText((t) => t + input); - return; - } - - return; // Consume everything in input mode - } - - // ── SELECTION MODE ── - if (key.upArrow) { - setSelectedIndex((prev) => { - const next = (prev - 1 + optCount) % optCount; - if (options[next]?.inputMode && inputDraftRef.current) { - setInputText(inputDraftRef.current); - } - return next; - }); - return; - } - - if (key.downArrow) { - setSelectedIndex((prev) => { - const next = (prev + 1) % optCount; - if (options[next]?.inputMode && inputDraftRef.current) { - setInputText(inputDraftRef.current); - } - return next; - }); - return; - } - - if (key.return || key.enter) { - inputDraftRef.current = ""; - onSelect(selectedIndex); - return; - } - - if (key.escape) { - onCancel?.(); - return; - } - - // Number keys 1-9 (up to option count) - if (input >= "1" && input <= "9") { - const idx = parseInt(input) - 1; - if (idx < optCount) { - setSelectedIndex(idx); - if (options[idx]?.inputMode) { - // Enter input mode; restore draft if available - if (inputDraftRef.current) { - setInputText(inputDraftRef.current); - } - } else { - inputDraftRef.current = ""; - onSelect(idx); - } - } - return; - } - - // Consume all other keys + const { selectedIndex, isInputActive, inputText } = useSelectionInput({ + options, + onSelect, + onInputSubmit, + onCancel, }); + const optCount = options.length; const effectiveTitleColor = titleColor ?? borderColor; return ( - - {/* Title */} - {title && ( - <> - - {title} - - {" "} - - )} - {/* Content slot */} {children} @@ -228,10 +110,10 @@ export function SelectionPanel({ ) : ( {" "}▲/▼ select{" "} - {optCount <= 9 ? `1/${optCount}` : "1-9"} choose{" "}↵ confirm + {optCount <= 9 ? Array.from({ length: optCount }, (_, i) => i + 1).join("/") : "1-9"} choose{" "}↵ confirm {extraHint ?? ""} )} - + ); } diff --git a/src/kimi_cli_ts/ui/shell/SetupWizard.tsx b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx index dbb43ba41..d17aa2618 100644 --- a/src/kimi_cli_ts/ui/shell/SetupWizard.tsx +++ b/src/kimi_cli_ts/ui/shell/SetupWizard.tsx @@ -113,7 +113,7 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + platforms.length) % platforms.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % platforms.length); - } else if (key.return || key.enter) { + } else if (key.return) { const platform = platforms[selectedIndex]!; setSelectedPlatform(platform); setStep("api_key"); @@ -138,7 +138,7 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + models.length) % models.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % models.length); - } else if (key.return || key.enter) { + } else if (key.return) { const model = models[selectedIndex]!; const caps = model.capabilities || []; if (caps.includes("always_thinking")) { @@ -160,14 +160,14 @@ export function SetupWizard({ setSelectedIndex((i) => (i - 1 + choices.length) % choices.length); } else if (key.downArrow) { setSelectedIndex((i) => (i + 1) % choices.length); - } else if (key.return || key.enter) { + } else if (key.return) { finishSetup(selectedModel!, selectedIndex === 0); } break; } case "error": { - if (key.return || key.enter) { + if (key.return) { setStep("api_key"); setApiKey(""); setError(""); diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 9461e556c..3c6601443 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -14,6 +14,8 @@ import { PromptView } from "./PromptView.tsx"; import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; import { ApprovalPanel } from "./ApprovalPanel.tsx"; +import { QuestionPanel } from "./QuestionPanel.tsx"; +import { resolveQuestionRequest, rejectQuestionRequest } from "../../tools/ask_user/ask_user.ts"; import { ChoicePanel, ContentPanel } from "../components/CommandPanel.tsx"; import { DebugPanel } from "./DebugPanel.tsx"; import { SlashMenu } from "../components/SlashMenu.tsx"; @@ -44,7 +46,7 @@ export interface ShellProps { thinking?: boolean; yolo?: boolean; prefillText?: string; - onSubmit?: (input: string) => void; + onSubmit?: (input: string) => Promise; onInterrupt?: () => void; onPlanModeToggle?: () => Promise; onApprovalResponse?: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => void; @@ -160,6 +162,7 @@ export function Shell({ } }, getDynamicViewportHeight: () => getLastFrameHeight(), + onSubmitExternal: onSubmit, }); const allCommands = createAllCommands(shellCommands, extraSlashCommands); @@ -220,6 +223,20 @@ export function Shell({ onRespond={handleApprovalResponse} /> )} + {wire.pendingQuestion && ( + { + resolveQuestionRequest(wire.pendingQuestion!.id, answers); + wire.pushEvent({ type: "question_response", requestId: wire.pendingQuestion!.id, answers }); + }} + onCancel={() => { + rejectQuestionRequest(wire.pendingQuestion!.id); + wire.pushEvent({ type: "question_response", requestId: wire.pendingQuestion!.id, answers: {} }); + }} + /> + )} Math.max(0, i - 1)); diff --git a/src/kimi_cli_ts/ui/shell/slash.ts b/src/kimi_cli_ts/ui/shell/slash.ts index 0ea5a3833..2029fada2 100644 --- a/src/kimi_cli_ts/ui/shell/slash.ts +++ b/src/kimi_cli_ts/ui/shell/slash.ts @@ -27,6 +27,8 @@ export interface ShellSlashContext { soulClear?: () => Promise; /** Get the current line count of the dynamic viewport below (prompt + bottom slot). */ getDynamicViewportHeight?: () => number; + /** Submit input through the soul (goes through runSoul with Wire context). Returns when turn completes. */ + onSubmitExternal?: (input: string) => Promise; } /** @@ -41,19 +43,18 @@ export function createShellSlashCommands( description: "Clear conversation history", aliases: ["cls", "reset"], handler: async () => { - // Match Python: soul clears context, then shell triggers same-session reload. - // Step 1: Clear the wire messages FIRST (before unmounting Ink) + // Match Python exactly: await run_soul_command("/clear"); raise Reload() + // Step 1: Clear UI messages before soul command (prevents flash of old content) ctx.clearMessages?.(); - // Step 2: Clear the soul context - await ctx.soulClear?.(); - // Step 3: Snapshot the dynamic viewport height BEFORE triggerReload unmounts Ink + // Step 2: Run soul /clear through Wire context (clears context + wire file) + await ctx.onSubmitExternal?.("/clear"); + // Step 3: Snapshot viewport height before reload unmounts Ink const height = ctx.getDynamicViewportHeight?.() ?? 5; - // Step 4: Trigger same-session reload — calls inkUnmount() which does a final render. + // Step 4: Trigger same-session reload (remounts Ink with fresh state) if (ctx.triggerReload && ctx.sessionId) { ctx.triggerReload(ctx.sessionId); } - // Step 5: AFTER Ink unmount, erase the residual dynamic viewport lines it left behind, - // then write the feedback message in their place. + // Step 5: Erase residual lines left by Ink unmount, show feedback const eraseLine = "\x1b[2K"; const cursorUp = "\x1b[A"; process.stdout.write( @@ -63,6 +64,36 @@ export function createShellSlashCommands( ); }, }, + { + name: "new", + description: "Start a new session", + handler: async () => { + // Match Python: create new session, clean up empty current session, then reload. + const info = ctx.getSessionInfo?.(); + if (!info || !ctx.triggerReload) { + ctx.pushNotification("New", "No active session."); + return; + } + const { Session } = await import("../../session.ts"); + const currentSession = await Session.find(info.workDir, ctx.sessionId ?? ""); + if (currentSession && await currentSession.isEmpty()) { + await currentSession.delete(); + } + const newSession = await Session.create(info.workDir); + // Snapshot viewport height before reload unmounts Ink + const height = ctx.getDynamicViewportHeight?.() ?? 5; + // Trigger reload with new session + ctx.triggerReload(newSession.id); + // Erase residual Ink lines and show feedback (same pattern as /clear) + const eraseLine = "\x1b[2K"; + const cursorUp = "\x1b[A"; + process.stdout.write( + (eraseLine + cursorUp).repeat(height) + + eraseLine + "\r" + + "\x1b[32mNew session created. Switching...\x1b[39m\n" + ); + }, + }, { name: "exit", description: "Exit the application", diff --git a/src/kimi_cli_ts/ui/shell/useSelectionInput.ts b/src/kimi_cli_ts/ui/shell/useSelectionInput.ts new file mode 100644 index 000000000..75b59fc3e --- /dev/null +++ b/src/kimi_cli_ts/ui/shell/useSelectionInput.ts @@ -0,0 +1,232 @@ +/** + * useSelectionInput — Reusable keyboard logic for selection panels. + * + * Extracted from SelectionPanel so both ApprovalPanel and QuestionPanel + * can share the same input behavior with different UIs. + * + * Features: + * - ↑/↓ circular navigation + * - Number key shortcuts (1-9) + * - Inline text input for options marked with inputMode + * - Draft persistence when navigating away from input options + * - Multi-select with SPACE toggle + * - Captures ALL keyboard input via useInputLayer + */ + +import { useState, useRef, useCallback } from "react"; +import { useInputLayer } from "./input-stack.ts"; + +export interface SelectionInputOption { + /** If true, this option enters inline text input when selected. */ + inputMode?: boolean; +} + +export interface UseSelectionInputOpts { + /** Option definitions (only inputMode flag is needed). */ + options: SelectionInputOption[]; + /** Called when user confirms selection (Enter or number key in single-select). */ + onSelect: (index: number) => void; + /** Called when user submits text from an inputMode option. */ + onInputSubmit?: (index: number, text: string) => void; + /** Called on Escape. */ + onCancel?: () => void; + /** Enable multi-select mode (SPACE to toggle, Enter to submit all). */ + multiSelect?: boolean; + /** Called when user submits in multi-select mode. Receives set of selected indices. */ + onMultiSubmit?: (selected: Set, inputText: string) => void; + /** Extra key handler for custom keys (e.g., ◄/► for tabs). Return true to consume. */ + onExtraKey?: (input: string, key: Record) => boolean; +} + +export interface SelectionInputState { + /** Currently highlighted option index. */ + selectedIndex: number; + /** Whether the currently selected option is in text input mode. */ + isInputActive: boolean; + /** Current text in the input field (only meaningful when isInputActive). */ + inputText: string; + /** Set of selected indices (multi-select mode). */ + multiSelected: Set; + /** Programmatically set the selected index. */ + setSelectedIndex: (index: number | ((prev: number) => number)) => void; + /** Reset all state (e.g., when switching questions). */ + reset: () => void; +} + +export function useSelectionInput(opts: UseSelectionInputOpts): SelectionInputState { + const { + options, + onSelect, + onInputSubmit, + onCancel, + multiSelect = false, + onMultiSubmit, + onExtraKey, + } = opts; + const [selectedIndex, setSelectedIndex] = useState(0); + const [inputText, setInputText] = useState(""); + const [multiSelected, setMultiSelected] = useState>(new Set()); + const inputDraftRef = useRef(""); + + const isInputActive = !!options[selectedIndex]?.inputMode; + const optCount = options.length; + + const reset = useCallback(() => { + setSelectedIndex(0); + setInputText(""); + setMultiSelected(new Set()); + inputDraftRef.current = ""; + }, []); + + useInputLayer((input, key) => { + // ── INPUT MODE (inputMode option selected) ── + if (isInputActive) { + if (key.return) { + const text = inputText.trim(); + if (multiSelect) { + // Multi-select: submit all selections + input text + onMultiSubmit?.(multiSelected, text); + return; + } + if (text) { + setInputText(""); + inputDraftRef.current = ""; + onInputSubmit?.(selectedIndex, text); + } + return; + } + + if (key.escape) { + setInputText(""); + inputDraftRef.current = ""; + onCancel?.(); + return; + } + + if (key.upArrow) { + inputDraftRef.current = inputText; + setInputText(""); + setSelectedIndex((i) => (i - 1 + optCount) % optCount); + return; + } + + if (key.downArrow) { + inputDraftRef.current = inputText; + setInputText(""); + setSelectedIndex((i) => (i + 1) % optCount); + return; + } + + if (key.backspace || key.delete) { + setInputText((t) => t.slice(0, -1)); + return; + } + + if (input && !key.ctrl && !key.meta) { + setInputText((t) => t + input); + return; + } + + return; // Consume everything in input mode + } + + // ── SELECTION MODE ── + + // Let caller handle custom keys first (e.g., ◄/► for tabs) + if (onExtraKey?.(input, key as unknown as Record)) { + return; + } + + if (key.upArrow) { + setSelectedIndex((prev) => { + const next = (prev - 1 + optCount) % optCount; + if (options[next]?.inputMode && inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + return next; + }); + return; + } + + if (key.downArrow) { + setSelectedIndex((prev) => { + const next = (prev + 1) % optCount; + if (options[next]?.inputMode && inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + return next; + }); + return; + } + + // SPACE: toggle in multi-select mode + if (input === " " && multiSelect) { + setMultiSelected((prev) => { + const next = new Set(prev); + if (next.has(selectedIndex)) { + next.delete(selectedIndex); + } else { + next.add(selectedIndex); + } + return next; + }); + return; + } + + if (key.return) { + inputDraftRef.current = ""; + if (multiSelect) { + onMultiSubmit?.(multiSelected, ""); + } else { + onSelect(selectedIndex); + } + return; + } + + if (key.escape) { + onCancel?.(); + return; + } + + // Number keys 1-9 (up to option count) + if (input >= "1" && input <= "9") { + const idx = parseInt(input) - 1; + if (idx < optCount) { + setSelectedIndex(idx); + if (options[idx]?.inputMode) { + // Navigate to input option; restore draft + if (inputDraftRef.current) { + setInputText(inputDraftRef.current); + } + } else if (multiSelect) { + // Toggle in multi-select + setMultiSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) { + next.delete(idx); + } else { + next.add(idx); + } + return next; + }); + } else { + // Direct select in single-select + inputDraftRef.current = ""; + onSelect(idx); + } + } + return; + } + + // Consume all other keys + }); + + return { + selectedIndex, + isInputActive, + inputText, + multiSelected, + setSelectedIndex, + reset, + }; +} diff --git a/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts index 4602ac4aa..5f349daac 100644 --- a/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts +++ b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts @@ -8,6 +8,7 @@ import { useCallback, useRef } from "react"; import { parseSlashCommand, findSlashCommand } from "./slash.ts"; import { runShellCommand, openExternalEditor } from "./shell-executor.ts"; +import { Reload } from "../../cli/index.ts"; import { SHELL_MODE_COMMANDS } from "./shell-commands.ts"; import type { WireUIEvent } from "./events.ts"; import type { ApprovalRequest, ApprovalResponseKind } from "../../wire/types.ts"; @@ -26,7 +27,7 @@ export interface UseShellCallbacksOptions { exit: () => void; pushNotification: (title: string, body: string) => void; /** External onSubmit from ShellProps — sends input to the agent loop. */ - onSubmitExternal?: (input: string) => void; + onSubmitExternal?: (input: string) => Promise; onInterruptExternal?: () => void; onPlanModeToggleExternal?: () => Promise; onApprovalResponseExternal?: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => void; @@ -61,7 +62,17 @@ interface InputStateAccessor { * Commands that are purely UI-side and never pass through soul.run(). * Mirrors Python: shell_slash_registry commands that are NOT also soul commands. */ -const PURE_SHELL_COMMANDS = new Set(["exit", "quit", "q", "theme"]); +const PURE_SHELL_COMMANDS = new Set([ + "exit", "quit", "q", + "theme", + "clear", "cls", "reset", + "new", + "help", "h", "?", + "version", + "undo", + "fork", + "usage", "status", +]); export function useShellCallbacks({ wire, @@ -116,7 +127,39 @@ export function useShellCallbacks({ // Panel support: if command has a panel and no args, open it locally if (knownCmd.panel && !parsed.args) { const pc = knownCmd.panel(); - if (pc) { inputRef.current.openPanel(pc); return; } + if (pc) { + // Wrap onSelect/onSubmit to catch Reload thrown by soul-level panels + // and translate to shell-level reload (matching Python pattern) + if (pc.type === "choice") { + const origOnSelect = pc.onSelect; + pc.onSelect = (value: string) => { + try { + return origOnSelect(value); + } catch (err) { + if (err instanceof Reload) { + onReloadExternal?.(err.sessionId ?? "", err.prefillText ?? undefined); + return; + } + throw err; + } + }; + } else if (pc.type === "input" && pc.onSubmit) { + const origOnSubmit = pc.onSubmit; + pc.onSubmit = (value: string) => { + try { + return origOnSubmit(value); + } catch (err) { + if (err instanceof Reload) { + onReloadExternal?.(err.sessionId ?? "", err.prefillText ?? undefined); + return; + } + throw err; + } + }; + } + inputRef.current.openPanel(pc); + return; + } } // Route to soul.run() — mirrors Python: await self.run_soul_command(raw_input) onSubmitExternal?.(input); @@ -130,19 +173,28 @@ export function useShellCallbacks({ if (inputRef.current.shellMode) { runShellCommand(input, pushNotification); return; } onSubmitExternal?.(input); }, - [allCommands, shellCommands, onSubmitExternal, wire, pushNotification], + [allCommands, shellCommands, onSubmitExternal, onReloadExternal, wire, pushNotification], ); const onSlashExecute = useCallback((cmd: SlashCommand) => { - const result = cmd.handler(""); - if (result && typeof result.then === "function") { - result.then((feedback: void | string) => { - if (typeof feedback === "string") { - wire.pushEvent({ type: "slash_result", text: feedback }); - } - }); + // Shell commands (clear, exit, quit, theme, etc.) run directly — their handlers + // manage their own lifecycle and don't need Wire context. + // Soul commands must route through onSubmitExternal → runSoul() for Wire context. + const isShellCmd = shellCommands.some(sc => sc.name === cmd.name || sc.aliases?.includes(cmd.name)); + if (isShellCmd) { + const result = cmd.handler(""); + if (result && typeof result.then === "function") { + result.then((feedback: void | string) => { + if (typeof feedback === "string") { + wire.pushEvent({ type: "slash_result", text: feedback }); + } + }); + } + } else { + // Soul command — route through runSoul() to ensure Wire context exists + onSubmitExternal?.(`/${cmd.name}`); } - }, [wire]); + }, [wire, shellCommands, onSubmitExternal]); const onExit = useCallback(() => exit(), [exit]); diff --git a/src/kimi_cli_ts/ui/theme.ts b/src/kimi_cli_ts/ui/theme.ts index bb38a5ae9..b13fd0dbd 100644 --- a/src/kimi_cli_ts/ui/theme.ts +++ b/src/kimi_cli_ts/ui/theme.ts @@ -115,8 +115,8 @@ const MESSAGE_DARK: MessageColors = { tool: "#0000d7", // Rich "blue" — ANSI basic blue (\e[34m) error: "#ff7b72", // Rich "dark_red" darkRed: "#870000", // Rich "dark_red" — 256-color idx 88, for rejected/error bullets - dim: "#b2b2b2", // Rich "grey50" — 256-color idx 244 - thinking: "#b2b2b2", // Rich "grey50" — matches Python's grey50 italic + dim: "#808080", // Rich "grey50" — 256-color idx 244 = RGB(128,128,128) + thinking: "#808080", // Rich "grey50" — 256-color idx 244 = RGB(128,128,128) highlight: "#56d364", // Rich "green" }; diff --git a/src/kimi_cli_ts/wire/file.ts b/src/kimi_cli_ts/wire/file.ts index 8ce53b465..fba681664 100644 --- a/src/kimi_cli_ts/wire/file.ts +++ b/src/kimi_cli_ts/wire/file.ts @@ -286,11 +286,9 @@ export class WireFile { } content += JSON.stringify(record) + "\n"; - // Append to file - const writer = Bun.file(this.path).writer(); - writer.write(content); - await writer.flush(); - writer.end(); + // Append to file (not overwrite!) + const { appendFile: fsAppendFile } = await import("node:fs/promises"); + await fsAppendFile(this.path, content, "utf-8"); } toString(): string { diff --git a/src/kimi_cli_ts/wire/serde.ts b/src/kimi_cli_ts/wire/serde.ts index bd28e55f9..66fa422a9 100644 --- a/src/kimi_cli_ts/wire/serde.ts +++ b/src/kimi_cli_ts/wire/serde.ts @@ -12,10 +12,16 @@ import { } from "./types.ts"; /** - * Detect the type name of a WireMessage by trying each schema. - * Returns the first matching type name. + * Detect the type name of a WireMessage. + * Uses __wireType hint if available (fast path from wireSend), + * otherwise falls back to trial-parsing each schema. */ function detectTypeName(msg: Record): string | null { + // Fast path: tagged messages from wireSend() + if (typeof msg.__wireType === "string" && msg.__wireType in _wireMessageSchemas) { + return msg.__wireType; + } + // Slow path: trial-parse each schema for (const [name, schema] of Object.entries(_wireMessageSchemas)) { const result = schema.safeParse(msg); if (result.success) return name; @@ -51,7 +57,9 @@ export function serializeWireMessage( if (!typeName) { throw new Error(`Cannot detect wire message type for: ${JSON.stringify(msg)}`); } - return toEnvelope(typeName, msg) as Record; + // Strip __wireType hint from serialized payload + const { __wireType: _, ...cleanPayload } = msg; + return toEnvelope(typeName, cleanPayload) as Record; } /** From 0ad8bc0f1993c248ff7bf2eab3a49748c9663fd6 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sun, 5 Apr 2026 19:06:47 +0800 Subject: [PATCH 19/28] feat(ts): handle terminal resize without remounting Shell UI --- src/kimi_cli_ts/cli/index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index 3383280ea..e9cde7367 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -375,8 +375,10 @@ program inkUnmountFn?.(); }; - // Create a UILoopFn that translates Wire messages → WireUIEvent for Shell - const createShellUILoopFn = (pe: (event: WireUIEvent) => void): UILoopFn => { + // Create a UILoopFn that translates Wire messages → WireUIEvent for Shell. + // Uses the outer `pushEvent` variable (not a parameter) so that event + // routing stays correct across Shell lifecycle changes. + const createShellUILoopFn = (): UILoopFn => { return async (wire) => { const uiSide = wire.uiSide(true); while (true) { @@ -387,6 +389,8 @@ program if (err instanceof QueueShutDown) break; throw err; } + const pe = pushEvent; + if (!pe) continue; const t = msg.__wireType ?? ""; if (t === "TurnBegin") { const raw = msg.user_input; @@ -490,7 +494,7 @@ program currentCancelController = cancelController; const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; try { - await runSoul(app.soul, input, createShellUILoopFn(pushEvent!), cancelController, { + await runSoul(app.soul, input, createShellUILoopFn(), cancelController, { wireFile, runtime: app.soul.runtime, }); @@ -588,7 +592,7 @@ program const cancelController = new AbortController(); currentCancelController = cancelController; const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; - runSoul(app.soul, currentPrompt, createShellUILoopFn(pushEvent!), cancelController, { + runSoul(app.soul, currentPrompt, createShellUILoopFn(), cancelController, { wireFile, runtime: app.soul.runtime, }).catch((err) => { From 9f9b2dcccd719619e856eae9c1b610e77c949579 Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Sun, 5 Apr 2026 19:16:32 +0800 Subject: [PATCH 20/28] feat(ts): use resizeKey to rebuild Shell JSX subtree on terminal resize --- src/kimi_cli_ts/ui/shell/Shell.tsx | 4 ++-- src/kimi_cli_ts/ui/shell/useShellLayout.ts | 27 ++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index 3c6601443..f7fd67105 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -193,12 +193,12 @@ export function Shell({ }); // ── Layout ── - const { staticItems } = useShellLayout(wire.messages, wire.isStreaming); + const { staticItems, resizeKey } = useShellLayout(wire.messages, wire.isStreaming); const mode = inputState.mode; const promptSymbol = getPromptSymbol(mode, inputState.shellMode, thinking, wire.status?.plan_mode ?? false); return ( - + {(item: any) => item._isWelcome ? ( diff --git a/src/kimi_cli_ts/ui/shell/useShellLayout.ts b/src/kimi_cli_ts/ui/shell/useShellLayout.ts index 085415811..a7ba3f086 100644 --- a/src/kimi_cli_ts/ui/shell/useShellLayout.ts +++ b/src/kimi_cli_ts/ui/shell/useShellLayout.ts @@ -2,7 +2,7 @@ * useShellLayout.ts — Terminal height tracking and static items computation. */ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useStdout } from "ink"; import type { UIMessage } from "./events.ts"; @@ -21,10 +21,29 @@ export function useShellLayout(messages: UIMessage[], isStreaming: boolean) { const { stdout } = useStdout(); const [termHeight, setTermHeight] = useState(stdout?.rows || 24); + // Monotonic counter incremented after resize settles (debounced). + // Used as React key on Shell's root Box to force a full subtree rebuild, + // which makes re-output all items at the new terminal width. + // Shell's own hooks (useWire, useShellInput, etc.) are unaffected because + // Shell itself does not unmount — only its returned JSX tree is rebuilt. + const [resizeKey, setResizeKey] = useState(0); + const debounceRef = useRef | null>(null); + useEffect(() => { - const onResize = () => setTermHeight(stdout?.rows || 24); + const onResize = () => { + setTermHeight(stdout?.rows || 24); + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + setResizeKey((k) => k + 1); + }, 300); + }; stdout?.on("resize", onResize); - return () => { stdout?.off("resize", onResize); }; + return () => { + stdout?.off("resize", onResize); + if (debounceRef.current) clearTimeout(debounceRef.current); + }; }, [stdout]); const staticItems = React.useMemo( @@ -32,5 +51,5 @@ export function useShellLayout(messages: UIMessage[], isStreaming: boolean) { [messages, isStreaming], ); - return { termHeight, staticItems }; + return { termHeight, staticItems, resizeKey }; } From 46ee45a1ee2bf1451ae6ebabff2eb19bf6004e7c Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Mon, 6 Apr 2026 02:58:37 +0800 Subject: [PATCH 21/28] feat(ts): align TS codebase structure 1:1 with Python, fix circular imports, fix print mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural alignment: - Add missing barrel index.ts for all modules (approval_runtime, background, hooks, plugin, subagents, soul/dynamic_injections, tools/*, utils) - Extract share.ts, session_state.ts from config.ts/session.ts - Rename tool files to index.ts for symmetry (agent.ts→index.ts, etc.) - Add new modules: acp/, vis/, web/, cli/toad.ts, tools/plan/enter.ts, tools/test.ts - Add 13 missing utils (datetime, editor, envvar, frontmatter, io, etc.) Bug fixes: - Fix circular import cli→app→soul→cli by extracting cli/errors.ts - Lazy-load heavy modules (react, ink, app, soul) in CLI handler - Fix print mode to match Python's rich.pretty_repr output format Co-Authored-By: Claude Opus 4.6 (1M context) --- src/kimi_cli_ts/acp/convert.ts | 132 +++ src/kimi_cli_ts/acp/index.ts | 11 + src/kimi_cli_ts/acp/kaos.ts | 300 +++++++ src/kimi_cli_ts/acp/mcp.ts | 70 ++ src/kimi_cli_ts/acp/server.ts | 512 ++++++++++++ src/kimi_cli_ts/acp/session.ts | 552 +++++++++++++ src/kimi_cli_ts/acp/tools.ts | 241 ++++++ src/kimi_cli_ts/acp/types.ts | 366 +++++++++ src/kimi_cli_ts/acp/version.ts | 47 ++ src/kimi_cli_ts/approval_runtime/index.ts | 343 +------- src/kimi_cli_ts/approval_runtime/models.ts | 38 + src/kimi_cli_ts/approval_runtime/runtime.ts | 298 +++++++ src/kimi_cli_ts/background/agent_runner.ts | 247 ++++++ src/kimi_cli_ts/background/index.ts | 26 + src/kimi_cli_ts/background/manager.ts | 21 + src/kimi_cli_ts/cli/errors.ts | 41 + src/kimi_cli_ts/cli/index.ts | 278 +++++-- src/kimi_cli_ts/cli/toad.ts | 90 +++ src/kimi_cli_ts/config.ts | 7 +- src/kimi_cli_ts/hooks/index.ts | 7 + src/kimi_cli_ts/hooks/runner.ts | 114 +++ src/kimi_cli_ts/plugin/index.ts | 28 + src/kimi_cli_ts/session.ts | 62 +- src/kimi_cli_ts/session_state.ts | 154 ++++ src/kimi_cli_ts/share.ts | 18 + src/kimi_cli_ts/soul/agent.ts | 12 +- .../soul/dynamic_injections/index.ts | 6 + src/kimi_cli_ts/soul/kimisoul.ts | 4 +- src/kimi_cli_ts/subagents/index.ts | 16 + .../tools/agent/{agent.ts => index.ts} | 0 .../tools/ask_user/{ask_user.ts => index.ts} | 0 .../background/{background.ts => index.ts} | 0 .../tools/dmail/{dmail.ts => index.ts} | 0 src/kimi_cli_ts/tools/file/index.ts | 11 + src/kimi_cli_ts/tools/file/replace.ts | 316 ++++---- src/kimi_cli_ts/tools/file/write.ts | 315 ++++---- src/kimi_cli_ts/tools/index.ts | 10 + src/kimi_cli_ts/tools/plan/enter.ts | 124 +++ .../tools/plan/{plan.ts => index.ts} | 123 +-- .../tools/shell/{shell.ts => index.ts} | 0 src/kimi_cli_ts/tools/test.ts | 78 ++ .../tools/think/{think.ts => index.ts} | 0 .../tools/todo/{todo.ts => index.ts} | 0 src/kimi_cli_ts/tools/web/index.ts | 7 + src/kimi_cli_ts/ui/shell/Shell.tsx | 2 +- src/kimi_cli_ts/ui/shell/UsagePanel.tsx | 516 ++++++------ src/kimi_cli_ts/ui/shell/Visualize.tsx | 19 +- .../ui/shell/commands/export_import.ts | 227 +++--- src/kimi_cli_ts/ui/shell/useShellCallbacks.ts | 2 +- src/kimi_cli_ts/utils/broadcast.ts | 6 + src/kimi_cli_ts/utils/datetime.ts | 52 ++ src/kimi_cli_ts/utils/diff.ts | 580 +++++++------ src/kimi_cli_ts/utils/editor.ts | 100 +++ src/kimi_cli_ts/utils/envvar.ts | 24 + src/kimi_cli_ts/utils/frontmatter.ts | 50 ++ src/kimi_cli_ts/utils/index.ts | 36 + src/kimi_cli_ts/utils/io.ts | 30 + src/kimi_cli_ts/utils/media_tags.ts | 35 + src/kimi_cli_ts/utils/proxy.ts | 33 + src/kimi_cli_ts/utils/server.ts | 143 ++++ src/kimi_cli_ts/utils/slashcmd.ts | 90 +++ src/kimi_cli_ts/utils/subprocess_env.ts | 43 + src/kimi_cli_ts/utils/term.ts | 27 + src/kimi_cli_ts/vis/api/index.ts | 7 + src/kimi_cli_ts/vis/api/sessions.ts | 763 ++++++++++++++++++ src/kimi_cli_ts/vis/api/statistics.ts | 259 ++++++ src/kimi_cli_ts/vis/api/system.ts | 23 + src/kimi_cli_ts/vis/app.ts | 371 +++++++++ src/kimi_cli_ts/vis/index.ts | 6 + src/kimi_cli_ts/web/api/config.ts | 191 +++++ src/kimi_cli_ts/web/api/index.ts | 7 + src/kimi_cli_ts/web/api/open_in.ts | 153 ++++ src/kimi_cli_ts/web/api/sessions.ts | 708 ++++++++++++++++ src/kimi_cli_ts/web/app.ts | 397 +++++++++ src/kimi_cli_ts/web/auth.ts | 213 +++++ src/kimi_cli_ts/web/index.ts | 13 + src/kimi_cli_ts/web/models.ts | 101 +++ src/kimi_cli_ts/web/runner/index.ts | 11 + src/kimi_cli_ts/web/runner/messages.ts | 53 ++ src/kimi_cli_ts/web/runner/process.ts | 400 +++++++++ src/kimi_cli_ts/web/runner/worker.ts | 101 +++ src/kimi_cli_ts/web/store/index.ts | 15 + src/kimi_cli_ts/web/store/sessions.ts | 405 ++++++++++ tests/tools/ask_user.test.ts | 2 +- tests/tools/shell.test.ts | 2 +- tests/tools/think.test.ts | 2 +- tests/tools/todo.test.ts | 2 +- tests/tools/tool_schemas.test.ts | 8 +- 88 files changed, 9760 insertions(+), 1493 deletions(-) create mode 100644 src/kimi_cli_ts/acp/convert.ts create mode 100644 src/kimi_cli_ts/acp/index.ts create mode 100644 src/kimi_cli_ts/acp/kaos.ts create mode 100644 src/kimi_cli_ts/acp/mcp.ts create mode 100644 src/kimi_cli_ts/acp/server.ts create mode 100644 src/kimi_cli_ts/acp/session.ts create mode 100644 src/kimi_cli_ts/acp/tools.ts create mode 100644 src/kimi_cli_ts/acp/types.ts create mode 100644 src/kimi_cli_ts/acp/version.ts create mode 100644 src/kimi_cli_ts/approval_runtime/models.ts create mode 100644 src/kimi_cli_ts/approval_runtime/runtime.ts create mode 100644 src/kimi_cli_ts/background/agent_runner.ts create mode 100644 src/kimi_cli_ts/background/index.ts create mode 100644 src/kimi_cli_ts/cli/errors.ts create mode 100644 src/kimi_cli_ts/cli/toad.ts create mode 100644 src/kimi_cli_ts/hooks/index.ts create mode 100644 src/kimi_cli_ts/hooks/runner.ts create mode 100644 src/kimi_cli_ts/plugin/index.ts create mode 100644 src/kimi_cli_ts/session_state.ts create mode 100644 src/kimi_cli_ts/share.ts create mode 100644 src/kimi_cli_ts/soul/dynamic_injections/index.ts create mode 100644 src/kimi_cli_ts/subagents/index.ts rename src/kimi_cli_ts/tools/agent/{agent.ts => index.ts} (100%) rename src/kimi_cli_ts/tools/ask_user/{ask_user.ts => index.ts} (100%) rename src/kimi_cli_ts/tools/background/{background.ts => index.ts} (100%) rename src/kimi_cli_ts/tools/dmail/{dmail.ts => index.ts} (100%) create mode 100644 src/kimi_cli_ts/tools/file/index.ts create mode 100644 src/kimi_cli_ts/tools/index.ts create mode 100644 src/kimi_cli_ts/tools/plan/enter.ts rename src/kimi_cli_ts/tools/plan/{plan.ts => index.ts} (61%) rename src/kimi_cli_ts/tools/shell/{shell.ts => index.ts} (100%) create mode 100644 src/kimi_cli_ts/tools/test.ts rename src/kimi_cli_ts/tools/think/{think.ts => index.ts} (100%) rename src/kimi_cli_ts/tools/todo/{todo.ts => index.ts} (100%) create mode 100644 src/kimi_cli_ts/tools/web/index.ts create mode 100644 src/kimi_cli_ts/utils/broadcast.ts create mode 100644 src/kimi_cli_ts/utils/datetime.ts create mode 100644 src/kimi_cli_ts/utils/editor.ts create mode 100644 src/kimi_cli_ts/utils/envvar.ts create mode 100644 src/kimi_cli_ts/utils/frontmatter.ts create mode 100644 src/kimi_cli_ts/utils/index.ts create mode 100644 src/kimi_cli_ts/utils/io.ts create mode 100644 src/kimi_cli_ts/utils/media_tags.ts create mode 100644 src/kimi_cli_ts/utils/proxy.ts create mode 100644 src/kimi_cli_ts/utils/server.ts create mode 100644 src/kimi_cli_ts/utils/slashcmd.ts create mode 100644 src/kimi_cli_ts/utils/subprocess_env.ts create mode 100644 src/kimi_cli_ts/utils/term.ts create mode 100644 src/kimi_cli_ts/vis/api/index.ts create mode 100644 src/kimi_cli_ts/vis/api/sessions.ts create mode 100644 src/kimi_cli_ts/vis/api/statistics.ts create mode 100644 src/kimi_cli_ts/vis/api/system.ts create mode 100644 src/kimi_cli_ts/vis/app.ts create mode 100644 src/kimi_cli_ts/vis/index.ts create mode 100644 src/kimi_cli_ts/web/api/config.ts create mode 100644 src/kimi_cli_ts/web/api/index.ts create mode 100644 src/kimi_cli_ts/web/api/open_in.ts create mode 100644 src/kimi_cli_ts/web/api/sessions.ts create mode 100644 src/kimi_cli_ts/web/app.ts create mode 100644 src/kimi_cli_ts/web/auth.ts create mode 100644 src/kimi_cli_ts/web/index.ts create mode 100644 src/kimi_cli_ts/web/models.ts create mode 100644 src/kimi_cli_ts/web/runner/index.ts create mode 100644 src/kimi_cli_ts/web/runner/messages.ts create mode 100644 src/kimi_cli_ts/web/runner/process.ts create mode 100644 src/kimi_cli_ts/web/runner/worker.ts create mode 100644 src/kimi_cli_ts/web/store/index.ts create mode 100644 src/kimi_cli_ts/web/store/sessions.ts diff --git a/src/kimi_cli_ts/acp/convert.ts b/src/kimi_cli_ts/acp/convert.ts new file mode 100644 index 000000000..c8ec9bf40 --- /dev/null +++ b/src/kimi_cli_ts/acp/convert.ts @@ -0,0 +1,132 @@ +/** + * ACP content block conversion — corresponds to Python acp/convert.py + * Converts between ACP content blocks and internal wire types. + */ + +import type { + ACPContentBlock, + ContentToolCallContent, + FileEditToolCallContent, + TerminalToolCallContent, + TextContentBlock, +} from "./types.ts"; +import type { ContentPart, ToolReturnValue } from "../types.ts"; +import type { DisplayBlock, DiffDisplayBlock, TodoDisplayBlock } from "../wire/types.ts"; +import { logger } from "../utils/logging.ts"; + +/** + * Convert ACP content blocks to internal ContentPart array. + * Corresponds to Python acp_blocks_to_content_parts(). + */ +export function acpBlocksToContentParts(prompt: ACPContentBlock[]): ContentPart[] { + const content: ContentPart[] = []; + for (const block of prompt) { + switch (block.type) { + case "text": + content.push({ type: "text", text: block.text }); + break; + case "image": + content.push({ + type: "image", + source: { + type: "base64", + mediaType: block.mime_type, + data: block.data, + }, + }); + break; + case "embedded_resource": { + const resource = block.resource; + if (resource.type === "text") { + content.push({ + type: "text", + text: `\n${resource.text}\n`, + }); + } else { + logger.warn(`Unsupported embedded resource type: ${resource.type}`); + } + break; + } + case "resource": + content.push({ + type: "text", + text: ``, + }); + break; + default: + logger.warn(`Unsupported prompt content block: ${JSON.stringify(block)}`); + } + } + return content; +} + +/** + * Convert a DisplayBlock to ACP FileEditToolCallContent. + * Returns null for non-diff blocks. + * Corresponds to Python display_block_to_acp_content(). + */ +export function displayBlockToAcpContent( + block: DisplayBlock, +): FileEditToolCallContent | null { + if (block.type === "diff") { + const diffBlock = block as DiffDisplayBlock; + return { + type: "diff", + path: diffBlock.path, + old_text: diffBlock.old_text, + new_text: diffBlock.new_text, + }; + } + return null; +} + +/** + * Convert a ToolReturnValue to ACP content list. + * Corresponds to Python tool_result_to_acp_content(). + */ +export function toolResultToAcpContent( + toolRet: ToolReturnValue, + shouldHide: boolean = false, +): (ContentToolCallContent | FileEditToolCallContent | TerminalToolCallContent)[] { + if (shouldHide) { + return []; + } + + const contents: (ContentToolCallContent | FileEditToolCallContent | TerminalToolCallContent)[] = + []; + + // Process display blocks + if (toolRet.display) { + for (const block of toolRet.display) { + const displayBlock = block as DisplayBlock; + if (displayBlock.type === "acp/hide_output") { + // Return early to indicate no output should be shown + return []; + } + const content = displayBlockToAcpContent(displayBlock); + if (content !== null) { + contents.push(content); + } + } + } + + // Process output + const output = toolRet.output; + if (output) { + contents.push(toTextBlock(output)); + } + + // Fallback to message if no other content + if (contents.length === 0 && toolRet.message) { + contents.push(toTextBlock(toolRet.message)); + } + + return contents; +} + +function toTextBlock(text: string): ContentToolCallContent { + return { + type: "content", + content: { type: "text", text } as TextContentBlock, + }; +} diff --git a/src/kimi_cli_ts/acp/index.ts b/src/kimi_cli_ts/acp/index.ts new file mode 100644 index 000000000..3d3497bfb --- /dev/null +++ b/src/kimi_cli_ts/acp/index.ts @@ -0,0 +1,11 @@ +/** + * ACP entry point — corresponds to Python acp/__init__.py + * Starts the ACP server on stdio. + */ + +export { ACPServer } from "./server.ts"; +export { ACPSession } from "./session.ts"; +export type { ACPClient, ACPKaos } from "./kaos.ts"; +export { replaceTools } from "./tools.ts"; +export { negotiateVersion } from "./version.ts"; +export { acpMcpServersToMcpConfig } from "./mcp.ts"; diff --git a/src/kimi_cli_ts/acp/kaos.ts b/src/kimi_cli_ts/acp/kaos.ts new file mode 100644 index 000000000..652684b33 --- /dev/null +++ b/src/kimi_cli_ts/acp/kaos.ts @@ -0,0 +1,300 @@ +/** + * ACP Kaos adapter — corresponds to Python acp/kaos.py + * Routes file operations through ACP client with fallback to local fs. + * ACPProcess polls ACP terminal for output. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import { logger } from "../utils/logging.ts"; +import type { + ClientCapabilities, + TerminalOutputResponse, + WaitForTerminalExitResponse, +} from "./types.ts"; + +const _DEFAULT_TERMINAL_OUTPUT_LIMIT = 50_000; +const _DEFAULT_POLL_INTERVAL = 0.2; +const _TRUNCATION_NOTICE = "[acp output truncated]\n"; + +/** + * Minimal ACP client interface used by ACPKaos and ACPProcess. + * Matches the subset of methods needed from the Python acp.Client. + */ +export interface ACPClient { + readTextFile(opts: { path: string; sessionId: string }): Promise<{ content: string }>; + writeTextFile(opts: { path: string; content: string; sessionId: string }): Promise; + createTerminal(opts: { + command: string; + sessionId: string; + outputByteLimit?: number; + }): Promise<{ terminalId: string }>; + terminalOutput(opts: { + sessionId: string; + terminalId: string; + }): Promise; + waitForTerminalExit(opts: { + sessionId: string; + terminalId: string; + }): Promise; + killTerminal(opts: { sessionId: string; terminalId: string }): Promise; + releaseTerminal(opts: { sessionId: string; terminalId: string }): Promise; + sessionUpdate(opts: { sessionId: string; update: unknown }): Promise; + requestPermission( + options: unknown[], + sessionId: string, + toolCallUpdate: unknown, + ): Promise<{ outcome: unknown }>; +} + +/** + * KAOS process adapter for ACP terminal execution. + * Corresponds to Python ACPProcess. + */ +export class ACPProcess { + private _client: ACPClient; + private _sessionId: string; + private _terminalId: string; + private _pollInterval: number; + private _returncode: number | null = null; + private _lastOutput = ""; + private _truncationNoted = false; + private _exitPromise: Promise; + private _resolveExit!: (code: number) => void; + private _stdoutChunks: string[] = []; + private _pollAbort = new AbortController(); + + readonly pid = -1; + + constructor( + client: ACPClient, + sessionId: string, + terminalId: string, + pollInterval = _DEFAULT_POLL_INTERVAL, + ) { + this._client = client; + this._sessionId = sessionId; + this._terminalId = terminalId; + this._pollInterval = pollInterval; + + this._exitPromise = new Promise((resolve) => { + this._resolveExit = resolve; + }); + + // Start polling in background + this._pollOutput().catch(() => {}); + } + + get returncode(): number | null { + return this._returncode; + } + + get stdout(): string { + return this._stdoutChunks.join(""); + } + + async wait(): Promise { + return this._exitPromise; + } + + async kill(): Promise { + await this._client.killTerminal({ + sessionId: this._sessionId, + terminalId: this._terminalId, + }); + } + + private _feedOutput(outputResponse: TerminalOutputResponse): void { + const output = outputResponse.output; + const reset = + outputResponse.truncated || + (this._lastOutput !== "" && !output.startsWith(this._lastOutput)); + + if (reset && this._lastOutput && !this._truncationNoted) { + this._stdoutChunks.push(_TRUNCATION_NOTICE); + this._truncationNoted = true; + } + + const delta = reset ? output : output.slice(this._lastOutput.length); + if (delta) { + this._stdoutChunks.push(delta); + } + this._lastOutput = output; + } + + private static _normalizeExitCode(exitCode: number | null): number { + return exitCode === null ? 1 : exitCode; + } + + private async _pollOutput(): Promise { + let exitCode: number | null = null; + + // Start exit waiter + const exitPromise = this._client + .waitForTerminalExit({ + sessionId: this._sessionId, + terminalId: this._terminalId, + }) + .catch(() => null); + + try { + while (!this._pollAbort.signal.aborted) { + // Check if exit already resolved + const raceResult = await Promise.race([ + exitPromise.then((r) => ({ type: "exit" as const, result: r })), + new Promise<{ type: "timeout" }>((resolve) => + setTimeout(() => resolve({ type: "timeout" }), this._pollInterval * 1000), + ), + ]); + + if (raceResult.type === "exit") { + const exitResponse = raceResult.result; + exitCode = exitResponse?.exit_code ?? null; + break; + } + + // Poll output + const outputResponse = await this._client.terminalOutput({ + sessionId: this._sessionId, + terminalId: this._terminalId, + }); + this._feedOutput(outputResponse); + + if (outputResponse.exit_status) { + exitCode = outputResponse.exit_status.exit_code; + // Try to get exit response too + try { + const exitResponse = await Promise.race([ + exitPromise, + new Promise((resolve) => setTimeout(() => resolve(null), 1000)), + ]); + if (exitResponse?.exit_code != null) { + exitCode = exitResponse.exit_code; + } + } catch { + // ignore + } + break; + } + } + + // Final output poll + try { + const finalOutput = await this._client.terminalOutput({ + sessionId: this._sessionId, + terminalId: this._terminalId, + }); + this._feedOutput(finalOutput); + } catch { + // ignore + } + } catch (exc) { + const errorNote = `[acp terminal error] ${exc}\n`; + this._stdoutChunks.push(errorNote); + if (exitCode === null) { + exitCode = 1; + } + } finally { + this._returncode = ACPProcess._normalizeExitCode(exitCode); + this._resolveExit(this._returncode); + + try { + await this._client.releaseTerminal({ + sessionId: this._sessionId, + terminalId: this._terminalId, + }); + } catch { + // ignore + } + } + } +} + +/** + * KAOS backend that routes supported operations through ACP. + * Corresponds to Python ACPKaos. + */ +export class ACPKaos { + readonly name = "acp"; + + private _client: ACPClient; + private _sessionId: string; + private _supportsRead: boolean; + private _supportsWrite: boolean; + private _supportsTerminal: boolean; + private _outputByteLimit: number | null; + private _pollInterval: number; + + constructor( + client: ACPClient, + sessionId: string, + clientCapabilities: ClientCapabilities | null, + opts?: { + outputByteLimit?: number | null; + pollInterval?: number; + }, + ) { + this._client = client; + this._sessionId = sessionId; + + const fs = clientCapabilities?.fs; + this._supportsRead = !!(fs?.read_text_file); + this._supportsWrite = !!(fs?.write_text_file); + this._supportsTerminal = !!(clientCapabilities?.terminal); + this._outputByteLimit = opts?.outputByteLimit ?? _DEFAULT_TERMINAL_OUTPUT_LIMIT; + this._pollInterval = opts?.pollInterval ?? _DEFAULT_POLL_INTERVAL; + } + + get supportsTerminal(): boolean { + return this._supportsTerminal; + } + + /** + * Read text from a file, routing through ACP if supported. + */ + async readText(filePath: string): Promise { + const absPath = path.resolve(filePath); + if (!this._supportsRead) { + return await fs.readFile(absPath, "utf-8"); + } + const response = await this._client.readTextFile({ + path: absPath, + sessionId: this._sessionId, + }); + return response.content; + } + + /** + * Write text to a file, routing through ACP if supported. + */ + async writeText(filePath: string, data: string, mode: "w" | "a" = "w"): Promise { + const absPath = path.resolve(filePath); + + if (mode === "a") { + if (this._supportsRead && this._supportsWrite) { + const existing = await this.readText(absPath); + await this._client.writeTextFile({ + path: absPath, + content: existing + data, + sessionId: this._sessionId, + }); + return data.length; + } + await fs.appendFile(absPath, data, "utf-8"); + return data.length; + } + + if (!this._supportsWrite) { + await fs.writeFile(absPath, data, "utf-8"); + return data.length; + } + + await this._client.writeTextFile({ + path: absPath, + content: data, + sessionId: this._sessionId, + }); + return data.length; + } +} diff --git a/src/kimi_cli_ts/acp/mcp.ts b/src/kimi_cli_ts/acp/mcp.ts new file mode 100644 index 000000000..c72481219 --- /dev/null +++ b/src/kimi_cli_ts/acp/mcp.ts @@ -0,0 +1,70 @@ +/** + * ACP MCP config conversion — corresponds to Python acp/mcp.py + * Converts ACP MCP server definitions to internal MCP config format. + */ + +import type { MCPServer } from "./types.ts"; +import { MCPConfigError } from "../exception.ts"; + +/** + * MCP config format used internally (matches fastmcp MCPConfig structure). + */ +export interface MCPConfigEntry { + url?: string; + transport?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; +} + +export interface MCPConfig { + mcpServers: Record; +} + +/** + * Convert ACP MCP server definitions to internal MCPConfig format. + * Corresponds to Python acp_mcp_servers_to_mcp_config(). + */ +export function acpMcpServersToMcpConfig(mcpServers: MCPServer[]): MCPConfig { + if (!mcpServers.length) { + return { mcpServers: {} }; + } + + try { + const servers: Record = {}; + for (const server of mcpServers) { + servers[server.name] = convertAcpMcpServer(server); + } + return { mcpServers: servers }; + } catch (err) { + throw new MCPConfigError(`Invalid MCP config from ACP client: ${err}`); + } +} + +/** + * Convert a single ACP MCP server to a dictionary representation. + */ +function convertAcpMcpServer(server: MCPServer): MCPConfigEntry { + switch (server.type) { + case "http": + return { + url: server.url, + transport: "http", + headers: Object.fromEntries((server.headers ?? []).map((h) => [h.name, h.value])), + }; + case "sse": + return { + url: server.url, + transport: "sse", + headers: Object.fromEntries((server.headers ?? []).map((h) => [h.name, h.value])), + }; + case "stdio": + return { + command: server.command, + args: server.args ?? [], + env: Object.fromEntries((server.env ?? []).map((e) => [e.name, e.value])), + transport: "stdio", + }; + } +} diff --git a/src/kimi_cli_ts/acp/server.ts b/src/kimi_cli_ts/acp/server.ts new file mode 100644 index 000000000..d41f66101 --- /dev/null +++ b/src/kimi_cli_ts/acp/server.ts @@ -0,0 +1,512 @@ +/** + * ACP server — corresponds to Python acp/server.py + * Manages ACP sessions, model switching, authentication, and prompt routing. + */ + +import type { ACPClient } from "./kaos.ts"; +import { ACPKaos } from "./kaos.ts"; +import { acpMcpServersToMcpConfig } from "./mcp.ts"; +import { ACPSession } from "./session.ts"; +import { replaceTools } from "./tools.ts"; +import type { + ACPContentBlock, + MCPServer, + ClientCapabilities, + AgentCapabilities, + AuthMethod, + Implementation, + InitializeResponse, + NewSessionResponse, + ResumeSessionResponse, + ListSessionsResponse, + SessionInfo, + SessionMode, + SessionModeState, + SessionModelState, + ModelInfo, + PromptResponse, + AuthenticateResponse, + AvailableCommand, + AvailableCommandsUpdate, +} from "./types.ts"; +import { ACPRequestError } from "./types.ts"; +import { negotiateVersion } from "./version.ts"; +import type { ACPVersionSpec } from "./version.ts"; +import { KimiCLI } from "../app.ts"; +import { KIMI_CODE_OAUTH_KEY, loadTokens } from "../auth/oauth.ts"; +import { loadConfig, saveConfig } from "../config.ts"; +import type { Config, LLMModel } from "../config.ts"; +import { NAME, VERSION } from "../constant.ts"; +import { createLLM, deriveModelCapabilities } from "../llm.ts"; +import type { LLMProviderConfig, LLMModelConfig } from "../llm.ts"; +import { Session } from "../session.ts"; +import { KimiToolset } from "../soul/toolset.ts"; +import { logger } from "../utils/logging.ts"; + +// ── _ModelIDConv ───────────────────────────────────────── + +class _ModelIDConv { + readonly modelKey: string; + readonly thinking: boolean; + + constructor(modelKey: string, thinking: boolean) { + this.modelKey = modelKey; + this.thinking = thinking; + } + + static fromAcpModelId(modelId: string): _ModelIDConv { + if (modelId.endsWith(",thinking")) { + return new _ModelIDConv(modelId.slice(0, -",thinking".length), true); + } + return new _ModelIDConv(modelId, false); + } + + toAcpModelId(): string { + if (this.thinking) { + return `${this.modelKey},thinking`; + } + return this.modelKey; + } + + equals(other: _ModelIDConv): boolean { + return this.modelKey === other.modelKey && this.thinking === other.thinking; + } +} + +// ── _expandLlmModels ───────────────────────────────────── + +function _expandLlmModels(models: Record): ModelInfo[] { + const expanded: ModelInfo[] = []; + for (const [modelKey, model] of Object.entries(models)) { + const modelConfig: LLMModelConfig = { + model: model.model, + provider: model.provider, + maxContextSize: model.max_context_size, + capabilities: model.capabilities, + }; + const capabilities = deriveModelCapabilities(modelConfig); + + if (model.model.includes("thinking") || model.model.includes("reason")) { + // always-thinking models + expanded.push({ + model_id: new _ModelIDConv(modelKey, true).toAcpModelId(), + name: model.model, + }); + } else { + expanded.push({ + model_id: modelKey, + name: model.model, + }); + if (capabilities.has("thinking")) { + expanded.push({ + model_id: new _ModelIDConv(modelKey, true).toAcpModelId(), + name: `${model.model} (thinking)`, + }); + } + } + } + return expanded; +} + +// ── ACPServer ──────────────────────────────────────────── + +export class ACPServer { + clientCapabilities: ClientCapabilities | null = null; + conn: ACPClient | null = null; + sessions = new Map(); + negotiatedVersion: ACPVersionSpec | null = null; + private _authMethods: AuthMethod[] = []; + + onConnect(conn: ACPClient): void { + logger.info("ACP client connected"); + this.conn = conn; + } + + async initialize(opts: { + protocolVersion: number; + clientCapabilities?: ClientCapabilities | null; + clientInfo?: Implementation | null; + }): Promise { + this.negotiatedVersion = negotiateVersion(opts.protocolVersion); + logger.info( + `ACP server initialized with client protocol version: ${opts.protocolVersion}, ` + + `negotiated version: ${JSON.stringify(this.negotiatedVersion)}, ` + + `client capabilities: ${JSON.stringify(opts.clientCapabilities)}, ` + + `client info: ${JSON.stringify(opts.clientInfo)}`, + ); + this.clientCapabilities = opts.clientCapabilities ?? null; + + // get command and args of current process for terminal-auth + const command = process.argv[1] ?? process.argv[0] ?? "kimi"; + const terminalArgs = ["login"]; + + // Build and cache auth methods for reuse in AUTH_REQUIRED errors + this._authMethods = [ + { + id: "login", + name: "Login with Kimi account", + description: + "Run `kimi login` command in the terminal, " + + "then follow the instructions to finish login.", + field_meta: { + "terminal-auth": { + command, + args: terminalArgs, + label: "Kimi Code Login", + env: {}, + type: "terminal", + }, + }, + }, + ]; + + return { + protocol_version: this.negotiatedVersion.protocolVersion, + agent_capabilities: { + load_session: true, + prompt_capabilities: { + embedded_context: true, + image: true, + audio: false, + }, + mcp_capabilities: { http: true, sse: false }, + session_capabilities: { + list: {}, + resume: {}, + }, + }, + auth_methods: this._authMethods, + agent_info: { name: NAME, version: VERSION }, + }; + } + + private static _checkTokenUsable(): string | null { + const ref = { storage: "file" as const, key: KIMI_CODE_OAUTH_KEY }; + const token = loadTokens(ref); + + if (token === null || !(token as any)?.access_token) { + return "no valid token found"; + } + const t = token as any; + if (t.expires_at && t.expires_at < Date.now() / 1000 && !t.refresh_token) { + return "token expired and no refresh token available"; + } + return null; + } + + private _checkAuth(): void { + const reason = ACPServer._checkTokenUsable(); + if (reason) { + const authMethodsData: Record[] = []; + for (const m of this._authMethods) { + if (m.field_meta && "terminal-auth" in m.field_meta) { + const terminalAuth = m.field_meta["terminal-auth"] as Record; + authMethodsData.push({ + id: m.id, + name: m.name, + description: m.description, + type: terminalAuth.type ?? "terminal", + args: terminalAuth.args ?? [], + env: terminalAuth.env ?? {}, + }); + } + } + logger.warn(`Authentication required, ${reason}`); + throw ACPRequestError.authRequired({ authMethods: authMethodsData }); + } + } + + async newSession(opts: { + cwd: string; + mcpServers?: MCPServer[] | null; + }): Promise { + logger.info(`Creating new session for working directory: ${opts.cwd}`); + if (!this.conn) throw new Error("ACP client not connected"); + if (!this.clientCapabilities) throw new Error("ACP connection not initialized"); + + this._checkAuth(); + + const session = await Session.create(opts.cwd); + const mcpConfig = acpMcpServersToMcpConfig(opts.mcpServers ?? []); + + const cli = await KimiCLI.create({ + workDir: opts.cwd, + sessionId: session.id, + }); + + const config = cli.soul.runtime.config; + const acpKaos = new ACPKaos(this.conn, session.id, this.clientCapabilities); + const acpSession = new ACPSession(session.id, cli, this.conn, acpKaos); + const modelIdConv = new _ModelIDConv(config.default_model, config.default_thinking); + this.sessions.set(session.id, [acpSession, modelIdConv]); + + if (cli.agent.toolset instanceof KimiToolset) { + replaceTools( + this.clientCapabilities, + this.conn, + session.id, + cli.agent.toolset, + cli.soul.runtime, + ); + } + + // Send available commands in background + const commands: AvailableCommand[] = cli.agent.slashCommands + .list() + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + })); + + // Fire and forget + this.conn + .sessionUpdate({ + sessionId: session.id, + update: { + session_update: "available_commands_update", + available_commands: commands, + } as AvailableCommandsUpdate, + }) + .catch((e) => logger.warn(`Failed to send available commands: ${e}`)); + + return { + session_id: session.id, + modes: _defaultModeState(), + models: { + available_models: _expandLlmModels(config.models), + current_model_id: modelIdConv.toAcpModelId(), + }, + }; + } + + private async _setupSession(opts: { + cwd: string; + sessionId: string; + mcpServers?: MCPServer[] | null; + }): Promise<[ACPSession, _ModelIDConv]> { + if (!this.conn) throw new Error("ACP client not connected"); + if (!this.clientCapabilities) throw new Error("ACP connection not initialized"); + + const session = await Session.find(opts.cwd, opts.sessionId); + if (!session) { + logger.error(`Session not found: ${opts.sessionId} for working directory: ${opts.cwd}`); + throw ACPRequestError.invalidParams({ session_id: "Session not found" }); + } + + const mcpConfig = acpMcpServersToMcpConfig(opts.mcpServers ?? []); + + const cli = await KimiCLI.create({ + workDir: opts.cwd, + sessionId: opts.sessionId, + resumed: true, + }); + + const config = cli.soul.runtime.config; + const acpKaos = new ACPKaos(this.conn, session.id, this.clientCapabilities); + const acpSession = new ACPSession(session.id, cli, this.conn, acpKaos); + const modelIdConv = new _ModelIDConv(config.default_model, config.default_thinking); + this.sessions.set(session.id, [acpSession, modelIdConv]); + + if (cli.agent.toolset instanceof KimiToolset) { + replaceTools( + this.clientCapabilities, + this.conn, + session.id, + cli.agent.toolset, + cli.soul.runtime, + ); + } + + return [acpSession, modelIdConv]; + } + + async loadSession(opts: { + cwd: string; + sessionId: string; + mcpServers?: MCPServer[] | null; + }): Promise { + logger.info(`Loading session: ${opts.sessionId} for working directory: ${opts.cwd}`); + + if (this.sessions.has(opts.sessionId)) { + logger.warn(`Session already loaded: ${opts.sessionId}`); + return; + } + + this._checkAuth(); + await this._setupSession(opts); + } + + async resumeSession(opts: { + cwd: string; + sessionId: string; + mcpServers?: MCPServer[] | null; + }): Promise { + logger.info(`Resuming session: ${opts.sessionId} for working directory: ${opts.cwd}`); + + if (!this.sessions.has(opts.sessionId)) { + await this._setupSession(opts); + } + + const [acpSession, modelIdConv] = this.sessions.get(opts.sessionId)!; + const config = acpSession.cli.soul.runtime.config; + return { + modes: _defaultModeState(), + models: { + available_models: _expandLlmModels(config.models), + current_model_id: modelIdConv.toAcpModelId(), + }, + }; + } + + async listSessions(opts: { + cursor?: string | null; + cwd?: string | null; + }): Promise { + logger.info(`Listing sessions for working directory: ${opts.cwd}`); + if (!opts.cwd) { + return { sessions: [], next_cursor: null }; + } + const sessions = await Session.list(opts.cwd); + return { + sessions: sessions.map((s) => ({ + cwd: opts.cwd!, + session_id: s.id, + title: s.title, + updated_at: new Date(s.updatedAt * 1000).toISOString(), + })), + next_cursor: null, + }; + } + + async setSessionMode(opts: { modeId: string; sessionId: string }): Promise { + if (opts.modeId !== "default") { + throw new Error("Only default mode is supported"); + } + } + + async setSessionModel(opts: { modelId: string; sessionId: string }): Promise { + logger.info(`Setting session model to ${opts.modelId} for session: ${opts.sessionId}`); + + const entry = this.sessions.get(opts.sessionId); + if (!entry) { + logger.error(`Session not found: ${opts.sessionId}`); + throw ACPRequestError.invalidParams({ session_id: "Session not found" }); + } + + const [acpSession, currentModelIdConv] = entry; + const cli = acpSession.cli; + const modelIdConv = _ModelIDConv.fromAcpModelId(opts.modelId); + if (modelIdConv.equals(currentModelIdConv)) { + return; + } + + const config = cli.soul.runtime.config; + const newModel = config.models[modelIdConv.modelKey]; + if (!newModel) { + logger.error(`Model not found: ${modelIdConv.modelKey}`); + throw ACPRequestError.invalidParams({ model_id: "Model not found" }); + } + const newProvider = config.providers[newModel.provider]; + if (!newProvider) { + logger.error( + `Provider not found: ${newModel.provider} for model: ${modelIdConv.modelKey}`, + ); + throw ACPRequestError.invalidParams({ model_id: "Model's provider not found" }); + } + + const providerConfig: LLMProviderConfig = { + type: (newProvider as any).type ?? "openai", + baseUrl: (newProvider as any).base_url ?? "", + apiKey: (newProvider as any).api_key ?? "", + customHeaders: (newProvider as any).custom_headers, + oauth: (newProvider as any).oauth?.key ?? null, + }; + + const modelConfig: LLMModelConfig = { + model: newModel.model, + provider: newModel.provider, + maxContextSize: newModel.max_context_size, + capabilities: newModel.capabilities, + }; + + const newLlm = createLLM(providerConfig, modelConfig, { + thinking: modelIdConv.thinking, + sessionId: acpSession.id, + }); + cli.soul.runtime.llm = newLlm; + + config.default_model = modelIdConv.modelKey; + config.default_thinking = modelIdConv.thinking; + + // Persist the model change + try { + const { config: configForSave } = await loadConfig(); + configForSave.default_model = modelIdConv.modelKey; + configForSave.default_thinking = modelIdConv.thinking; + await saveConfig(configForSave); + } catch (e) { + logger.warn(`Failed to persist model change: ${e}`); + } + + // Update cached model ID conv + this.sessions.set(opts.sessionId, [acpSession, modelIdConv]); + } + + async authenticate(opts: { methodId: string }): Promise { + if (opts.methodId === "login") { + const reason = ACPServer._checkTokenUsable(); + if (reason === null) { + logger.info(`Authentication successful for method: ${opts.methodId}`); + return {}; + } + logger.warn(`Authentication not complete for method: ${opts.methodId} (${reason})`); + throw ACPRequestError.authRequired({ + message: "Please complete login in terminal first", + authMethods: this._authMethods, + }); + } + + logger.error(`Unknown auth method: ${opts.methodId}`); + throw ACPRequestError.invalidParams({ method_id: "Unknown auth method" }); + } + + async prompt(opts: { + prompt: ACPContentBlock[]; + sessionId: string; + }): Promise { + logger.info(`Received prompt request for session: ${opts.sessionId}`); + const entry = this.sessions.get(opts.sessionId); + if (!entry) { + logger.error(`Session not found: ${opts.sessionId}`); + throw ACPRequestError.invalidParams({ session_id: "Session not found" }); + } + const [acpSession] = entry; + return await acpSession.prompt(opts.prompt); + } + + async cancel(opts: { sessionId: string }): Promise { + logger.info(`Received cancel request for session: ${opts.sessionId}`); + const entry = this.sessions.get(opts.sessionId); + if (!entry) { + logger.error(`Session not found: ${opts.sessionId}`); + throw ACPRequestError.invalidParams({ session_id: "Session not found" }); + } + const [acpSession] = entry; + await acpSession.cancel(); + } +} + +// ── Helpers ────────────────────────────────────────────── + +function _defaultModeState(): SessionModeState { + return { + available_modes: [ + { + id: "default", + name: "Default", + description: "The default mode.", + }, + ], + current_mode_id: "default", + }; +} diff --git a/src/kimi_cli_ts/acp/session.ts b/src/kimi_cli_ts/acp/session.ts new file mode 100644 index 000000000..b74877209 --- /dev/null +++ b/src/kimi_cli_ts/acp/session.ts @@ -0,0 +1,552 @@ +/** + * ACP session — corresponds to Python acp/session.py + * Manages ACP session state, prompt execution, and wire message routing. + */ + +import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; + +import { acpBlocksToContentParts, displayBlockToAcpContent, toolResultToAcpContent } from "./convert.ts"; +import type { ACPClient } from "./kaos.ts"; +import type { ACPKaos } from "./kaos.ts"; +import type { + ACPContentBlock, + AgentMessageChunk, + AgentThoughtChunk, + ToolCallStart, + ToolCallProgress, + ContentToolCallContent, + FileEditToolCallContent, + TerminalToolCallContent, + AgentPlanUpdate, + PlanEntry, + PermissionOption, + ToolCallUpdate, + PromptResponse, + TextContentBlock, +} from "./types.ts"; +import { ACPRequestError } from "./types.ts"; +import type { KimiCLI } from "../app.ts"; +import { LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, runSoul } from "../soul/index.ts"; +import type { Wire } from "../wire/wire_core.ts"; +import { QueueShutDown } from "../utils/queue.ts"; +import { extractKeyArgument } from "../tools/types.ts"; +import { getCurrentToolCallOrNull } from "../soul/toolset.ts"; +import { logger } from "../utils/logging.ts"; +import type { + ToolResult, + Notification, + TodoDisplayBlock, + TodoDisplayItem, + DisplayBlock, + DiffDisplayBlock, +} from "../wire/types.ts"; +import type { ContentPart } from "../types.ts"; + +// ── Context variables ───────────────────────────────────── + +const _currentTurnId = new AsyncLocalStorage(); +const _terminalToolCallIds = new AsyncLocalStorage | null>(); + +export function getCurrentAcpToolCallIdOrNull(): string | null { + const turnId = _currentTurnId.getStore() ?? null; + if (turnId === null) return null; + const toolCall = getCurrentToolCallOrNull(); + if (toolCall === null) return null; + return `${turnId}/${toolCall.id}`; +} + +export function registerTerminalToolCallId(toolCallId: string): void { + const calls = _terminalToolCallIds.getStore() ?? null; + if (calls !== null) { + calls.add(toolCallId); + } +} + +export function shouldHideTerminalOutput(toolCallId: string): boolean { + const calls = _terminalToolCallIds.getStore() ?? null; + return calls !== null && calls.has(toolCallId); +} + +// ── _ToolCallState ──────────────────────────────────────── + +class _ToolCallState { + readonly toolCall: { id: string; function: { name: string; arguments: string | null } }; + args: string; + + constructor(toolCall: { id: string; function: { name: string; arguments: string | null } }) { + this.toolCall = toolCall; + this.args = toolCall.function.arguments || ""; + } + + get acpToolCallId(): string { + const turnId = _currentTurnId.getStore() ?? null; + if (turnId === null) throw new Error("Turn ID not set"); + return `${turnId}/${this.toolCall.id}`; + } + + appendArgsPart(argsPart: string): void { + this.args += argsPart; + } + + getTitle(): string { + const toolName = this.toolCall.function.name; + const subtitle = extractKeyArgument(this.args, toolName); + if (subtitle) { + return `${toolName}: ${subtitle}`; + } + return toolName; + } +} + +// ── _TurnState ──────────────────────────────────────────── + +class _TurnState { + readonly id: string; + readonly toolCalls = new Map(); + lastToolCall: _ToolCallState | null = null; + private _cancelController = new AbortController(); + + constructor() { + this.id = randomUUID(); + } + + get cancelSignal(): AbortSignal { + return this._cancelController.signal; + } + + cancel(): void { + this._cancelController.abort(); + } +} + +// ── ACPSession ──────────────────────────────────────────── + +export class ACPSession { + private _id: string; + private _cli: KimiCLI; + private _conn: ACPClient; + private _kaos: ACPKaos | null; + private _turnState: _TurnState | null = null; + + constructor( + id: string, + cli: KimiCLI, + acpConn: ACPClient, + kaos: ACPKaos | null = null, + ) { + this._id = id; + this._cli = cli; + this._conn = acpConn; + this._kaos = kaos; + } + + get id(): string { + return this._id; + } + + get cli(): KimiCLI { + return this._cli; + } + + private _isOAuthSession(): boolean { + try { + const llm = this._cli.soul.runtime?.llm; + return llm !== null && llm !== undefined && (llm as any).oauth != null; + } catch { + return false; + } + } + + async prompt(prompt: ACPContentBlock[]): Promise { + const userInput = acpBlocksToContentParts(prompt); + this._turnState = new _TurnState(); + + return await _currentTurnId.run(this._turnState.id, async () => { + return await _terminalToolCallIds.run(new Set(), async () => { + try { + const cancelController = new AbortController(); + const turnState = this._turnState!; + + // Connect cancel + const onCancel = () => cancelController.abort(); + turnState.cancelSignal.addEventListener("abort", onCancel, { once: true }); + + await runSoul( + this._cli.soul, + userInput as string | ContentPart[], + async (wire: Wire) => { + // Process wire messages from the soul's actual Wire + const uiSide = wire.uiSide(true); // merged messages + while (true) { + let msg: any; + try { + msg = await uiSide.receive(); + } catch (e) { + if (e instanceof QueueShutDown) break; + throw e; + } + await this._handleWireMessage(msg); + } + }, + cancelController, + ); + + return { stop_reason: "end_turn" as const }; + } catch (e) { + if (e instanceof LLMNotSet) { + logger.error(`LLM not set: ${e}`); + throw ACPRequestError.authRequired(); + } + if (e instanceof LLMNotSupported) { + logger.error(`LLM not supported: ${e}`); + throw ACPRequestError.internalError({ error: String(e) }); + } + if (e instanceof MaxStepsReached) { + logger.warn(`Max steps reached: ${(e as MaxStepsReached).nSteps}`); + return { stop_reason: "max_turn_requests" as const }; + } + if (e instanceof RunCancelled) { + logger.info("Prompt cancelled by user"); + return { stop_reason: "cancelled" as const }; + } + // Check for API status errors (401 for OAuth sessions) + if ((e as any)?.status === 401 && this._isOAuthSession()) { + logger.warn("Authentication failed (401), prompting re-login"); + throw ACPRequestError.authRequired(); + } + logger.error(`Unexpected error during prompt: ${e}`); + throw ACPRequestError.internalError({ error: String(e) }); + } finally { + this._turnState = null; + } + }); + }); + } + + async cancel(): Promise { + if (this._turnState === null) { + logger.warn("Cancel requested but no prompt is running"); + return; + } + this._turnState.cancel(); + } + + // ── Wire message handler ────────────────────────────── + + private async _handleWireMessage(msg: any): Promise { + // Determine message type from __wireType tag or by shape + const wireType = msg.__wireType as string | undefined; + + switch (wireType) { + case "ThinkPart": + await this._sendThinking(msg.text); + break; + case "TextPart": + await this._sendText(msg.text); + break; + case "ToolCall": + await this._sendToolCall(msg); + break; + case "ToolCallPart": + await this._sendToolCallPart(msg); + break; + case "ToolResult": + await this._sendToolResult(msg); + break; + case "Notification": + await this._sendNotification(msg); + break; + case "ApprovalRequest": + await this._handleApprovalRequest(msg); + break; + case "StepInterrupted": + // Stop processing + break; + case "QuestionRequest": + logger.warn("QuestionRequest is unsupported in ACP session; resolving empty answer."); + if (msg.resolve) msg.resolve({}); + break; + case "TurnBegin": + case "TurnEnd": + case "SteerInput": + case "StepBegin": + case "CompactionBegin": + case "CompactionEnd": + case "MCPLoadingBegin": + case "MCPLoadingEnd": + case "StatusUpdate": + case "ApprovalResponse": + case "SubagentEvent": + case "PlanDisplay": + case "ToolCallRequest": + // Ignored in ACP session + break; + default: + // Try to detect message type by shape + if (msg.text !== undefined && msg.type === "think") { + await this._sendThinking(msg.text); + } else if (msg.text !== undefined && msg.type === "text") { + await this._sendText(msg.text); + } + break; + } + } + + // ── Send methods ────────────────────────────────────── + + private async _sendThinking(think: string): Promise { + if (!this._id || !this._conn) return; + + const update: AgentThoughtChunk = { + session_update: "agent_thought_chunk", + content: { type: "text", text: think }, + }; + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + } + + private async _sendText(text: string): Promise { + if (!this._id || !this._conn) return; + + const update: AgentMessageChunk = { + session_update: "agent_message_chunk", + content: { type: "text", text }, + }; + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + } + + private async _sendNotification(notification: { title: string; body: string }): Promise { + const body = notification.body.trim(); + let text = `[Notification] ${notification.title}`; + if (body) { + text = `${text}\n${body}`; + } + await this._sendText(text); + } + + private async _sendToolCall(toolCall: { + id: string; + function: { name: string; arguments: string | null }; + }): Promise { + if (!this._turnState || !this._id || !this._conn) return; + + const state = new _ToolCallState(toolCall); + this._turnState.toolCalls.set(toolCall.id, state); + this._turnState.lastToolCall = state; + + const update: ToolCallStart = { + session_update: "tool_call", + tool_call_id: state.acpToolCallId, + title: state.getTitle(), + status: "in_progress", + content: [ + { + type: "content", + content: { type: "text", text: state.args }, + } as ContentToolCallContent, + ], + }; + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + logger.debug(`Sent tool call: ${toolCall.function.name}`); + } + + private async _sendToolCallPart(part: { + arguments_part?: string; + }): Promise { + if ( + !this._turnState || + !this._id || + !this._conn || + !part.arguments_part || + !this._turnState.lastToolCall + ) { + return; + } + + this._turnState.lastToolCall.appendArgsPart(part.arguments_part); + + const update: ToolCallProgress = { + session_update: "tool_call_update", + tool_call_id: this._turnState.lastToolCall.acpToolCallId, + title: this._turnState.lastToolCall.getTitle(), + status: "in_progress", + content: [ + { + type: "content", + content: { type: "text", text: this._turnState.lastToolCall.args }, + } as ContentToolCallContent, + ], + }; + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + logger.debug(`Sent tool call update: ${part.arguments_part?.slice(0, 50)}`); + } + + private async _sendToolResult(result: { + tool_call_id: string; + return_value: { is_error?: boolean; output?: string; message?: string; display?: unknown[] }; + }): Promise { + if (!this._turnState || !this._id || !this._conn) return; + + const toolRet = result.return_value; + const state = this._turnState.toolCalls.get(result.tool_call_id); + if (!state) { + logger.warn(`Tool call not found: ${result.tool_call_id}`); + return; + } + this._turnState.toolCalls.delete(result.tool_call_id); + + const hide = shouldHideTerminalOutput(state.acpToolCallId); + const contents = toolResultToAcpContent(toolRet as any, hide); + + const update: ToolCallProgress = { + session_update: "tool_call_update", + tool_call_id: state.acpToolCallId, + status: toolRet.is_error ? "failed" : "completed", + }; + if (contents.length > 0) { + update.content = contents; + } + + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + logger.debug(`Sent tool result: ${result.tool_call_id}`); + + // Send plan updates for todo display blocks + if (toolRet.display) { + for (const block of toolRet.display) { + if ((block as any)?.type === "todo") { + await this._sendPlanUpdate(block as TodoDisplayBlock); + } + } + } + } + + private async _handleApprovalRequest(request: any): Promise { + if (!this._turnState || !this._id || !this._conn) { + logger.warn("No session ID, auto-rejecting approval request"); + if (request.resolve) request.resolve("reject"); + return; + } + + const state = this._turnState.toolCalls.get(request.data?.tool_call_id ?? request.tool_call_id); + if (!state) { + logger.warn(`Tool call not found for approval: ${request.data?.tool_call_id ?? request.tool_call_id}`); + if (request.resolve) request.resolve("reject"); + return; + } + + try { + let content: (ContentToolCallContent | FileEditToolCallContent | TerminalToolCallContent)[] = []; + const display = request.data?.display ?? request.display ?? []; + if (display.length > 0) { + for (const block of display) { + const diffContent = displayBlockToAcpContent(block); + if (diffContent !== null) { + content.push(diffContent); + } + } + } + if (content.length === 0) { + const description = request.data?.description ?? request.description ?? "unknown action"; + content.push({ + type: "content", + content: { + type: "text", + text: `Requesting approval to perform: ${description}`, + }, + } as ContentToolCallContent); + } + + const action = request.data?.action ?? request.action ?? "unknown"; + logger.debug(`Requesting permission for action: ${action}`); + + const options: PermissionOption[] = [ + { option_id: "approve", name: "Approve once", kind: "allow_once" }, + { option_id: "approve_for_session", name: "Approve for this session", kind: "allow_always" }, + { option_id: "reject", name: "Reject", kind: "reject_once" }, + ]; + + const toolCallUpdate: ToolCallUpdate = { + tool_call_id: state.acpToolCallId, + title: state.getTitle(), + content, + }; + + const response = await this._conn.requestPermission( + options, + this._id, + toolCallUpdate, + ); + + const outcome = (response as any)?.outcome; + if (outcome?.type === "allowed") { + const optionId = outcome.option_id; + if (optionId === "approve") { + logger.debug(`Permission granted for: ${action}`); + request.resolve("approve"); + } else if (optionId === "approve_for_session") { + logger.debug(`Permission granted for session: ${action}`); + request.resolve("approve_for_session"); + } else { + logger.debug(`Permission denied for: ${action}`); + request.resolve("reject"); + } + } else { + logger.debug(`Permission request cancelled for: ${action}`); + request.resolve("reject"); + } + } catch (e) { + logger.error(`Error handling approval request: ${e}`); + request.resolve("reject"); + } + } + + private async _sendPlanUpdate(block: TodoDisplayBlock): Promise { + const statusMap: Record = { + pending: "pending", + "in progress": "in_progress", + in_progress: "in_progress", + done: "completed", + completed: "completed", + }; + + const entries: PlanEntry[] = []; + for (const todo of block.items) { + if (todo.title) { + entries.push({ + content: todo.title, + priority: "medium", + status: statusMap[todo.status.toLowerCase()] ?? "pending", + }); + } + } + + if (entries.length === 0) { + logger.warn("No valid todo items to send in plan update"); + return; + } + + const update: AgentPlanUpdate = { + session_update: "plan", + entries, + }; + await this._conn.sessionUpdate({ + sessionId: this._id, + update, + }); + } +} diff --git a/src/kimi_cli_ts/acp/tools.ts b/src/kimi_cli_ts/acp/tools.ts new file mode 100644 index 000000000..c68b9d8eb --- /dev/null +++ b/src/kimi_cli_ts/acp/tools.ts @@ -0,0 +1,241 @@ +/** + * ACP tool replacements — corresponds to Python acp/tools.py + * Replaces Shell tool with Terminal tool when ACP client supports terminal. + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../tools/base.ts"; +import { ToolResultBuilder } from "../tools/types.ts"; +import type { ToolContext, ToolResult } from "../tools/types.ts"; +import { ToolRejectedError } from "../tools/types.ts"; +import { Shell } from "../tools/shell/index.ts"; +import type { KimiToolset } from "../soul/toolset.ts"; +import type { Runtime } from "../soul/agent.ts"; +import type { Approval } from "../soul/approval.ts"; +import type { ACPClient } from "./kaos.ts"; +import type { ClientCapabilities, TerminalToolCallContent, ToolCallProgress } from "./types.ts"; +import { logger } from "../utils/logging.ts"; + +const _DEFAULT_OUTPUT_BYTE_LIMIT = 50_000; + +/** + * A special display block type that indicates output should be hidden in ACP clients. + * Corresponds to Python HideOutputDisplayBlock. + */ +export const HIDE_OUTPUT_DISPLAY_BLOCK = { type: "acp/hide_output" as const }; + +/** + * Replace tools in the toolset when running under ACP. + * Swaps Shell tool with Terminal when client supports terminal. + * Corresponds to Python replace_tools(). + */ +export function replaceTools( + clientCapabilities: ClientCapabilities, + acpClient: ACPClient, + acpSessionId: string, + toolset: KimiToolset, + runtime: Runtime, +): void { + if (clientCapabilities.terminal) { + const shellTool = toolset.find("Shell"); + if (shellTool instanceof Shell) { + const terminal = new Terminal(shellTool, acpClient, acpSessionId, runtime.approval); + toolset.add(terminal); + logger.debug("Replaced Shell tool with ACP Terminal tool"); + } + } +} + +// ── Terminal tool ────────────────────────────────────────── + +const TerminalParamsSchema = z.object({ + command: z.string().describe("The command to execute."), + timeout: z + .number() + .int() + .min(1) + .max(24 * 60 * 60) + .default(60) + .describe("The timeout in seconds for the command to execute."), + run_in_background: z + .boolean() + .default(false) + .describe("Whether to run the command as a background task."), + description: z + .string() + .default("") + .describe("A short description for the background task."), +}); + +type TerminalParams = z.infer; + +/** + * ACP Terminal tool — executes commands via ACP terminal protocol. + * Replaces the Shell tool when running under ACP with terminal support. + * Corresponds to Python Terminal class. + */ +export class Terminal extends CallableTool { + readonly name: string; + readonly description: string; + readonly schema = TerminalParamsSchema; + + private _acpClient: ACPClient; + private _acpSessionId: string; + private _approval: Approval; + + constructor( + shellTool: Shell, + acpClient: ACPClient, + acpSessionId: string, + approval: Approval, + ) { + super(); + this.name = shellTool.name; + this.description = shellTool.description; + this._acpClient = acpClient; + this._acpSessionId = acpSessionId; + this._approval = approval; + } + + async execute(params: TerminalParams, ctx: ToolContext): Promise { + const builder = new ToolResultBuilder(); + // Hide tool output because we use TerminalToolCallContent which already streams output + builder.display(HIDE_OUTPUT_DISPLAY_BLOCK); + + if (!params.command) { + return builder.error("Command cannot be empty."); + } + + const approvalResult = await this._approval.request( + this.name, + "run shell command", + `Run command \`${params.command}\``, + ); + if (!approvalResult.approved) { + return new ToolRejectedError({ + message: approvalResult.feedback + ? `The tool call is rejected by the user. User feedback: ${approvalResult.feedback}` + : undefined, + brief: approvalResult.feedback + ? `Rejected: ${approvalResult.feedback}` + : "Rejected by user", + hasFeedback: !!approvalResult.feedback, + }).toToolResult(); + } + + const timeoutSeconds = params.timeout; + const timeoutLabel = `${timeoutSeconds}s`; + let terminalId: string | null = null; + let exitStatus: { exit_code: number | null; signal?: string | null } | null = null; + let timedOut = false; + + try { + const resp = await this._acpClient.createTerminal({ + command: params.command, + sessionId: this._acpSessionId, + outputByteLimit: _DEFAULT_OUTPUT_BYTE_LIMIT, + }); + terminalId = resp.terminalId; + + // Send terminal tool call content to ACP client + const acpToolCallId = getCurrentAcpToolCallIdOrNull(); + if (acpToolCallId) { + const update: ToolCallProgress = { + session_update: "tool_call_update", + tool_call_id: acpToolCallId, + status: "in_progress", + content: [ + { + type: "terminal", + terminal_id: terminalId, + } as TerminalToolCallContent, + ], + }; + await this._acpClient.sessionUpdate({ + sessionId: this._acpSessionId, + update, + }); + } + + // Wait for terminal exit with timeout + try { + const exitPromise = this._acpClient.waitForTerminalExit({ + sessionId: this._acpSessionId, + terminalId, + }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), timeoutSeconds * 1000), + ); + exitStatus = await Promise.race([exitPromise, timeoutPromise]); + } catch (e) { + if (e instanceof Error && e.message === "timeout") { + timedOut = true; + await this._acpClient.killTerminal({ + sessionId: this._acpSessionId, + terminalId, + }); + } else { + throw e; + } + } + + // Get final output + const outputResponse = await this._acpClient.terminalOutput({ + sessionId: this._acpSessionId, + terminalId, + }); + builder.write(outputResponse.output); + if (outputResponse.exit_status) { + exitStatus = outputResponse.exit_status; + } + + const exitCode = exitStatus?.exit_code ?? null; + const exitSignal = (exitStatus as any)?.signal ?? null; + + const truncatedNote = outputResponse.truncated + ? " Output was truncated by the client output limit." + : ""; + + if (timedOut) { + return builder.error( + `Command killed by timeout (${timeoutLabel})${truncatedNote}`, + ); + } + if (exitSignal) { + return builder.error( + `Command terminated by signal: ${exitSignal}.${truncatedNote}`, + ); + } + if (exitCode !== null && exitCode !== 0) { + return builder.error( + `Command failed with exit code: ${exitCode}.${truncatedNote}`, + ); + } + return builder.ok(`Command executed successfully.${truncatedNote}`); + } finally { + if (terminalId !== null) { + try { + await this._acpClient.releaseTerminal({ + sessionId: this._acpSessionId, + terminalId, + }); + } catch { + // ignore + } + } + } + } +} + +// ── Context helpers ──────────────────────────────────────── + +/** Global variable to track current ACP tool call ID (set by ACPSession). */ +let _currentAcpToolCallId: string | null = null; + +export function setCurrentAcpToolCallId(id: string | null): void { + _currentAcpToolCallId = id; +} + +export function getCurrentAcpToolCallIdOrNull(): string | null { + return _currentAcpToolCallId; +} diff --git a/src/kimi_cli_ts/acp/types.ts b/src/kimi_cli_ts/acp/types.ts new file mode 100644 index 000000000..3f1c51624 --- /dev/null +++ b/src/kimi_cli_ts/acp/types.ts @@ -0,0 +1,366 @@ +/** + * ACP type aliases — corresponds to Python acp/types.py + * Type definitions for ACP content blocks and MCP server types. + */ + +// ── MCP Server types ──────────────────────────────────── + +export interface HttpMcpServer { + type: "http"; + name: string; + url: string; + headers?: Array<{ name: string; value: string }>; +} + +export interface SseMcpServer { + type: "sse"; + name: string; + url: string; + headers?: Array<{ name: string; value: string }>; +} + +export interface McpServerStdio { + type: "stdio"; + name: string; + command: string; + args?: string[]; + env?: Array<{ name: string; value: string }>; +} + +export type MCPServer = HttpMcpServer | SseMcpServer | McpServerStdio; + +// ── ACP Content Blocks ────────────────────────────────── + +export interface TextContentBlock { + type: "text"; + text: string; +} + +export interface ImageContentBlock { + type: "image"; + data: string; + mime_type: string; +} + +export interface AudioContentBlock { + type: "audio"; + data: string; + mime_type: string; +} + +export interface TextResourceContents { + type: "text"; + uri: string; + text: string; +} + +export interface BlobResourceContents { + type: "blob"; + uri: string; + blob: string; +} + +export type ResourceContents = TextResourceContents | BlobResourceContents; + +export interface ResourceContentBlock { + type: "resource"; + uri: string; + name: string; +} + +export interface EmbeddedResourceContentBlock { + type: "embedded_resource"; + resource: ResourceContents; +} + +export type ACPContentBlock = + | TextContentBlock + | ImageContentBlock + | AudioContentBlock + | ResourceContentBlock + | EmbeddedResourceContentBlock; + +// ── ACP Tool Call Content ─────────────────────────────── + +export interface ContentToolCallContent { + type: "content"; + content: TextContentBlock; +} + +export interface FileEditToolCallContent { + type: "diff"; + path: string; + old_text: string; + new_text: string; +} + +export interface TerminalToolCallContent { + type: "terminal"; + terminal_id: string; +} + +export type ToolCallContent = + | ContentToolCallContent + | FileEditToolCallContent + | TerminalToolCallContent; + +// ── ACP Session Updates ───────────────────────────────── + +export interface AgentMessageChunk { + session_update: "agent_message_chunk"; + content: TextContentBlock; +} + +export interface AgentThoughtChunk { + session_update: "agent_thought_chunk"; + content: TextContentBlock; +} + +export interface ToolCallStart { + session_update: "tool_call"; + tool_call_id: string; + title: string; + status: "in_progress" | "completed" | "failed"; + content?: ToolCallContent[]; +} + +export interface ToolCallProgress { + session_update: "tool_call_update"; + tool_call_id: string; + title?: string; + status: "in_progress" | "completed" | "failed"; + content?: ToolCallContent[]; +} + +export interface PlanEntry { + content: string; + priority: "low" | "medium" | "high"; + status: "pending" | "in_progress" | "completed"; +} + +export interface AgentPlanUpdate { + session_update: "plan"; + entries: PlanEntry[]; +} + +export type SessionUpdate = + | AgentMessageChunk + | AgentThoughtChunk + | ToolCallStart + | ToolCallProgress + | AgentPlanUpdate; + +// ── ACP Permission ────────────────────────────────────── + +export interface PermissionOption { + option_id: string; + name: string; + kind: "allow_once" | "allow_always" | "reject_once"; +} + +export interface ToolCallUpdate { + tool_call_id: string; + title: string; + content: ToolCallContent[]; +} + +export interface AllowedOutcome { + type: "allowed"; + option_id: string; +} + +export interface CancelledOutcome { + type: "cancelled"; +} + +export type PermissionOutcome = AllowedOutcome | CancelledOutcome; + +export interface PermissionResponse { + outcome: PermissionOutcome; +} + +// ── ACP Capabilities ──────────────────────────────────── + +export interface FsCapabilities { + read_text_file?: boolean; + write_text_file?: boolean; +} + +export interface TerminalCapabilities { + create?: boolean; +} + +export interface PromptCapabilities { + embedded_context?: boolean; + image?: boolean; + audio?: boolean; +} + +export interface McpCapabilities { + http?: boolean; + sse?: boolean; +} + +export interface SessionListCapabilities {} +export interface SessionResumeCapabilities {} + +export interface SessionCapabilities { + list?: SessionListCapabilities; + resume?: SessionResumeCapabilities; +} + +export interface ClientCapabilities { + fs?: FsCapabilities; + terminal?: TerminalCapabilities; +} + +export interface AgentCapabilities { + load_session?: boolean; + prompt_capabilities?: PromptCapabilities; + mcp_capabilities?: McpCapabilities; + session_capabilities?: SessionCapabilities; +} + +// ── ACP Auth ──────────────────────────────────────────── + +export interface AuthMethod { + id: string; + name: string; + description: string; + field_meta?: Record; +} + +// ── ACP Protocol Types ────────────────────────────────── + +export interface Implementation { + name: string; + version: string; +} + +export interface SessionMode { + id: string; + name: string; + description: string; +} + +export interface SessionModeState { + available_modes: SessionMode[]; + current_mode_id: string; +} + +export interface ModelInfo { + model_id: string; + name: string; +} + +export interface SessionModelState { + available_models: ModelInfo[]; + current_model_id: string; +} + +export interface SessionInfo { + cwd: string; + session_id: string; + title: string; + updated_at: string; +} + +// ── ACP Request / Response ────────────────────────────── + +export interface InitializeResponse { + protocol_version: number; + agent_capabilities: AgentCapabilities; + auth_methods: AuthMethod[]; + agent_info: Implementation; +} + +export interface NewSessionResponse { + session_id: string; + modes: SessionModeState; + models: SessionModelState; +} + +export interface ResumeSessionResponse { + modes: SessionModeState; + models: SessionModelState; +} + +export interface ForkSessionResponse { + session_id: string; + modes: SessionModeState; + models: SessionModelState; +} + +export interface ListSessionsResponse { + sessions: SessionInfo[]; + next_cursor: string | null; +} + +export interface PromptResponse { + stop_reason: "end_turn" | "max_turn_requests" | "cancelled"; +} + +export interface AuthenticateResponse {} + +export interface AvailableCommand { + name: string; + description: string; +} + +export interface AvailableCommandsUpdate { + session_update: "available_commands_update"; + available_commands: AvailableCommand[]; +} + +// ── ACP Terminal ──────────────────────────────────────── + +export interface CreateTerminalResponse { + terminal_id: string; +} + +export interface TerminalExitStatus { + exit_code: number | null; + signal: string | null; +} + +export interface TerminalOutputResponse { + output: string; + truncated: boolean; + exit_status?: TerminalExitStatus; +} + +export interface WaitForTerminalExitResponse { + exit_code: number | null; +} + +// ── ACP File Operations ───────────────────────────────── + +export interface ReadTextFileResponse { + content: string; +} + +// ── ACP Error ─────────────────────────────────────────── + +export class ACPRequestError extends Error { + readonly code: string; + readonly data?: Record; + + constructor(code: string, message: string, data?: Record) { + super(message); + this.name = "ACPRequestError"; + this.code = code; + this.data = data; + } + + static authRequired(data?: Record): ACPRequestError { + return new ACPRequestError("AUTH_REQUIRED", "Authentication required", data); + } + + static invalidParams(data?: Record): ACPRequestError { + return new ACPRequestError("INVALID_PARAMS", "Invalid parameters", data); + } + + static internalError(data?: Record): ACPRequestError { + return new ACPRequestError("INTERNAL_ERROR", "Internal error", data); + } +} diff --git a/src/kimi_cli_ts/acp/version.ts b/src/kimi_cli_ts/acp/version.ts new file mode 100644 index 000000000..d8964825a --- /dev/null +++ b/src/kimi_cli_ts/acp/version.ts @@ -0,0 +1,47 @@ +/** + * ACP version negotiation — corresponds to Python acp/version.py + */ + +export interface ACPVersionSpec { + /** Negotiation integer (currently 1). */ + protocolVersion: number; + /** ACP spec tag (e.g. "v0.10.8"). */ + specTag: string; + /** Corresponding SDK version (e.g. "0.8.0"). */ + sdkVersion: string; +} + +export const CURRENT_VERSION: ACPVersionSpec = { + protocolVersion: 1, + specTag: "v0.10.8", + sdkVersion: "0.8.0", +}; + +export const SUPPORTED_VERSIONS: Map = new Map([ + [1, CURRENT_VERSION], +]); + +export const MIN_PROTOCOL_VERSION = 1; + +/** + * Negotiate the protocol version with the client. + * + * Returns the highest server-supported version that does not exceed the + * client's requested version. If the client version is lower than + * MIN_PROTOCOL_VERSION the server still returns its own current version + * so the client can decide whether to disconnect. + */ +export function negotiateVersion(clientProtocolVersion: number): ACPVersionSpec { + if (clientProtocolVersion < MIN_PROTOCOL_VERSION) { + return CURRENT_VERSION; + } + + let best: ACPVersionSpec | null = null; + for (const [ver, spec] of SUPPORTED_VERSIONS) { + if (ver <= clientProtocolVersion && (best === null || ver > best.protocolVersion)) { + best = spec; + } + } + + return best ?? CURRENT_VERSION; +} diff --git a/src/kimi_cli_ts/approval_runtime/index.ts b/src/kimi_cli_ts/approval_runtime/index.ts index 971ce6eb1..d94238b10 100644 --- a/src/kimi_cli_ts/approval_runtime/index.ts +++ b/src/kimi_cli_ts/approval_runtime/index.ts @@ -1,325 +1,24 @@ /** - * Approval runtime — corresponds to Python approval_runtime/ - * Manages approval requests lifecycle: create, wait, resolve, cancel. + * Approval runtime — corresponds to Python approval_runtime/__init__.py + * Re-exports from models.ts and runtime.ts. */ -import { randomUUID } from "node:crypto"; -import { AsyncLocalStorage } from "node:async_hooks"; -import { logger } from "../utils/logging.ts"; -import type { RootWireHub } from "../wire/root_hub.ts"; -import type { - ApprovalRequest as WireApprovalRequest, - ApprovalResponse as WireApprovalResponse, -} from "../wire/types.ts"; - -// ── Types ─────────────────────────────────────────────── - -export type ApprovalResponseKind = "approve" | "approve_for_session" | "reject"; -export type ApprovalSourceKind = "foreground_turn" | "background_agent"; -export type ApprovalStatus = "pending" | "resolved" | "cancelled"; -export type ApprovalRuntimeEventKind = "request_created" | "request_resolved"; - -export interface ApprovalSource { - kind: ApprovalSourceKind; - id: string; - agentId?: string; - subagentType?: string; -} - -export interface ApprovalRequestRecord { - id: string; - toolCallId: string; - sender: string; - action: string; - description: string; - display: unknown[]; - source: ApprovalSource; - createdAt: number; - status: ApprovalStatus; - resolvedAt: number | null; - response: ApprovalResponseKind | null; - feedback: string; -} - -export interface ApprovalRuntimeEvent { - kind: ApprovalRuntimeEventKind; - request: ApprovalRequestRecord; -} - -// ── Errors ────────────────────────────────────────────── - -export class ApprovalCancelledError extends Error { - constructor(requestId: string) { - super(`Approval cancelled: ${requestId}`); - this.name = "ApprovalCancelledError"; - } -} - -// ── Approval Source Context (ContextVar equivalent) ───── - -const _approvalSourceStorage = new AsyncLocalStorage(); - -export function getCurrentApprovalSourceOrNull(): ApprovalSource | null { - return _approvalSourceStorage.getStore() ?? null; -} - -export function setCurrentApprovalSource(source: ApprovalSource): void { - // Note: AsyncLocalStorage manages context automatically via run(). - // For imperative set/reset, we store on the current context. - const store = _approvalSourceStorage.getStore(); - if (store !== undefined) { - // We're inside a run() context — callers should use runWithApprovalSource instead - logger.warn("setCurrentApprovalSource called inside existing context"); - } -} - -/** - * Run a callback with the given approval source set as the current context. - * Equivalent to Python's ContextVar set/reset pattern. - */ -export function runWithApprovalSource(source: ApprovalSource, fn: () => T): T { - return _approvalSourceStorage.run(source, fn); -} - -/** - * Run an async callback with the given approval source set as the current context. - */ -export async function runWithApprovalSourceAsync( - source: ApprovalSource, - fn: () => Promise, -): Promise { - return _approvalSourceStorage.run(source, fn); -} - -// ── Waiter (promise-based future) ─────────────────────── - -interface Waiter { - resolve: (value: [ApprovalResponseKind, string]) => void; - reject: (reason: Error) => void; - promise: Promise<[ApprovalResponseKind, string]>; -} - -function createWaiter(): Waiter { - let resolve!: Waiter["resolve"]; - let reject!: Waiter["reject"]; - const promise = new Promise<[ApprovalResponseKind, string]>((res, rej) => { - resolve = res; - reject = rej; - }); - return { resolve, reject, promise }; -} - -// ── Runtime ───────────────────────────────────────────── - -export type EventSubscriber = (event: ApprovalRuntimeEvent) => void; - -export class ApprovalRuntime { - private requests = new Map(); - private waiters = new Map(); - private subscribers = new Map(); - private _rootWireHub: RootWireHub | null = null; - - /** Bind a root wire hub for broadcasting approval events to UI. */ - bindRootWireHub(rootWireHub: RootWireHub): void { - if (this._rootWireHub === rootWireHub) return; - this._rootWireHub = rootWireHub; - } - - createRequest(opts: { - requestId?: string; - toolCallId: string; - sender: string; - action: string; - description: string; - display?: unknown[]; - source: ApprovalSource; - }): ApprovalRequestRecord { - const request: ApprovalRequestRecord = { - id: opts.requestId ?? randomUUID(), - toolCallId: opts.toolCallId, - sender: opts.sender, - action: opts.action, - description: opts.description, - display: opts.display ?? [], - source: opts.source, - createdAt: Date.now() / 1000, - status: "pending", - resolvedAt: null, - response: null, - feedback: "", - }; - this.requests.set(request.id, request); - this.publishEvent({ kind: "request_created", request }); - this._publishWireRequest(request); - return request; - } - - async waitForResponse( - requestId: string, - timeout: number = 300_000, - ): Promise<[ApprovalResponseKind, string]> { - const request = this.requests.get(requestId); - if (!request) throw new Error(`Approval request not found: ${requestId}`); - - if (request.status === "cancelled") { - throw new ApprovalCancelledError(requestId); - } - if (request.status === "resolved" && request.response) { - return [request.response, request.feedback]; - } - - let waiter = this.waiters.get(requestId); - if (!waiter) { - waiter = createWaiter(); - this.waiters.set(requestId, waiter); - } - - // Race the waiter against a timeout to prevent hanging forever - const timeoutPromise = new Promise((_, reject) => { - const timer = setTimeout(() => { - reject(new Error("timeout")); - }, timeout); - // Don't hold the process open for this timer - if (typeof timer === "object" && "unref" in timer) { - timer.unref(); - } - }); - - try { - return await Promise.race([waiter.promise, timeoutPromise]); - } catch (err) { - if (err instanceof Error && err.message === "timeout") { - logger.warn( - `Approval request ${requestId} timed out after ${timeout}ms`, - ); - // Pop the waiter before cancelling so _cancelRequest won't - // reject a future that nobody is awaiting (which would trigger - // an "unhandled rejection" warning). - this.waiters.delete(requestId); - this._cancelRequest(requestId, "approval timed out"); - throw new ApprovalCancelledError(requestId); - } - throw err; - } - } - - resolve(requestId: string, response: ApprovalResponseKind, feedback = ""): boolean { - const request = this.requests.get(requestId); - if (!request || request.status !== "pending") return false; - - request.status = "resolved"; - request.response = response; - request.feedback = feedback; - request.resolvedAt = Date.now() / 1000; - - const waiter = this.waiters.get(requestId); - if (waiter) { - waiter.resolve([response, feedback]); - this.waiters.delete(requestId); - } - this.publishEvent({ kind: "request_resolved", request }); - this._publishWireResponse(requestId, response, feedback); - return true; - } - - /** Cancel a single pending request by ID. */ - private _cancelRequest(requestId: string, feedback = ""): void { - const request = this.requests.get(requestId); - if (!request || request.status !== "pending") return; - - request.status = "cancelled"; - request.response = "reject"; - request.feedback = feedback; - request.resolvedAt = Date.now() / 1000; - - const waiter = this.waiters.get(requestId); - if (waiter) { - waiter.reject(new ApprovalCancelledError(requestId)); - this.waiters.delete(requestId); - } - this.publishEvent({ kind: "request_resolved", request }); - this._publishWireResponse(requestId, "reject", feedback); - } - - cancelBySource(sourceKind: ApprovalSourceKind, sourceId: string): number { - let cancelled = 0; - for (const [requestId, request] of this.requests) { - if (request.status !== "pending") continue; - if (request.source.kind !== sourceKind || request.source.id !== sourceId) continue; - - request.status = "cancelled"; - request.response = "reject"; - request.resolvedAt = Date.now() / 1000; - - const waiter = this.waiters.get(requestId); - if (waiter) { - waiter.reject(new ApprovalCancelledError(requestId)); - this.waiters.delete(requestId); - } - this.publishEvent({ kind: "request_resolved", request }); - this._publishWireResponse(requestId, "reject"); - cancelled++; - } - return cancelled; - } - - listPending(): ApprovalRequestRecord[] { - return [...this.requests.values()] - .filter((r) => r.status === "pending") - .sort((a, b) => a.createdAt - b.createdAt); - } - - getRequest(requestId: string): ApprovalRequestRecord | undefined { - return this.requests.get(requestId); - } - - subscribe(callback: EventSubscriber): string { - const token = randomUUID(); - this.subscribers.set(token, callback); - return token; - } - - unsubscribe(token: string): void { - this.subscribers.delete(token); - } - - private publishEvent(event: ApprovalRuntimeEvent): void { - for (const cb of this.subscribers.values()) { - try { - cb(event); - } catch (err) { - logger.error("Approval runtime event subscriber failed", err); - } - } - } - - private _publishWireRequest(request: ApprovalRequestRecord): void { - if (!this._rootWireHub) return; - this._rootWireHub.publishNowait({ - id: request.id, - tool_call_id: request.toolCallId, - sender: request.sender, - action: request.action, - description: request.description, - display: request.display, - source_kind: request.source.kind, - source_id: request.source.id, - agent_id: request.source.agentId ?? null, - subagent_type: request.source.subagentType ?? null, - source_description: null, - } as unknown as WireApprovalRequest); - } - - private _publishWireResponse( - requestId: string, - response: ApprovalResponseKind, - feedback = "", - ): void { - if (!this._rootWireHub) return; - this._rootWireHub.publishNowait({ - request_id: requestId, - response, - feedback, - } as unknown as WireApprovalResponse); - } -} +export { + type ApprovalResponseKind, + type ApprovalSourceKind, + type ApprovalStatus, + type ApprovalRuntimeEventKind, + type ApprovalSource, + type ApprovalRequestRecord, + type ApprovalRuntimeEvent, +} from "./models.ts"; + +export { + ApprovalCancelledError, + ApprovalRuntime, + getCurrentApprovalSourceOrNull, + setCurrentApprovalSource, + runWithApprovalSource, + runWithApprovalSourceAsync, + type EventSubscriber, +} from "./runtime.ts"; diff --git a/src/kimi_cli_ts/approval_runtime/models.ts b/src/kimi_cli_ts/approval_runtime/models.ts new file mode 100644 index 000000000..dd3b4a43d --- /dev/null +++ b/src/kimi_cli_ts/approval_runtime/models.ts @@ -0,0 +1,38 @@ +/** + * Approval runtime models — corresponds to Python approval_runtime/models.py + * Data types for approval requests, sources, and events. + */ + +// ── Types ─────────────────────────────────────────────── + +export type ApprovalResponseKind = "approve" | "approve_for_session" | "reject"; +export type ApprovalSourceKind = "foreground_turn" | "background_agent"; +export type ApprovalStatus = "pending" | "resolved" | "cancelled"; +export type ApprovalRuntimeEventKind = "request_created" | "request_resolved"; + +export interface ApprovalSource { + kind: ApprovalSourceKind; + id: string; + agentId?: string; + subagentType?: string; +} + +export interface ApprovalRequestRecord { + id: string; + toolCallId: string; + sender: string; + action: string; + description: string; + display: unknown[]; + source: ApprovalSource; + createdAt: number; + status: ApprovalStatus; + resolvedAt: number | null; + response: ApprovalResponseKind | null; + feedback: string; +} + +export interface ApprovalRuntimeEvent { + kind: ApprovalRuntimeEventKind; + request: ApprovalRequestRecord; +} diff --git a/src/kimi_cli_ts/approval_runtime/runtime.ts b/src/kimi_cli_ts/approval_runtime/runtime.ts new file mode 100644 index 000000000..a8a4e7452 --- /dev/null +++ b/src/kimi_cli_ts/approval_runtime/runtime.ts @@ -0,0 +1,298 @@ +/** + * Approval runtime — corresponds to Python approval_runtime/runtime.py + * Manages approval requests lifecycle: create, wait, resolve, cancel. + */ + +import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { logger } from "../utils/logging.ts"; +import type { RootWireHub } from "../wire/root_hub.ts"; +import type { + ApprovalRequest as WireApprovalRequest, + ApprovalResponse as WireApprovalResponse, +} from "../wire/types.ts"; +import type { + ApprovalRequestRecord, + ApprovalResponseKind, + ApprovalRuntimeEvent, + ApprovalSource, + ApprovalSourceKind, +} from "./models.ts"; + +// ── Errors ────────────────────────────────────────────── + +export class ApprovalCancelledError extends Error { + constructor(requestId: string) { + super(`Approval cancelled: ${requestId}`); + this.name = "ApprovalCancelledError"; + } +} + +// ── Approval Source Context (ContextVar equivalent) ───── + +const _approvalSourceStorage = new AsyncLocalStorage(); + +export function getCurrentApprovalSourceOrNull(): ApprovalSource | null { + return _approvalSourceStorage.getStore() ?? null; +} + +export function setCurrentApprovalSource(source: ApprovalSource): void { + // Note: AsyncLocalStorage manages context automatically via run(). + // For imperative set/reset, we store on the current context. + const store = _approvalSourceStorage.getStore(); + if (store !== undefined) { + // We're inside a run() context — callers should use runWithApprovalSource instead + logger.warn("setCurrentApprovalSource called inside existing context"); + } +} + +/** + * Run a callback with the given approval source set as the current context. + * Equivalent to Python's ContextVar set/reset pattern. + */ +export function runWithApprovalSource(source: ApprovalSource, fn: () => T): T { + return _approvalSourceStorage.run(source, fn); +} + +/** + * Run an async callback with the given approval source set as the current context. + */ +export async function runWithApprovalSourceAsync( + source: ApprovalSource, + fn: () => Promise, +): Promise { + return _approvalSourceStorage.run(source, fn); +} + +// ── Waiter (promise-based future) ─────────────────────── + +interface Waiter { + resolve: (value: [ApprovalResponseKind, string]) => void; + reject: (reason: Error) => void; + promise: Promise<[ApprovalResponseKind, string]>; +} + +function createWaiter(): Waiter { + let resolve!: Waiter["resolve"]; + let reject!: Waiter["reject"]; + const promise = new Promise<[ApprovalResponseKind, string]>((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject, promise }; +} + +// ── Runtime ───────────────────────────────────────────── + +export type EventSubscriber = (event: ApprovalRuntimeEvent) => void; + +export class ApprovalRuntime { + private requests = new Map(); + private waiters = new Map(); + private subscribers = new Map(); + private _rootWireHub: RootWireHub | null = null; + + /** Bind a root wire hub for broadcasting approval events to UI. */ + bindRootWireHub(rootWireHub: RootWireHub): void { + if (this._rootWireHub === rootWireHub) return; + this._rootWireHub = rootWireHub; + } + + createRequest(opts: { + requestId?: string; + toolCallId: string; + sender: string; + action: string; + description: string; + display?: unknown[]; + source: ApprovalSource; + }): ApprovalRequestRecord { + const request: ApprovalRequestRecord = { + id: opts.requestId ?? randomUUID(), + toolCallId: opts.toolCallId, + sender: opts.sender, + action: opts.action, + description: opts.description, + display: opts.display ?? [], + source: opts.source, + createdAt: Date.now() / 1000, + status: "pending", + resolvedAt: null, + response: null, + feedback: "", + }; + this.requests.set(request.id, request); + this.publishEvent({ kind: "request_created", request }); + this._publishWireRequest(request); + return request; + } + + async waitForResponse( + requestId: string, + timeout: number = 300_000, + ): Promise<[ApprovalResponseKind, string]> { + const request = this.requests.get(requestId); + if (!request) throw new Error(`Approval request not found: ${requestId}`); + + if (request.status === "cancelled") { + throw new ApprovalCancelledError(requestId); + } + if (request.status === "resolved" && request.response) { + return [request.response, request.feedback]; + } + + let waiter = this.waiters.get(requestId); + if (!waiter) { + waiter = createWaiter(); + this.waiters.set(requestId, waiter); + } + + // Race the waiter against a timeout to prevent hanging forever + const timeoutPromise = new Promise((_, reject) => { + const timer = setTimeout(() => { + reject(new Error("timeout")); + }, timeout); + // Don't hold the process open for this timer + if (typeof timer === "object" && "unref" in timer) { + timer.unref(); + } + }); + + try { + return await Promise.race([waiter.promise, timeoutPromise]); + } catch (err) { + if (err instanceof Error && err.message === "timeout") { + logger.warn( + `Approval request ${requestId} timed out after ${timeout}ms`, + ); + // Pop the waiter before cancelling so _cancelRequest won't + // reject a future that nobody is awaiting (which would trigger + // an "unhandled rejection" warning). + this.waiters.delete(requestId); + this._cancelRequest(requestId, "approval timed out"); + throw new ApprovalCancelledError(requestId); + } + throw err; + } + } + + resolve(requestId: string, response: ApprovalResponseKind, feedback = ""): boolean { + const request = this.requests.get(requestId); + if (!request || request.status !== "pending") return false; + + request.status = "resolved"; + request.response = response; + request.feedback = feedback; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.resolve([response, feedback]); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, response, feedback); + return true; + } + + /** Cancel a single pending request by ID. */ + private _cancelRequest(requestId: string, feedback = ""): void { + const request = this.requests.get(requestId); + if (!request || request.status !== "pending") return; + + request.status = "cancelled"; + request.response = "reject"; + request.feedback = feedback; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.reject(new ApprovalCancelledError(requestId)); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, "reject", feedback); + } + + cancelBySource(sourceKind: ApprovalSourceKind, sourceId: string): number { + let cancelled = 0; + for (const [requestId, request] of this.requests) { + if (request.status !== "pending") continue; + if (request.source.kind !== sourceKind || request.source.id !== sourceId) continue; + + request.status = "cancelled"; + request.response = "reject"; + request.resolvedAt = Date.now() / 1000; + + const waiter = this.waiters.get(requestId); + if (waiter) { + waiter.reject(new ApprovalCancelledError(requestId)); + this.waiters.delete(requestId); + } + this.publishEvent({ kind: "request_resolved", request }); + this._publishWireResponse(requestId, "reject"); + cancelled++; + } + return cancelled; + } + + listPending(): ApprovalRequestRecord[] { + return [...this.requests.values()] + .filter((r) => r.status === "pending") + .sort((a, b) => a.createdAt - b.createdAt); + } + + getRequest(requestId: string): ApprovalRequestRecord | undefined { + return this.requests.get(requestId); + } + + subscribe(callback: EventSubscriber): string { + const token = randomUUID(); + this.subscribers.set(token, callback); + return token; + } + + unsubscribe(token: string): void { + this.subscribers.delete(token); + } + + private publishEvent(event: ApprovalRuntimeEvent): void { + for (const cb of this.subscribers.values()) { + try { + cb(event); + } catch (err) { + logger.error("Approval runtime event subscriber failed", err); + } + } + } + + private _publishWireRequest(request: ApprovalRequestRecord): void { + if (!this._rootWireHub) return; + this._rootWireHub.publishNowait({ + id: request.id, + tool_call_id: request.toolCallId, + sender: request.sender, + action: request.action, + description: request.description, + display: request.display, + source_kind: request.source.kind, + source_id: request.source.id, + agent_id: request.source.agentId ?? null, + subagent_type: request.source.subagentType ?? null, + source_description: null, + } as unknown as WireApprovalRequest); + } + + private _publishWireResponse( + requestId: string, + response: ApprovalResponseKind, + feedback = "", + ): void { + if (!this._rootWireHub) return; + this._rootWireHub.publishNowait({ + request_id: requestId, + response, + feedback, + } as unknown as WireApprovalResponse); + } +} diff --git a/src/kimi_cli_ts/background/agent_runner.ts b/src/kimi_cli_ts/background/agent_runner.ts new file mode 100644 index 000000000..f5427a37e --- /dev/null +++ b/src/kimi_cli_ts/background/agent_runner.ts @@ -0,0 +1,247 @@ +/** + * Background agent runner — corresponds to Python background/agent_runner.py + * Runs a subagent as a background task, handling approval events and lifecycle. + */ + +import { logger } from "../utils/logging.ts"; +import { + type ApprovalSource, + type ApprovalRuntimeEvent, + ApprovalCancelledError, + runWithApprovalSourceAsync, +} from "../approval_runtime/index.ts"; +import { RunCancelled } from "../soul/index.ts"; +import { SubagentBuilder } from "../subagents/builder.ts"; +import { type SubagentRunSpec, prepareSoul } from "../subagents/core.ts"; +import { SubagentOutputWriter } from "../subagents/output.ts"; +import { runWithSummaryContinuation } from "../subagents/runner.ts"; +import type { Wire } from "../wire/wire_core.ts"; +import type { BackgroundTaskManager } from "./manager.ts"; +import type { Runtime } from "../soul/agent.ts"; + +export class BackgroundAgentRunner { + private _runtime: Runtime; + private _manager: BackgroundTaskManager; + private _taskId: string; + private _agentId: string; + private _subagentType: string; + private _prompt: string; + private _modelOverride: string | null; + private _timeoutMs: number | null; + private _resumed: boolean; + private _builder: SubagentBuilder; + private _approvalAbortControllers = new Set(); + + constructor(opts: { + runtime: Runtime; + manager: BackgroundTaskManager; + taskId: string; + agentId: string; + subagentType: string; + prompt: string; + modelOverride?: string | null; + timeoutS?: number | null; + resumed?: boolean; + }) { + this._runtime = opts.runtime; + this._manager = opts.manager; + this._taskId = opts.taskId; + this._agentId = opts.agentId; + this._subagentType = opts.subagentType; + this._prompt = opts.prompt; + this._modelOverride = opts.modelOverride ?? null; + this._timeoutMs = opts.timeoutS != null ? opts.timeoutS * 1000 : null; + this._resumed = opts.resumed ?? false; + this._builder = new SubagentBuilder(opts.runtime); + } + + async run(): Promise { + const approvalRuntime = this._runtime.approvalRuntime; + const subagentStore = this._runtime.subagentStore; + if (!approvalRuntime || !subagentStore) { + throw new Error("approvalRuntime and subagentStore must be set"); + } + + const source: ApprovalSource = { + kind: "background_agent", + id: this._taskId, + agentId: this._agentId, + subagentType: this._subagentType, + }; + + const approvalSubscription = approvalRuntime.subscribe( + (event) => this._onApprovalRuntimeEvent(event), + ); + + const taskOutputPath = this._manager.store.outputPath(this._taskId); + const output = new SubagentOutputWriter( + subagentStore.outputPath(this._agentId), + [taskOutputPath], + ); + + try { + await runWithApprovalSourceAsync(source, async () => { + if (this._timeoutMs != null) { + await this._runWithTimeout(output, this._timeoutMs); + } else { + await this._runCore(output); + } + }); + } catch (err) { + if (err instanceof RunCancelled) { + subagentStore.updateInstance(this._agentId, { status: "killed" }); + this._manager.markTaskKilled(this._taskId, "Run was cancelled"); + output.stage("cancelled"); + } else if (err instanceof Error && err.name === "AbortError") { + // Task was stopped externally + subagentStore.updateInstance(this._agentId, { status: "killed" }); + this._manager.markTaskKilled(this._taskId, "Stopped by TaskStop"); + output.stage("cancelled"); + } else if (err instanceof Error) { + logger.error("Background agent runner failed", err.message); + subagentStore.updateInstance(this._agentId, { status: "failed" }); + this._manager.markTaskFailed(this._taskId, err.message); + output.error(err.message); + } + } finally { + for (const ac of this._approvalAbortControllers) { + ac.abort(); + } + this._approvalAbortControllers.clear(); + approvalRuntime.unsubscribe(approvalSubscription); + approvalRuntime.cancelBySource("background_agent", this._taskId); + } + } + + private async _runWithTimeout(output: SubagentOutputWriter, timeoutMs: number): Promise { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + if (typeof timer === "object" && "unref" in timer) { + (timer as NodeJS.Timeout).unref(); + } + try { + await Promise.race([ + this._runCore(output), + new Promise((_, reject) => { + ac.signal.addEventListener("abort", () => { + reject(new Error(`Agent task timed out after ${timeoutMs / 1000}s`)); + }); + }), + ]); + } catch (err) { + if (err instanceof Error && err.message.startsWith("Agent task timed out")) { + logger.warn(`Background agent task ${this._taskId} timed out after ${timeoutMs / 1000}s`); + const subagentStore = this._runtime.subagentStore!; + subagentStore.updateInstance(this._agentId, { status: "failed" }); + this._manager.markTaskFailed(this._taskId, err.message); + output.error(err.message); + return; + } + throw err; + } finally { + clearTimeout(timer); + } + } + + private async _runCore(output: SubagentOutputWriter): Promise { + const subagentStore = this._runtime.subagentStore!; + this._manager.markTaskRunning(this._taskId); + output.stage("runner_started"); + + const typeDef = this._runtime.laborMarket!.requireBuiltinType(this._subagentType); + const record = subagentStore.requireInstance(this._agentId); + let launchSpec = record.launchSpec; + if (this._modelOverride != null) { + launchSpec = { + ...launchSpec, + modelOverride: this._modelOverride, + effectiveModel: this._modelOverride, + }; + } + + const spec: SubagentRunSpec = { + agentId: this._agentId, + typeDef, + launchSpec, + prompt: this._prompt, + resumed: this._resumed, + }; + const [soul, prompt] = await prepareSoul( + spec, + this._runtime, + this._builder, + subagentStore, + (name) => output.stage(name), + ); + + const uiLoopFn = async (wire: Wire): Promise => { + const wireUi = wire.uiSide(true); + while (true) { + const msg = await wireUi.receive(); + output.writeWireMessage(msg); + } + }; + + output.stage("run_soul_start"); + const [finalResponse, failure] = await runWithSummaryContinuation( + soul, + prompt, + uiLoopFn, + subagentStore.wirePath(this._agentId), + ); + + if (failure != null) { + this._manager.markTaskFailed(this._taskId, failure.message); + subagentStore.updateInstance(this._agentId, { status: "failed" }); + output.stage(`failed: ${failure.brief}`); + return; + } + output.stage("run_soul_finished"); + + if (finalResponse == null) { + this._manager.markTaskFailed( + this._taskId, + "Agent completed but produced no output.", + ); + subagentStore.updateInstance(this._agentId, { status: "failed" }); + output.stage("failed: empty output"); + return; + } + output.summary(finalResponse); + subagentStore.updateInstance(this._agentId, { status: "idle" }); + this._manager.markTaskCompleted(this._taskId); + } + + private _onApprovalRuntimeEvent(event: ApprovalRuntimeEvent): void { + const request = event.request; + if (request.source.kind !== "background_agent" || request.source.id !== this._taskId) { + return; + } + const ac = new AbortController(); + this._approvalAbortControllers.add(ac); + this._applyApprovalRuntimeEvent(event) + .catch((err) => { + if (!(err instanceof Error && err.name === "AbortError")) { + logger.error("Failed to apply background approval state update", err); + } + }) + .finally(() => { + this._approvalAbortControllers.delete(ac); + }); + } + + private async _applyApprovalRuntimeEvent(event: ApprovalRuntimeEvent): Promise { + const approvalRuntime = this._runtime.approvalRuntime!; + if (event.kind === "request_created") { + this._manager.markTaskAwaitingApproval(this._taskId, event.request.description); + } else if (event.kind === "request_resolved") { + const pendingForTask = approvalRuntime + .listPending() + .filter( + (p) => p.source.kind === "background_agent" && p.source.id === this._taskId, + ); + if (pendingForTask.length > 0) return; + this._manager.markTaskRunning(this._taskId); + } + } +} diff --git a/src/kimi_cli_ts/background/index.ts b/src/kimi_cli_ts/background/index.ts new file mode 100644 index 000000000..fe1ada21b --- /dev/null +++ b/src/kimi_cli_ts/background/index.ts @@ -0,0 +1,26 @@ +/** + * Background barrel export — corresponds to Python background/__init__.py + */ + +export { generateTaskId } from "./ids.ts"; +export { BackgroundTaskManager } from "./manager.ts"; +export { + type TaskConsumerState, + type TaskControl, + type TaskKind, + type TaskOutputChunk, + type TaskRuntime, + type TaskSpec, + type TaskStatus, + type TaskView, + isTerminalStatus, +} from "./models.ts"; +export { BackgroundTaskStore } from "./store.ts"; +export { + buildActiveTaskSnapshot, + formatTask, + formatTaskList, + listTaskViews, +} from "./summary.ts"; +export { runBackgroundTaskWorker } from "./worker.ts"; +export { BackgroundAgentRunner } from "./agent_runner.ts"; diff --git a/src/kimi_cli_ts/background/manager.ts b/src/kimi_cli_ts/background/manager.ts index 70f144bf1..5d21f3568 100644 --- a/src/kimi_cli_ts/background/manager.ts +++ b/src/kimi_cli_ts/background/manager.ts @@ -355,6 +355,27 @@ export class BackgroundTaskManager { this._store.writeRuntime(taskId, runtime); } + markTaskAwaitingApproval(taskId: string, reason: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "awaiting_approval"; + runtime.updatedAt = Date.now() / 1000; + runtime.failureReason = reason; + this._store.writeRuntime(taskId, runtime); + } + + markTaskTimedOut(taskId: string, reason: string): void { + const runtime = this._store.readRuntime(taskId); + if (isTerminalStatus(runtime.status)) return; + runtime.status = "failed"; + runtime.updatedAt = Date.now() / 1000; + runtime.finishedAt = runtime.updatedAt; + runtime.interrupted = true; + runtime.timedOut = true; + runtime.failureReason = reason; + this._store.writeRuntime(taskId, runtime); + } + private bestEffortKill(runtime: TaskRuntime): void { try { const pid = runtime.childPgid ?? runtime.childPid ?? runtime.workerPid; diff --git a/src/kimi_cli_ts/cli/errors.ts b/src/kimi_cli_ts/cli/errors.ts new file mode 100644 index 000000000..51bba5cb7 --- /dev/null +++ b/src/kimi_cli_ts/cli/errors.ts @@ -0,0 +1,41 @@ +/** + * CLI error types — extracted from cli/index.ts to break circular imports. + * + * These sentinel errors are thrown by soul/kimisoul.ts and caught by cli/index.ts. + * Keeping them in a separate leaf module avoids the cycle: + * cli/index.ts → app.ts → soul/kimisoul.ts → cli/index.ts + */ + +export class Reload extends Error { + sessionId: string | null; + prefillText: string | null; + constructor(sessionId: string | null = null, prefillText: string | null = null) { + super("reload"); + this.name = "Reload"; + this.sessionId = sessionId; + this.prefillText = prefillText; + } +} + +/** Return true if an unknown value is a Reload sentinel. */ +export function isReload(err: unknown): err is Reload { + return err instanceof Reload || (err instanceof Error && err.name === "Reload"); +} + +export class SwitchToWeb extends Error { + sessionId: string | null; + constructor(sessionId: string | null = null) { + super("switch_to_web"); + this.name = "SwitchToWeb"; + this.sessionId = sessionId; + } +} + +export class SwitchToVis extends Error { + sessionId: string | null; + constructor(sessionId: string | null = null) { + super("switch_to_vis"); + this.name = "SwitchToVis"; + this.sessionId = sessionId; + } +} diff --git a/src/kimi_cli_ts/cli/index.ts b/src/kimi_cli_ts/cli/index.ts index e9cde7367..dd41d3826 100644 --- a/src/kimi_cli_ts/cli/index.ts +++ b/src/kimi_cli_ts/cli/index.ts @@ -4,54 +4,17 @@ */ import { Command } from "commander"; -import React from "react"; -import { render } from "ink"; -import { KimiCLI } from "../app.ts"; -import { runSoul, type UILoopFn } from "../soul/index.ts"; +// Heavy imports are lazy-loaded inside the action handler to speed up --help/--version. +// Only type imports (erased at runtime) are kept static. +import type { UILoopFn } from "../soul/index.ts"; import type { ContentPart } from "../types.ts"; -import { WireFile } from "../wire/file.ts"; -import { Shell } from "../ui/shell/Shell.tsx"; import type { WireUIEvent } from "../ui/shell/events.ts"; import type { ApprovalResponseKind } from "../wire/types.ts"; -import { QueueShutDown } from "../utils/queue.ts"; -import chalk from "chalk"; -import { patchInkLogUpdate } from "../ui/renderer/index.ts"; // ── Re-exports from Python cli/__init__.py ────────────── - -export class Reload extends Error { - sessionId: string | null; - prefillText: string | null; - constructor(sessionId: string | null = null, prefillText: string | null = null) { - super("reload"); - this.name = "Reload"; - this.sessionId = sessionId; - this.prefillText = prefillText; - } -} - -/** Return true if an unknown value is a Reload sentinel. */ -function isReload(err: unknown): err is Reload { - return err instanceof Reload || (err instanceof Error && err.name === "Reload"); -} - -export class SwitchToWeb extends Error { - sessionId: string | null; - constructor(sessionId: string | null = null) { - super("switch_to_web"); - this.name = "SwitchToWeb"; - this.sessionId = sessionId; - } -} - -export class SwitchToVis extends Error { - sessionId: string | null; - constructor(sessionId: string | null = null) { - super("switch_to_vis"); - this.name = "SwitchToVis"; - this.sessionId = sessionId; - } -} +// These live in cli/errors.ts to avoid circular imports (cli ↔ soul ↔ cli). +export { Reload, isReload, SwitchToWeb, SwitchToVis } from "./errors.ts"; +import { Reload, isReload } from "./errors.ts"; export type UIMode = "shell" | "print" | "acp" | "wire"; export type InputFormat = "text" | "stream-json"; @@ -75,8 +38,9 @@ function stripSessionIdSuffix(title: string, sessionId: string): string { /** * Print a hint for resuming the session after exit. + * chalk must be passed in since it's lazy-loaded. */ -function printResumeHint(session: { id: string; isEmpty?: () => Promise }): void { +function printResumeHint(session: { id: string; isEmpty?: () => Promise }, chalk: any): void { console.error(chalk.dim(`\nTo resume this session: kimi -r ${session.id}`)); } @@ -88,6 +52,7 @@ import { infoCommand } from "./info.ts"; import { exportCommand } from "./export.ts"; import { mcpCommand } from "./mcp.ts"; import { pluginCommand } from "./plugin.ts"; +import { toadCommand } from "./toad.ts"; import { visCommand } from "./vis.ts"; import { webCommand } from "./web.ts"; @@ -114,6 +79,7 @@ const program = new Command() .addCommand(exportCommand) .addCommand(mcpCommand) .addCommand(pluginCommand) + .addCommand(toadCommand) .addCommand(visCommand) .addCommand(webCommand); @@ -190,6 +156,31 @@ program maxRalphIterations?: number; }, ) => { + // ── Lazy-load heavy modules (only needed for actual runs, not --help/--version) ── + const [ + ReactModule, + { render }, + { KimiCLI }, + { runSoul }, + { WireFile }, + { Shell }, + { QueueShutDown }, + chalkModule, + { patchInkLogUpdate }, + ] = await Promise.all([ + import("react"), + import("ink"), + import("../app.ts"), + import("../soul/index.ts"), + import("../wire/file.ts"), + import("../ui/shell/Shell.tsx"), + import("../utils/queue.ts"), + import("chalk"), + import("../ui/renderer/index.ts"), + ]); + const React = ReactModule.default ?? ReactModule; + const chalk = chalkModule.default ?? chalkModule; + // Handle --yes alias for --yolo if (options.yes) options.yolo = true; @@ -280,31 +271,194 @@ program try { if (options.print) { // ── Print mode: UILoopFn writes directly to stdout/stderr ── + // Matches Python's TextPrinter: rich.print(msg) for every wire message. + /** + * Format a value as a Python-style repr string. + * Matches Python's rich.pretty_repr output conventions: + * - Strings: 'text' (single-quoted) + * - null/undefined → None + * - booleans → True/False + * - Objects with __wireType: TypeName(field=value, ...) + * - Plain objects: { key: value, ... } in Python repr style + */ + function pyRepr(value: unknown): string { + if (value === null || value === undefined) return "None"; + if (typeof value === "boolean") return value ? "True" : "False"; + if (typeof value === "number") return String(value); + if (typeof value === "string") return `'${value}'`; + if (Array.isArray(value)) { + const items = value.map((v) => pyRepr(v)); + return `[${items.join(", ")}]`; + } + if (typeof value === "object") { + const obj = value as Record; + // If it's a wire-typed object, format as TypeName(field=value, ...) + if (typeof obj.__wireType === "string") { + return formatWireMessage(obj); + } + // Plain object: format as TypeName(field=value, ...) if it looks like one, + // otherwise as { key: value, ... } + const entries = Object.entries(obj); + if (entries.length === 0) return "{}"; + const parts = entries.map(([k, v]) => `${k}: ${pyRepr(v)}`); + return `{ ${parts.join(", ")} }`; + } + return String(value); + } + + /** + * Format a wire message with Python-style pretty-printing. + * Matches rich.pretty_repr(obj, max_width=80): + * - If single-line repr fits in max_width, use single line + * - Otherwise, use multi-line with 4-space indent per level + */ + function prettyRepr( + typeName: string, + fields: [string, unknown][], + indent: number, + maxWidth: number, + ): string { + // Build field repr strings + const fieldReprs = fields.map(([k, v]) => { + // For nested wire-typed objects, try compact first + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + const obj = v as Record; + if (typeof obj.__wireType === "string") { + const nestedName = obj.__wireType; + const { __wireType: _, ...nestedFields } = obj; + const nestedEntries = Object.entries(nestedFields); + return { key: k, value: v, compact: `${k}=${pyRepr(v)}`, nestedType: nestedName, nestedEntries }; + } + } + return { key: k, value: v, compact: `${k}=${pyRepr(v)}`, nestedType: null, nestedEntries: null }; + }); + + // Try compact (single-line) first + const compact = `${typeName}(${fieldReprs.map((f) => f.compact).join(", ")})`; + if (compact.length + indent <= maxWidth) { + return compact; + } + + // Multi-line format with 4-space indent + const childIndent = indent + 4; + const pad = " ".repeat(childIndent); + const closePad = " ".repeat(indent); + const lines: string[] = [`${typeName}(`]; + for (let i = 0; i < fieldReprs.length; i++) { + const f = fieldReprs[i]; + const isLast = i === fieldReprs.length - 1; + const comma = isLast ? "" : ","; + + // For nested wire-typed objects, try to pretty-print them too + if (f.nestedType && f.nestedEntries) { + const nestedRepr = prettyRepr( + f.nestedType, + f.nestedEntries.map(([nk, nv]) => [nk, nv]), + childIndent, + maxWidth, + ); + // Check if nested repr is multi-line + if (nestedRepr.includes("\n")) { + const nestedLines = nestedRepr.split("\n"); + lines.push(`${pad}${f.key}=${nestedLines[0]}`); + for (let j = 1; j < nestedLines.length - 1; j++) { + lines.push(`${pad}${nestedLines[j]}`); + } + lines.push(`${pad}${nestedLines[nestedLines.length - 1]}${comma}`); + } else { + lines.push(`${pad}${f.key}=${nestedRepr}${comma}`); + } + } else { + lines.push(`${pad}${f.key}=${pyRepr(f.value)}${comma}`); + } + } + lines.push(`${closePad})`); + return lines.join("\n"); + } + + /** + * Format a wire message for display, matching Python's rich.print(msg) output. + * Uses rich-compatible pretty_repr with max_width=80. + */ + function formatWireMessage(msg: any): string { + const typeName = msg.__wireType ?? "Unknown"; + const { __wireType: _, ...fields } = msg; + const entries: [string, unknown][] = Object.entries(fields); + return prettyRepr(typeName, entries, 0, 80); + } + + const outputFormat = options.outputFormat ?? "text"; + const finalOnly = options.finalMessageOnly ?? false; + const printUILoopFn: UILoopFn = async (wire) => { const uiSide = wire.uiSide(true); + // For final-only mode, track the last step's text content + let finalTextBuffer = ""; + // For text mode, merge consecutive TextPart/ThinkPart messages + // (Python's wire merge produces single merged parts; TS wire sends streaming deltas) + let pendingMerge: { type: string; msg: any } | null = null; + + function flushPendingMerge(): void { + if (pendingMerge) { + process.stdout.write(formatWireMessage(pendingMerge.msg) + "\n"); + pendingMerge = null; + } + } + while (true) { let msg: any; try { msg = await uiSide.receive(); } catch (err) { - if (err instanceof QueueShutDown) break; + if (err instanceof QueueShutDown) { + flushPendingMerge(); + break; + } throw err; } const t = msg.__wireType ?? ""; - if (t === "TextPart") { - process.stdout.write(msg.text); - } else if (t === "ThinkPart") { - process.stderr.write(chalk.dim(msg.text)); - } else if (t === "TurnEnd") { - process.stdout.write("\n"); - } else if (t === "StatusUpdate") { - if (options.verbose && msg.token_usage) { - process.stderr.write( - chalk.dim( - `[tokens] in=${msg.token_usage.inputTokens} out=${msg.token_usage.outputTokens}\n`, - ), - ); + + if (finalOnly) { + // FinalOnly mode: only output the last step's text + if (t === "StepBegin" || t === "StepInterrupted") { + finalTextBuffer = ""; + } else if (t === "TextPart") { + finalTextBuffer += msg.text; + } else if (t === "TurnEnd") { + if (finalTextBuffer) { + if (outputFormat === "stream-json") { + process.stdout.write( + JSON.stringify({ role: "assistant", content: finalTextBuffer }) + "\n", + ); + } else { + process.stdout.write(finalTextBuffer + "\n"); + } + finalTextBuffer = ""; + } } + } else if (outputFormat === "text") { + // Text mode: merge consecutive same-type content parts, + // matching Python's merged Wire output. + if (t === "TextPart" || t === "ThinkPart") { + if (pendingMerge && pendingMerge.type === t) { + // Merge: append text to the pending message + pendingMerge.msg.text += msg.text; + } else { + // Different type or no pending — flush old, start new + flushPendingMerge(); + pendingMerge = { type: t, msg: { ...msg } }; + } + } else { + // Non-content message: flush pending merge, then print + flushPendingMerge(); + process.stdout.write(formatWireMessage(msg) + "\n"); + } + } else { + // stream-json mode: emit JSON for each wire message + const { __wireType: typeName, ...fields } = msg; + process.stdout.write( + JSON.stringify({ __wireType: typeName, ...fields }) + "\n", + ); } } }; @@ -323,6 +477,10 @@ program }); if (prompt) { + // Echo user input to stdout (matches Python Print.run() line 89) + if (outputFormat === "text" && !finalOnly) { + console.log(prompt); + } const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined; const cancelController = new AbortController(); await runSoul(app.soul, prompt, printUILoopFn, cancelController, { @@ -330,7 +488,7 @@ program runtime: app.soul.runtime, }); } - printResumeHint(app.session); + printResumeHint(app.session, chalk); await app.shutdown(); } else if (options.wire) { // ── Wire mode ── @@ -616,7 +774,7 @@ program } // Normal exit - printResumeHint(app.session); + printResumeHint(app.session, chalk); break; } } diff --git a/src/kimi_cli_ts/cli/toad.ts b/src/kimi_cli_ts/cli/toad.ts new file mode 100644 index 000000000..e75804016 --- /dev/null +++ b/src/kimi_cli_ts/cli/toad.ts @@ -0,0 +1,90 @@ +/** + * Toad command — corresponds to Python cli/toad.py + * Launches the Kimi terminal (Toad) via ACP subcommand. + */ + +import { Command } from "commander"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +/** + * Build the default ACP command to pass to Toad. + */ +function defaultAcpCommand(): string[] { + const argv0 = process.argv[1] ?? process.argv[0]; + if (argv0) { + const resolvedPath = resolve(argv0); + try { + const { statSync } = require("node:fs"); + statSync(resolvedPath); + // If it's not a .ts/.js file being run via bun, use it directly + if (!resolvedPath.endsWith(".ts") && !resolvedPath.endsWith(".js")) { + return [resolvedPath, "acp"]; + } + } catch { + // fall through + } + } + + // Fallback: run via bun + return [process.execPath, argv0 ?? "kimi", "acp"]; +} + +/** + * Extract --work-dir / -w from extra args without consuming them. + */ +function extractProjectDir(extraArgs: string[]): string | null { + let workDir: string | null = null; + let idx = 0; + while (idx < extraArgs.length) { + const arg = extraArgs[idx]!; + if (arg === "--work-dir" || arg === "-w") { + if (idx + 1 < extraArgs.length) { + workDir = extraArgs[idx + 1]!; + idx += 2; + continue; + } + } else if (arg.startsWith("--work-dir=") || arg.startsWith("-w=")) { + workDir = arg.split("=", 2)[1]!; + } else if (arg.startsWith("-w") && arg.length > 2) { + workDir = arg.slice(2); + } + idx += 1; + } + + if (!workDir) return null; + return resolve(workDir); +} + +export const toadCommand = new Command("term") + .description("Run Kimi in Toad terminal.") + .allowUnknownOption(true) + .allowExcessArguments(true) + .action((_options: Record, cmd: Command) => { + const extraArgs = cmd.args; + const acpArgs = defaultAcpCommand(); + const acpCommand = acpArgs.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" "); + + // Toad requires Bun — check availability + const toadBin = Bun.which("toad"); + if (!toadBin) { + console.error( + "Toad dependency is missing. Install toad to use `kimi term`.", + ); + process.exit(1); + } + + const args = [toadBin, "acp", acpCommand]; + const projectDir = extractProjectDir(extraArgs); + if (projectDir !== null) { + args.push(projectDir); + } + + const result = spawnSync(args[0]!, args.slice(1), { + stdio: "inherit", + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + }); diff --git a/src/kimi_cli_ts/config.ts b/src/kimi_cli_ts/config.ts index cc6398e64..cb98b9048 100644 --- a/src/kimi_cli_ts/config.ts +++ b/src/kimi_cli_ts/config.ts @@ -171,15 +171,14 @@ export interface ConfigMeta { // ── Paths ─────────────────────────────────────────────── -import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { ConfigError } from "./exception.ts"; export { ConfigError }; -export function getShareDir(): string { - return process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi"); -} +// Re-export from canonical location (share.ts) for backwards compatibility +export { getShareDir } from "./share.ts"; +import { getShareDir } from "./share.ts"; export function getConfigFile(): string { return join(getShareDir(), "config.toml"); diff --git a/src/kimi_cli_ts/hooks/index.ts b/src/kimi_cli_ts/hooks/index.ts new file mode 100644 index 000000000..8cad3e86b --- /dev/null +++ b/src/kimi_cli_ts/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Hooks barrel export — corresponds to Python hooks/__init__.py + */ + +export { HOOK_EVENT_TYPES, type HookDef, type HookEventType } from "./config.ts"; +export { HookEngine } from "./engine.ts"; +export { type HookResult, runHook } from "./runner.ts"; diff --git a/src/kimi_cli_ts/hooks/runner.ts b/src/kimi_cli_ts/hooks/runner.ts new file mode 100644 index 000000000..f35378b30 --- /dev/null +++ b/src/kimi_cli_ts/hooks/runner.ts @@ -0,0 +1,114 @@ +/** + * Hook runner — corresponds to Python hooks/runner.py + * Executes individual hook commands with fail-open semantics. + */ + +import { logger } from "../utils/logging.ts"; + +export interface HookResult { + action: "allow" | "block"; + reason: string; + stdout: string; + stderr: string; + exitCode: number; + timedOut: boolean; +} + +function defaultResult(overrides?: Partial): HookResult { + return { + action: "allow", + reason: "", + stdout: "", + stderr: "", + exitCode: 0, + timedOut: false, + ...overrides, + }; +} + +/** + * Execute a single hook command. Fail-open: errors/timeouts -> allow. + */ +export async function runHook( + command: string, + inputData: Record, + opts?: { timeout?: number; cwd?: string }, +): Promise { + const timeout = opts?.timeout ?? 30; + const cwd = opts?.cwd; + + try { + const proc = Bun.spawn(["sh", "-c", command], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: cwd ?? undefined, + }); + + // Write input data to stdin + proc.stdin.write(JSON.stringify(inputData)); + proc.stdin.end(); + + // Wait with timeout + const timeoutPromise = new Promise<"timeout">((resolve) => { + const timer = setTimeout(() => resolve("timeout"), timeout * 1000); + if (typeof timer === "object" && "unref" in timer) { + (timer as NodeJS.Timeout).unref(); + } + }); + + const exitPromise = proc.exited.then(() => "done" as const); + const race = await Promise.race([exitPromise, timeoutPromise]); + + if (race === "timeout") { + proc.kill(); + await proc.exited; + logger.warn(`Hook timed out after ${timeout}s: ${command}`); + return defaultResult({ timedOut: true }); + } + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = proc.exitCode ?? 0; + + // Exit 2 = block + if (exitCode === 2) { + return { + action: "block", + reason: stderr.trim(), + stdout, + stderr, + exitCode: 2, + timedOut: false, + }; + } + + // Exit 0 + JSON stdout = structured decision + if (exitCode === 0 && stdout.trim()) { + try { + const raw = JSON.parse(stdout); + if (raw && typeof raw === "object") { + const hookOutput = raw.hookSpecificOutput ?? {}; + if (hookOutput.permissionDecision === "deny") { + return { + action: "block", + reason: String(hookOutput.permissionDecisionReason ?? ""), + stdout, + stderr, + exitCode: 0, + timedOut: false, + }; + } + } + } catch { + // JSON parse error — ignore + } + } + + return defaultResult({ stdout, stderr, exitCode }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.warn(`Hook failed: ${command}: ${errMsg}`); + return defaultResult({ stderr: errMsg }); + } +} diff --git a/src/kimi_cli_ts/plugin/index.ts b/src/kimi_cli_ts/plugin/index.ts new file mode 100644 index 000000000..555c811a9 --- /dev/null +++ b/src/kimi_cli_ts/plugin/index.ts @@ -0,0 +1,28 @@ +/** + * Plugin barrel export — corresponds to Python plugin/__init__.py + */ + +export { + PluginError, + type PluginRuntime, + type PluginToolSpec, + type PluginSpec, + PLUGIN_JSON, + parsePluginJson, + getPluginsDir, + injectConfig, + writeRuntime, + installPlugin, + refreshPluginConfigs, + listPlugins, + removePlugin, +} from "./manager.ts"; + +export { + type PluginToolResult, + type PluginConfig, + type OAuthManager, + collectHostValues, + PluginTool, + loadPluginTools, +} from "./tool.ts"; diff --git a/src/kimi_cli_ts/session.ts b/src/kimi_cli_ts/session.ts index e7fa6c71b..06150140d 100644 --- a/src/kimi_cli_ts/session.ts +++ b/src/kimi_cli_ts/session.ts @@ -1,9 +1,8 @@ /** - * Session module — corresponds to Python session.py + session_state.py + * Session module — corresponds to Python session.py * Manages per-workdir sessions with context files and state persistence. */ -import { z } from "zod/v4"; import { join, resolve } from "node:path"; import { createHash, randomUUID } from "node:crypto"; import { getShareDir } from "./config.ts"; @@ -18,52 +17,17 @@ import { type WorkDirMeta, } from "./metadata.ts"; -// ── Session State ─────────────────────────────────────── - -export const ApprovalStateData = z.object({ - yolo: z.boolean().default(false), - auto_approve_actions: z.array(z.string()).default([]), -}); -export type ApprovalStateData = z.infer; - -export const SessionState = z.object({ - version: z.number().int().default(1), - approval: ApprovalStateData.default({} as any), - additional_dirs: z.array(z.string()).default([]), - custom_title: z.string().nullable().default(null), - title_generated: z.boolean().default(false), - title_generate_attempts: z.number().int().default(0), - plan_mode: z.boolean().default(false), - plan_session_id: z.string().nullable().default(null), - plan_slug: z.string().nullable().default(null), - wire_mtime: z.number().nullable().default(null), - archived: z.boolean().default(false), - archived_at: z.number().nullable().default(null), - auto_archive_exempt: z.boolean().default(false), -}); -export type SessionState = z.infer; - -const STATE_FILE_NAME = "state.json"; - -export async function loadSessionState(sessionDir: string): Promise { - const stateFile = join(sessionDir, STATE_FILE_NAME); - const file = Bun.file(stateFile); - if (!(await file.exists())) { - return SessionState.parse({}); - } - try { - const data = await file.json(); - return SessionState.parse(data); - } catch { - logger.warn(`Corrupted state file, using defaults: ${stateFile}`); - return SessionState.parse({}); - } -} - -export async function saveSessionState(state: SessionState, sessionDir: string): Promise { - const stateFile = join(sessionDir, STATE_FILE_NAME); - await Bun.write(stateFile, JSON.stringify(state, null, 2)); -} +// Re-export session state types and functions from canonical location (session_state.ts) +export { + ApprovalStateData, + TodoItemState, + SessionState, + loadSessionState, + saveSessionState, + STATE_FILE_NAME, +} from "./session_state.ts"; +import type { SessionState } from "./session_state.ts"; +import { SessionState as SessionStateSchema, loadSessionState, saveSessionState } from "./session_state.ts"; // ── WorkDir Metadata (uses metadata.ts for Python-compatible MD5 hashing) ── @@ -243,7 +207,7 @@ export class Session { sessionsDir, contextFile, wireFile: join(sessionDir, "wire.jsonl"), - state: SessionState.parse({}), + state: SessionStateSchema.parse({}), }); await session.refresh(); return session; diff --git a/src/kimi_cli_ts/session_state.ts b/src/kimi_cli_ts/session_state.ts new file mode 100644 index 000000000..517c9ea15 --- /dev/null +++ b/src/kimi_cli_ts/session_state.ts @@ -0,0 +1,154 @@ +/** + * Session state — corresponds to Python session_state.py + * Defines the per-session state schema and persistence. + */ + +import { z } from "zod/v4"; +import { join } from "node:path"; +import { logger } from "./utils/logging.ts"; + +export const STATE_FILE_NAME = "state.json"; + +// ── Schemas ──────────────────────────────────────────── + +export const ApprovalStateData = z.object({ + yolo: z.boolean().default(false), + auto_approve_actions: z.array(z.string()).default([]), +}); +export type ApprovalStateData = z.infer; + +export const TodoItemState = z.object({ + title: z.string(), + status: z.enum(["pending", "in_progress", "done"]), +}); +export type TodoItemState = z.infer; + +export const SessionState = z.object({ + version: z.number().int().default(1), + approval: ApprovalStateData.default({} as any), + additional_dirs: z.array(z.string()).default([]), + custom_title: z.string().nullable().default(null), + title_generated: z.boolean().default(false), + title_generate_attempts: z.number().int().default(0), + plan_mode: z.boolean().default(false), + plan_session_id: z.string().nullable().default(null), + plan_slug: z.string().nullable().default(null), + // Archive state (previously in metadata.json) + wire_mtime: z.number().nullable().default(null), + archived: z.boolean().default(false), + archived_at: z.number().nullable().default(null), + auto_archive_exempt: z.boolean().default(false), + // Todo list state + todos: z.array(TodoItemState).default([]), +}); +export type SessionState = z.infer; + +// ── Legacy metadata migration ────────────────────────── + +const LEGACY_METADATA_FILENAME = "metadata.json"; + +type MigrationResult = "migrated" | "no_change" | "skip"; + +/** + * Migrate fields from legacy metadata.json into SessionState. + * Returns "migrated" if fields were merged, "no_change" if parsed but nothing needed, + * "skip" if the file is missing or unreadable. + */ +async function migrateLegacyMetadata( + sessionDir: string, + state: SessionState, +): Promise { + const metadataFile = join(sessionDir, LEGACY_METADATA_FILENAME); + const file = Bun.file(metadataFile); + if (!(await file.exists())) return "skip"; + + let data: Record; + try { + data = await file.json(); + } catch { + return "skip"; + } + + let changed = false; + + // Migrate title fields (only if state has defaults) + if (state.custom_title === null && data.title && data.title !== "Untitled") { + state.custom_title = data.title as string; + changed = true; + } + if (!state.title_generated && data.title_generated) { + state.title_generated = true; + changed = true; + } + if ( + state.title_generate_attempts === 0 && + typeof data.title_generate_attempts === "number" && + data.title_generate_attempts > 0 + ) { + state.title_generate_attempts = data.title_generate_attempts; + changed = true; + } + + // Migrate archive fields + if (!state.archived && data.archived) { + state.archived = true; + changed = true; + } + if (state.archived_at === null && data.archived_at != null) { + state.archived_at = data.archived_at as number; + changed = true; + } + if (!state.auto_archive_exempt && data.auto_archive_exempt) { + state.auto_archive_exempt = true; + changed = true; + } + + // Migrate wire_mtime + if (state.wire_mtime === null && data.wire_mtime != null) { + state.wire_mtime = data.wire_mtime as number; + changed = true; + } + + return changed ? "migrated" : "no_change"; +} + +// ── Load / Save ──────────────────────────────────────── + +export async function loadSessionState(sessionDir: string): Promise { + const stateFile = join(sessionDir, STATE_FILE_NAME); + const file = Bun.file(stateFile); + let state: SessionState; + + if (!(await file.exists())) { + state = SessionState.parse({}); + } else { + try { + const data = await file.json(); + state = SessionState.parse(data); + } catch { + logger.warn(`Corrupted state file, using defaults: ${stateFile}`); + state = SessionState.parse({}); + } + } + + // One-time migration from legacy metadata.json (best-effort) + const migration = await migrateLegacyMetadata(sessionDir, state); + if (migration === "migrated" || migration === "no_change") { + try { + if (migration === "migrated") { + await saveSessionState(state, sessionDir); + } + const legacyFile = join(sessionDir, LEGACY_METADATA_FILENAME); + await Bun.$`rm -f ${legacyFile}`.quiet(); + } catch { + logger.warn(`Failed to persist migration for ${sessionDir}, will retry next load`); + } + } + + return state; +} + +export async function saveSessionState(state: SessionState, sessionDir: string): Promise { + const stateFile = join(sessionDir, STATE_FILE_NAME); + await Bun.write(stateFile, JSON.stringify(state, null, 2)); +} diff --git a/src/kimi_cli_ts/share.ts b/src/kimi_cli_ts/share.ts new file mode 100644 index 000000000..5352228b0 --- /dev/null +++ b/src/kimi_cli_ts/share.ts @@ -0,0 +1,18 @@ +/** + * Share directory — corresponds to Python share.py + * Returns the path to the global Kimi share directory (~/.kimi/). + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; + +/** + * Get the share directory path. + * Creates the directory if it doesn't exist (matches Python behavior). + */ +export function getShareDir(): string { + const dir = process.env.KIMI_SHARE_DIR ?? join(homedir(), ".kimi"); + mkdirSync(dir, { recursive: true }); + return dir; +} diff --git a/src/kimi_cli_ts/soul/agent.ts b/src/kimi_cli_ts/soul/agent.ts index 43d03712d..763a20914 100644 --- a/src/kimi_cli_ts/soul/agent.ts +++ b/src/kimi_cli_ts/soul/agent.ts @@ -409,13 +409,13 @@ async function registerBuiltinTools(toolset: KimiToolset, runtime: Runtime): Pro () => import("../tools/file/replace.ts"), () => import("../tools/file/glob.ts"), () => import("../tools/file/grep.ts"), - () => import("../tools/shell/shell.ts"), + () => import("../tools/shell/index.ts"), () => import("../tools/web/fetch.ts"), () => import("../tools/web/search.ts"), - () => import("../tools/think/think.ts"), - () => import("../tools/ask_user/ask_user.ts"), - () => import("../tools/todo/todo.ts"), - () => import("../tools/plan/plan.ts"), + () => import("../tools/think/index.ts"), + () => import("../tools/ask_user/index.ts"), + () => import("../tools/todo/index.ts"), + () => import("../tools/plan/index.ts"), ]; for (const loadModule of toolModules) { @@ -446,7 +446,7 @@ async function registerBuiltinTools(toolset: KimiToolset, runtime: Runtime): Pro // Register Agent tool (needs runtime for description building) try { - const { AgentTool } = await import("../tools/agent/agent.ts"); + const { AgentTool } = await import("../tools/agent/index.ts"); const agentTool = new AgentTool(); agentTool.buildDescription(runtime); toolset.add(agentTool); diff --git a/src/kimi_cli_ts/soul/dynamic_injections/index.ts b/src/kimi_cli_ts/soul/dynamic_injections/index.ts new file mode 100644 index 000000000..7c4522733 --- /dev/null +++ b/src/kimi_cli_ts/soul/dynamic_injections/index.ts @@ -0,0 +1,6 @@ +/** + * Dynamic injections barrel export — corresponds to Python soul/dynamic_injections/__init__.py + */ + +export { PlanModeInjectionProvider, fullReminder, sparseReminder } from "./plan_mode.ts"; +export { YoloModeInjectionProvider } from "./yolo_mode.ts"; diff --git a/src/kimi_cli_ts/soul/kimisoul.ts b/src/kimi_cli_ts/soul/kimisoul.ts index 1e0bdfbaa..bb51a42cd 100644 --- a/src/kimi_cli_ts/soul/kimisoul.ts +++ b/src/kimi_cli_ts/soul/kimisoul.ts @@ -21,7 +21,7 @@ import { PlanModeInjectionProvider } from "./dynamic_injections/plan_mode.ts"; import { YoloModeInjectionProvider } from "./dynamic_injections/yolo_mode.ts"; import { wireSend, wireMsg, getWireOrNull } from "./index.ts"; import { MaxStepsReached } from "./index.ts"; -import { Reload } from "../cli/index.ts"; +import { Reload } from "../cli/errors.ts"; import { handleNew, handleSessions, handleTitle, createSessionsPanel, createTitlePanel } from "../ui/shell/commands/session.ts"; import { handleModel, createModelPanel } from "../ui/shell/commands/model.ts"; import { handleLogin, handleLogout, createLoginPanel } from "../ui/shell/commands/login.ts"; @@ -1208,7 +1208,7 @@ export class KimiSoul { ctx.askUser = async (question: string, options?: string[]): Promise => { const { randomUUID } = await import("node:crypto"); const { PendingQuestionRequest } = await import("../wire/types.ts"); - const { registerPendingQuestion } = await import("../tools/ask_user/ask_user.ts"); + const { registerPendingQuestion } = await import("../tools/ask_user/index.ts"); const { getCurrentToolCallOrNull } = await import("./toolset.ts"); const wire = getWireOrNull(); diff --git a/src/kimi_cli_ts/subagents/index.ts b/src/kimi_cli_ts/subagents/index.ts new file mode 100644 index 000000000..e8b3d9953 --- /dev/null +++ b/src/kimi_cli_ts/subagents/index.ts @@ -0,0 +1,16 @@ +/** + * Subagents barrel export — corresponds to Python subagents/__init__.py + */ + +export { + type AgentInstanceRecord, + type AgentLaunchSpec, + type AgentTypeDefinition, + type SubagentStatus, + type ToolPolicy, + type ToolPolicyMode, + defaultToolPolicy, +} from "./models.ts"; + +export { LaborMarket } from "./registry.ts"; +export { SubagentStore } from "./store.ts"; diff --git a/src/kimi_cli_ts/tools/agent/agent.ts b/src/kimi_cli_ts/tools/agent/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/agent/agent.ts rename to src/kimi_cli_ts/tools/agent/index.ts diff --git a/src/kimi_cli_ts/tools/ask_user/ask_user.ts b/src/kimi_cli_ts/tools/ask_user/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/ask_user/ask_user.ts rename to src/kimi_cli_ts/tools/ask_user/index.ts diff --git a/src/kimi_cli_ts/tools/background/background.ts b/src/kimi_cli_ts/tools/background/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/background/background.ts rename to src/kimi_cli_ts/tools/background/index.ts diff --git a/src/kimi_cli_ts/tools/dmail/dmail.ts b/src/kimi_cli_ts/tools/dmail/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/dmail/dmail.ts rename to src/kimi_cli_ts/tools/dmail/index.ts diff --git a/src/kimi_cli_ts/tools/file/index.ts b/src/kimi_cli_ts/tools/file/index.ts new file mode 100644 index 000000000..88940e37d --- /dev/null +++ b/src/kimi_cli_ts/tools/file/index.ts @@ -0,0 +1,11 @@ +/** + * File tools barrel export. + * Corresponds to Python tools/file/__init__.py + */ + +export { Glob } from "./glob.ts"; +export { Grep } from "./grep.ts"; +export { ReadFile } from "./read.ts"; +export { ReadMediaFile } from "./read_media.ts"; +export { StrReplaceFile } from "./replace.ts"; +export { WriteFile } from "./write.ts"; diff --git a/src/kimi_cli_ts/tools/file/replace.ts b/src/kimi_cli_ts/tools/file/replace.ts index ca188ce28..2e56de215 100644 --- a/src/kimi_cli_ts/tools/file/replace.ts +++ b/src/kimi_cli_ts/tools/file/replace.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import { z } from "zod/v4"; +import { buildWireDiffBlocks } from "../../utils/diff.ts"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; import { ToolError, ToolRejectedError } from "../types.ts"; @@ -19,170 +20,183 @@ const DESCRIPTION = `Replace specific strings within a specified file. - You should prefer this tool over WriteFile tool and Shell \`sed\` command.`; const EditSchema = z.object({ - old: z.string().describe("The old string to replace. Can be multi-line."), - new: z.string().describe("The new string to replace with. Can be multi-line."), - replace_all: z - .boolean() - .default(false) - .describe("Whether to replace all occurrences."), + old: z.string().describe("The old string to replace. Can be multi-line."), + new: z + .string() + .describe("The new string to replace with. Can be multi-line."), + replace_all: z + .boolean() + .default(false) + .describe("Whether to replace all occurrences."), }); const ParamsSchema = z.object({ - path: z.string().describe( - "The path to the file to edit. Absolute paths are required when editing files outside the working directory.", - ), - edit: z - .union([EditSchema, z.array(EditSchema)]) - .describe("The edit(s) to apply to the file."), + path: z + .string() + .describe( + "The path to the file to edit. Absolute paths are required when editing files outside the working directory.", + ), + edit: z + .union([EditSchema, z.array(EditSchema)]) + .describe("The edit(s) to apply to the file."), }); type Params = z.infer; type Edit = z.infer; function resolvePath(filePath: string, workingDir: string): string { - if (filePath.startsWith("/") || filePath.startsWith("~")) { - if (filePath.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE || ""; - return filePath.replace(/^~/, home); - } - return filePath; - } - return resolve(workingDir, filePath); + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return resolve(workingDir, filePath); } function applyEdit(content: string, edit: Edit): string { - if (edit.replace_all) { - return content.split(edit.old).join(edit.new); - } - const idx = content.indexOf(edit.old); - if (idx === -1) return content; - return content.slice(0, idx) + edit.new + content.slice(idx + edit.old.length); + if (edit.replace_all) { + return content.split(edit.old).join(edit.new); + } + const idx = content.indexOf(edit.old); + if (idx === -1) return content; + return ( + content.slice(0, idx) + edit.new + content.slice(idx + edit.old.length) + ); } export class StrReplaceFile extends CallableTool { - readonly name = "StrReplaceFile"; - readonly description = DESCRIPTION; - readonly schema = ParamsSchema; - - /** Optional plan mode bindings. */ - private _planModeChecker?: () => boolean; - private _planFilePathGetter?: () => string | null; - - /** Bind plan mode state checker and plan file path getter. */ - bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { - this._planModeChecker = checker; - this._planFilePathGetter = pathGetter; - } - - async execute(params: Params, ctx: ToolContext): Promise { - if (!params.path) { - return ToolError("File path cannot be empty."); - } - - try { - const resolvedPath = resolvePath(params.path, ctx.workingDir); - - // Check plan mode restrictions - const planTarget = inspectPlanEditTarget(resolvedPath, { - planModeChecker: this._planModeChecker ?? ctx.getPlanMode, - planFilePathGetter: this._planFilePathGetter, - }); - if ("isError" in planTarget && planTarget.isError) { - return planTarget; - } - const isPlanFileEdit = !("isError" in planTarget) && planTarget.isPlanTarget; - - const file = Bun.file(resolvedPath); - - if (!(await file.exists())) { - if (isPlanFileEdit) { - return ToolError( - "The current plan file does not exist yet. " + - "Use WriteFile to create it before calling StrReplaceFile.", - ); - } - return ToolError(`\`${params.path}\` does not exist.`); - } - - // Check if it's actually a file - const { stat: fsStat } = await import("node:fs/promises"); - try { - const info = await fsStat(resolvedPath); - if (!info.isFile()) { - return ToolError(`\`${params.path}\` is not a file.`); - } - } catch { - // stat failed — continue - } - - // Read the file content - const originalContent = await file.text(); - let content = originalContent; - - const edits: Edit[] = Array.isArray(params.edit) - ? params.edit - : [params.edit]; - - // Apply all edits - for (const edit of edits) { - content = applyEdit(content, edit); - } - - // Check if any changes were made - if (content === originalContent) { - return ToolError( - "No replacements were made. The old string was not found in the file.", - ); - } - - // Plan file edits are auto-approved; all other edits need approval - if (!isPlanFileEdit) { - // Build diff preview - const diffLines: string[] = []; - for (const edit of edits) { - if (edit.old.length < 200 && edit.new.length < 200) { - diffLines.push(`-${edit.old.split("\n").join("\n-")}`); - diffLines.push(`+${edit.new.split("\n").join("\n+")}`); - } - } - const diffPreview = diffLines.length > 0 ? `\n${diffLines.join("\n")}` : ""; - - const { decision, feedback } = await ctx.approval( - "StrReplaceFile", - "edit", - `Edit file \`${resolvedPath}\` (${edits.length} edit(s))${diffPreview}`, - ); - if (decision === "reject") { - return new ToolRejectedError({ - message: feedback - ? `The tool call is rejected by the user. User feedback: ${feedback}` - : undefined, - brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", - hasFeedback: !!feedback, - }).toToolResult(); - } - } - - // Write the modified content back - await Bun.write(resolvedPath, content); - - // Count changes for success message - let totalReplacements = 0; - for (const edit of edits) { - if (edit.replace_all) { - totalReplacements += originalContent.split(edit.old).length - 1; - } else { - totalReplacements += originalContent.includes(edit.old) ? 1 : 0; - } - } - - return { - isError: false, - output: "", - message: `File successfully edited. Applied ${edits.length} edit(s) with ${totalReplacements} total replacement(s).`, - }; - } catch (e) { - return ToolError(`Failed to edit. Error: ${e}`); - } - } + readonly name = "StrReplaceFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + /** Optional plan mode bindings. */ + private _planModeChecker?: () => boolean; + private _planFilePathGetter?: () => string | null; + + /** Bind plan mode state checker and plan file path getter. */ + bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { + this._planModeChecker = checker; + this._planFilePathGetter = pathGetter; + } + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + + // Check plan mode restrictions + const planTarget = inspectPlanEditTarget(resolvedPath, { + planModeChecker: this._planModeChecker ?? ctx.getPlanMode, + planFilePathGetter: this._planFilePathGetter, + }); + if ("isError" in planTarget && planTarget.isError) { + return planTarget; + } + const isPlanFileEdit = + !("isError" in planTarget) && planTarget.isPlanTarget; + + const file = Bun.file(resolvedPath); + + if (!(await file.exists())) { + if (isPlanFileEdit) { + return ToolError( + "The current plan file does not exist yet. " + + "Use WriteFile to create it before calling StrReplaceFile.", + ); + } + return ToolError(`\`${params.path}\` does not exist.`); + } + + // Check if it's actually a file + const { stat: fsStat } = await import("node:fs/promises"); + try { + const info = await fsStat(resolvedPath); + if (!info.isFile()) { + return ToolError(`\`${params.path}\` is not a file.`); + } + } catch { + // stat failed — continue + } + + // Read the file content + const originalContent = await file.text(); + let content = originalContent; + + const edits: Edit[] = Array.isArray(params.edit) + ? params.edit + : [params.edit]; + + // Apply all edits + for (const edit of edits) { + content = applyEdit(content, edit); + } + + // Check if any changes were made + if (content === originalContent) { + return ToolError( + "No replacements were made. The old string was not found in the file.", + ); + } + + // Plan file edits are auto-approved; all other edits need approval + if (!isPlanFileEdit) { + // Build diff display blocks (matches Python's build_diff_blocks) + const diffBlocks = buildWireDiffBlocks( + resolvedPath, + originalContent, + content, + ); + + const { decision, feedback } = await ctx.approval( + "StrReplaceFile", + "edit", + `Edit file \`${resolvedPath}\` (${edits.length} edit(s))`, + { display: diffBlocks }, + ); + if (decision === "reject") { + return new ToolRejectedError({ + message: feedback + ? `The tool call is rejected by the user. User feedback: ${feedback}` + : undefined, + brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", + hasFeedback: !!feedback, + }).toToolResult(); + } + } + + // Write the modified content back + await Bun.write(resolvedPath, content); + + // Build diff display blocks for result + const resultDiffBlocks = buildWireDiffBlocks( + resolvedPath, + originalContent, + content, + ); + + // Count changes for success message + let totalReplacements = 0; + for (const edit of edits) { + if (edit.replace_all) { + totalReplacements += originalContent.split(edit.old).length - 1; + } else { + totalReplacements += originalContent.includes(edit.old) ? 1 : 0; + } + } + + return { + isError: false, + output: "", + message: `File successfully edited. Applied ${edits.length} edit(s) with ${totalReplacements} total replacement(s).`, + display: resultDiffBlocks, + }; + } catch (e) { + return ToolError(`Failed to edit. Error: ${e}`); + } + } } diff --git a/src/kimi_cli_ts/tools/file/write.ts b/src/kimi_cli_ts/tools/file/write.ts index db8b0c7b4..c09802c34 100644 --- a/src/kimi_cli_ts/tools/file/write.ts +++ b/src/kimi_cli_ts/tools/file/write.ts @@ -3,13 +3,13 @@ * Corresponds to Python tools/file/write.py */ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { z } from "zod/v4"; +import { buildWireDiffBlocks } from "../../utils/diff.ts"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; import { ToolError, ToolRejectedError } from "../types.ts"; import { inspectPlanEditTarget } from "./plan_mode.ts"; -import type { DiffDisplayBlock } from "../display.ts"; const DESCRIPTION = `Write content to a file. @@ -18,172 +18,163 @@ const DESCRIPTION = `Write content to a file. - When the content to write is too long (e.g. > 100 lines), use this tool multiple times instead of a single call. Use \`overwrite\` mode at the first time, then use \`append\` mode after the first write.`; const ParamsSchema = z.object({ - path: z.string().describe( - "The path to the file to write. Absolute paths are required when writing files outside the working directory.", - ), - content: z.string().describe("The content to write to the file"), - mode: z - .enum(["overwrite", "append"]) - .default("overwrite") - .describe("The mode to use: `overwrite` or `append`."), + path: z + .string() + .describe( + "The path to the file to write. Absolute paths are required when writing files outside the working directory.", + ), + content: z.string().describe("The content to write to the file"), + mode: z + .enum(["overwrite", "append"]) + .default("overwrite") + .describe("The mode to use: `overwrite` or `append`."), }); type Params = z.infer; function resolvePath(filePath: string, workingDir: string): string { - if (filePath.startsWith("/") || filePath.startsWith("~")) { - if (filePath.startsWith("~")) { - const home = process.env.HOME || process.env.USERPROFILE || ""; - return filePath.replace(/^~/, home); - } - return filePath; - } - return resolve(workingDir, filePath); -} - -/** Build a simple unified diff for display. */ -function buildSimpleDiff(oldContent: string, newContent: string, path: string): string { - const oldLines = oldContent.split("\n"); - const newLines = newContent.split("\n"); - const maxPreview = 50; - const diffLines: string[] = [`--- a/${path}`, `+++ b/${path}`]; - - let shown = 0; - const maxLen = Math.max(oldLines.length, newLines.length); - for (let i = 0; i < maxLen && shown < maxPreview; i++) { - const oldLine = oldLines[i]; - const newLine = newLines[i]; - if (oldLine !== newLine) { - if (oldLine !== undefined) { - diffLines.push(`-${oldLine}`); - shown++; - } - if (newLine !== undefined) { - diffLines.push(`+${newLine}`); - shown++; - } - } - } - - if (shown >= maxPreview) { - diffLines.push(`... (diff truncated, ${maxLen} total lines)`); - } - - return diffLines.join("\n"); + if (filePath.startsWith("/") || filePath.startsWith("~")) { + if (filePath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return filePath.replace(/^~/, home); + } + return filePath; + } + return resolve(workingDir, filePath); } export class WriteFile extends CallableTool { - readonly name = "WriteFile"; - readonly description = DESCRIPTION; - readonly schema = ParamsSchema; - - /** Optional plan mode bindings. */ - private _planModeChecker?: () => boolean; - private _planFilePathGetter?: () => string | null; - - /** Bind plan mode state checker and plan file path getter. */ - bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { - this._planModeChecker = checker; - this._planFilePathGetter = pathGetter; - } - - async execute(params: Params, ctx: ToolContext): Promise { - if (!params.path) { - return ToolError("File path cannot be empty."); - } - - try { - const resolvedPath = resolvePath(params.path, ctx.workingDir); - - // Check plan mode restrictions - const planTarget = inspectPlanEditTarget(resolvedPath, { - planModeChecker: this._planModeChecker ?? ctx.getPlanMode, - planFilePathGetter: this._planFilePathGetter, - }); - if ("isError" in planTarget && planTarget.isError) { - return planTarget; - } - const isPlanFileWrite = !("isError" in planTarget) && planTarget.isPlanTarget; - - // Ensure parent directory for plan file writes - if (isPlanFileWrite && !("isError" in planTarget) && planTarget.planPath) { - const { mkdirSync } = await import("node:fs"); - mkdirSync(dirname(planTarget.planPath), { recursive: true }); - } - - // Check if parent directory exists - const parentDir = dirname(resolvedPath); - const { stat: fsStat, mkdir } = await import("node:fs/promises"); - try { - const parentInfo = await fsStat(parentDir); - if (!parentInfo.isDirectory()) { - return ToolError(`Parent path \`${parentDir}\` exists but is not a directory.`); - } - } catch (err: any) { - if (err?.code === "ENOENT") { - await mkdir(parentDir, { recursive: true }); - } else { - return ToolError(`Cannot access parent directory \`${parentDir}\`: ${err?.message}`); - } - } - - const file = Bun.file(resolvedPath); - const fileExisted = await file.exists(); - - // Build diff for approval display - let diffPreview = ""; - if (fileExisted) { - try { - const oldContent = await file.text(); - const newContent = params.mode === "append" ? oldContent + params.content : params.content; - diffPreview = buildSimpleDiff(oldContent, newContent, params.path); - } catch { - // Can't read old file — skip diff - } - } - - // Plan file writes are auto-approved; other writes need approval - if (!isPlanFileWrite) { - const approvalSummary = fileExisted - ? `${params.mode === "append" ? "Append to" : "Overwrite"} file \`${params.path}\`${diffPreview ? `\n${diffPreview}` : ""}` - : `Create file \`${params.path}\` (${params.content.length} chars)`; - - const { decision, feedback } = await ctx.approval( - "WriteFile", - fileExisted ? "edit" : "create", - approvalSummary, - ); - if (decision === "reject") { - return new ToolRejectedError({ - message: feedback - ? `The tool call is rejected by the user. User feedback: ${feedback}` - : undefined, - brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", - hasFeedback: !!feedback, - }).toToolResult(); - } - } - - if (params.mode === "append" && fileExisted) { - const { appendFile } = await import("node:fs/promises"); - await appendFile(resolvedPath, params.content, "utf-8"); - } else { - await Bun.write(resolvedPath, params.content); - } - - const newFile = Bun.file(resolvedPath); - const fileSize = newFile.size; - const action = - params.mode === "overwrite" - ? (fileExisted ? "overwritten" : "created") - : "appended to"; - return { - isError: false, - output: "", - message: `File successfully ${action}. Current size: ${fileSize} bytes.`, - }; - } catch (e) { - return ToolError(`Failed to write to ${params.path}. Error: ${e}`); - } - } + readonly name = "WriteFile"; + readonly description = DESCRIPTION; + readonly schema = ParamsSchema; + + /** Optional plan mode bindings. */ + private _planModeChecker?: () => boolean; + private _planFilePathGetter?: () => string | null; + + /** Bind plan mode state checker and plan file path getter. */ + bindPlanMode(checker: () => boolean, pathGetter: () => string | null): void { + this._planModeChecker = checker; + this._planFilePathGetter = pathGetter; + } + + async execute(params: Params, ctx: ToolContext): Promise { + if (!params.path) { + return ToolError("File path cannot be empty."); + } + + try { + const resolvedPath = resolvePath(params.path, ctx.workingDir); + + // Check plan mode restrictions + const planTarget = inspectPlanEditTarget(resolvedPath, { + planModeChecker: this._planModeChecker ?? ctx.getPlanMode, + planFilePathGetter: this._planFilePathGetter, + }); + if ("isError" in planTarget && planTarget.isError) { + return planTarget; + } + const isPlanFileWrite = + !("isError" in planTarget) && planTarget.isPlanTarget; + + // Ensure parent directory for plan file writes + if ( + isPlanFileWrite && + !("isError" in planTarget) && + planTarget.planPath + ) { + const { mkdirSync } = await import("node:fs"); + mkdirSync(dirname(planTarget.planPath), { recursive: true }); + } + + // Check if parent directory exists + const parentDir = dirname(resolvedPath); + const { stat: fsStat, mkdir } = await import("node:fs/promises"); + try { + const parentInfo = await fsStat(parentDir); + if (!parentInfo.isDirectory()) { + return ToolError( + `Parent path \`${parentDir}\` exists but is not a directory.`, + ); + } + } catch (err: any) { + if (err?.code === "ENOENT") { + await mkdir(parentDir, { recursive: true }); + } else { + return ToolError( + `Cannot access parent directory \`${parentDir}\`: ${err?.message}`, + ); + } + } + + const file = Bun.file(resolvedPath); + const fileExisted = await file.exists(); + + // Read old content and compute new content for diff + let oldContent = ""; + if (fileExisted) { + try { + oldContent = await file.text(); + } catch { + // Can't read old file — skip diff + } + } + const newContent = + params.mode === "append" ? oldContent + params.content : params.content; + + // Build diff display blocks (matches Python's build_diff_blocks) + const diffBlocks = buildWireDiffBlocks( + resolvedPath, + oldContent, + newContent, + ); + + // Plan file writes are auto-approved; other writes need approval + if (!isPlanFileWrite) { + const approvalSummary = fileExisted + ? `${params.mode === "append" ? "Append to" : "Overwrite"} file \`${params.path}\`` + : `Create file \`${params.path}\` (${params.content.length} chars)`; + + const { decision, feedback } = await ctx.approval( + "WriteFile", + fileExisted ? "edit" : "create", + approvalSummary, + { display: diffBlocks }, + ); + if (decision === "reject") { + return new ToolRejectedError({ + message: feedback + ? `The tool call is rejected by the user. User feedback: ${feedback}` + : undefined, + brief: feedback ? `Rejected: ${feedback}` : "Rejected by user", + hasFeedback: !!feedback, + }).toToolResult(); + } + } + + if (params.mode === "append" && fileExisted) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(resolvedPath, params.content, "utf-8"); + } else { + await Bun.write(resolvedPath, params.content); + } + + const newFile = Bun.file(resolvedPath); + const fileSize = newFile.size; + const action = + params.mode === "overwrite" + ? fileExisted + ? "overwritten" + : "created" + : "appended to"; + return { + isError: false, + output: "", + message: `File successfully ${action}. Current size: ${fileSize} bytes.`, + display: diffBlocks, + }; + } catch (e) { + return ToolError(`Failed to write to ${params.path}. Error: ${e}`); + } + } } diff --git a/src/kimi_cli_ts/tools/index.ts b/src/kimi_cli_ts/tools/index.ts new file mode 100644 index 000000000..7db47065c --- /dev/null +++ b/src/kimi_cli_ts/tools/index.ts @@ -0,0 +1,10 @@ +/** + * Tools barrel export. + * Corresponds to Python tools/__init__.py + */ + +export { SkipThisTool, extractKeyArgument } from "./types.ts"; +export { CallableTool } from "./base.ts"; +export { ToolRegistry } from "./registry.ts"; +export type { ToolContext, ToolResult, ToolDefinition } from "./types.ts"; +export { ToolOk, ToolError, ToolResultBuilder, ToolRejectedError } from "./types.ts"; diff --git a/src/kimi_cli_ts/tools/plan/enter.ts b/src/kimi_cli_ts/tools/plan/enter.ts new file mode 100644 index 000000000..81208a422 --- /dev/null +++ b/src/kimi_cli_ts/tools/plan/enter.ts @@ -0,0 +1,124 @@ +/** + * EnterPlanMode tool — lets the LLM request to enter plan mode. + * Corresponds to Python tools/plan/enter.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "../base.ts"; +import type { ToolContext, ToolResult } from "../types.ts"; +import { ToolError, ToolOk } from "../types.ts"; +import { getPlanFilePath } from "./heroes.ts"; + +const ENTER_DESCRIPTION = `Use this tool proactively when you're about to start a non-trivial implementation task. +Getting user sign-off on your approach before writing code prevents wasted effort. + +Use it when ANY of these conditions apply: +1. New Feature Implementation +2. Multiple Valid Approaches +3. Code Modifications +4. Architectural Decisions +5. Multi-File Changes +6. Unclear Requirements +7. User Preferences Matter + +When NOT to use: +- Single-line or few-line fixes +- User gave very specific, detailed instructions +- Pure research/exploration tasks`; + +const EnterParamsSchema = z.object({}); + +export class EnterPlanMode extends CallableTool { + readonly name = "EnterPlanMode"; + readonly description = ENTER_DESCRIPTION; + readonly schema = EnterParamsSchema; + + /** Session ID for plan file management. */ + private _sessionId: string; + private _isYolo: (() => boolean) | null = null; + + constructor(sessionId = "default") { + super(); + this._sessionId = sessionId; + } + + /** Bind optional YOLO mode checker. */ + bindYolo(isYolo: () => boolean): void { + this._isYolo = isYolo; + } + + async execute(_params: unknown, ctx: ToolContext): Promise { + // Guard: already in plan mode + if (ctx.getPlanMode?.()) { + return ToolError( + "Already in plan mode. Use ExitPlanMode when your plan is ready.", + ); + } + + const planPath = getPlanFilePath(this._sessionId); + + // In YOLO mode, auto-approve entering plan mode + if (this._isYolo?.()) { + ctx.setPlanMode?.(true); + return ToolOk( + `Plan mode activated (auto-approved in non-interactive mode).\n` + + `Plan file: ${planPath}\n` + + `Workflow: identify key questions about the codebase → ` + + `use Agent(subagent_type='explore') to investigate if needed → ` + + `design approach → ` + + `modify the plan file with WriteFile or StrReplaceFile ` + + `(create it with WriteFile first if it does not exist) → ` + + `call ExitPlanMode.\n`, + "Plan mode on (auto)", + ); + } + + // In interactive mode, ask user for confirmation + if (ctx.askUser) { + try { + const answer = await ctx.askUser("Enter plan mode?", ["Yes", "No"]); + + if (answer === "Yes") { + ctx.setPlanMode?.(true); + return ToolOk( + `Plan mode activated. You MUST NOT edit code files — only read and plan.\n` + + `Plan file: ${planPath}\n` + + `Workflow: identify key questions about the codebase → ` + + `use Agent(subagent_type='explore') to investigate if needed → ` + + `design approach → ` + + `modify the plan file with WriteFile or StrReplaceFile ` + + `(create it with WriteFile first if it does not exist) → ` + + `call ExitPlanMode.\n` + + `Use AskUserQuestion only to clarify missing requirements or choose ` + + `between approaches.\n` + + `Do NOT use AskUserQuestion to ask about plan approval.`, + "Plan mode on", + ); + } else { + return ToolOk( + "User declined to enter plan mode. Please check with user whether " + + "to proceed with implementation directly.", + "Declined", + ); + } + } catch { + // askUser not supported, fall through to auto-enter + } + } + + // Fallback: auto-enter plan mode + ctx.setPlanMode?.(true); + return ToolOk( + "Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.\n" + + "In plan mode, you should:\n" + + "1. Thoroughly explore the codebase to understand existing patterns\n" + + "2. Consider multiple approaches and their trade-offs\n" + + "3. Design a concrete implementation strategy\n" + + `4. Write your plan to: ${planPath}\n` + + "5. When ready, use ExitPlanMode to present your plan for approval\n" + + "\n" + + "Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.", + "Plan mode activated.", + ); + } +} diff --git a/src/kimi_cli_ts/tools/plan/plan.ts b/src/kimi_cli_ts/tools/plan/index.ts similarity index 61% rename from src/kimi_cli_ts/tools/plan/plan.ts rename to src/kimi_cli_ts/tools/plan/index.ts index 769f60ff6..bc42dd373 100644 --- a/src/kimi_cli_ts/tools/plan/plan.ts +++ b/src/kimi_cli_ts/tools/plan/index.ts @@ -1,131 +1,16 @@ /** - * Plan mode tools — lets the LLM enter/exit plan mode. - * Corresponds to Python tools/plan/enter.py and tools/plan/__init__.py + * ExitPlanMode tool — lets the LLM submit a plan for user approval. + * Corresponds to Python tools/plan/__init__.py */ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; import { z } from "zod/v4"; import { CallableTool } from "../base.ts"; import type { ToolContext, ToolResult } from "../types.ts"; import { ToolError, ToolOk } from "../types.ts"; import { getPlanFilePath, readPlanFile } from "./heroes.ts"; -// ── EnterPlanMode ────────────────────────────────── - -const ENTER_DESCRIPTION = `Use this tool proactively when you're about to start a non-trivial implementation task. -Getting user sign-off on your approach before writing code prevents wasted effort. - -Use it when ANY of these conditions apply: -1. New Feature Implementation -2. Multiple Valid Approaches -3. Code Modifications -4. Architectural Decisions -5. Multi-File Changes -6. Unclear Requirements -7. User Preferences Matter - -When NOT to use: -- Single-line or few-line fixes -- User gave very specific, detailed instructions -- Pure research/exploration tasks`; - -const EnterParamsSchema = z.object({}); - -export class EnterPlanMode extends CallableTool { - readonly name = "EnterPlanMode"; - readonly description = ENTER_DESCRIPTION; - readonly schema = EnterParamsSchema; - - /** Session ID for plan file management. */ - private _sessionId: string; - private _isYolo: (() => boolean) | null = null; - - constructor(sessionId = "default") { - super(); - this._sessionId = sessionId; - } - - /** Bind optional YOLO mode checker. */ - bindYolo(isYolo: () => boolean): void { - this._isYolo = isYolo; - } - - async execute(_params: unknown, ctx: ToolContext): Promise { - // Guard: already in plan mode - if (ctx.getPlanMode?.()) { - return ToolError( - "Already in plan mode. Use ExitPlanMode when your plan is ready.", - ); - } - - const planPath = getPlanFilePath(this._sessionId); - - // In YOLO mode, auto-approve entering plan mode - if (this._isYolo?.()) { - ctx.setPlanMode?.(true); - return ToolOk( - `Plan mode activated (auto-approved in non-interactive mode).\n` + - `Plan file: ${planPath}\n` + - `Workflow: identify key questions about the codebase → ` + - `use Agent(subagent_type='explore') to investigate if needed → ` + - `design approach → ` + - `modify the plan file with WriteFile or StrReplaceFile ` + - `(create it with WriteFile first if it does not exist) → ` + - `call ExitPlanMode.\n`, - "Plan mode on (auto)", - ); - } - - // In interactive mode, ask user for confirmation - if (ctx.askUser) { - try { - const answer = await ctx.askUser("Enter plan mode?", ["Yes", "No"]); - - if (answer === "Yes") { - ctx.setPlanMode?.(true); - return ToolOk( - `Plan mode activated. You MUST NOT edit code files — only read and plan.\n` + - `Plan file: ${planPath}\n` + - `Workflow: identify key questions about the codebase → ` + - `use Agent(subagent_type='explore') to investigate if needed → ` + - `design approach → ` + - `modify the plan file with WriteFile or StrReplaceFile ` + - `(create it with WriteFile first if it does not exist) → ` + - `call ExitPlanMode.\n` + - `Use AskUserQuestion only to clarify missing requirements or choose ` + - `between approaches.\n` + - `Do NOT use AskUserQuestion to ask about plan approval.`, - "Plan mode on", - ); - } else { - return ToolOk( - "User declined to enter plan mode. Please check with user whether " + - "to proceed with implementation directly.", - "Declined", - ); - } - } catch { - // askUser not supported, fall through to auto-enter - } - } - - // Fallback: auto-enter plan mode - ctx.setPlanMode?.(true); - return ToolOk( - "Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.\n" + - "In plan mode, you should:\n" + - "1. Thoroughly explore the codebase to understand existing patterns\n" + - "2. Consider multiple approaches and their trade-offs\n" + - "3. Design a concrete implementation strategy\n" + - `4. Write your plan to: ${planPath}\n` + - "5. When ready, use ExitPlanMode to present your plan for approval\n" + - "\n" + - "Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.", - "Plan mode activated.", - ); - } -} +// Re-export EnterPlanMode from enter.ts +export { EnterPlanMode } from "./enter.ts"; // ── ExitPlanMode ────────────────────────────────── diff --git a/src/kimi_cli_ts/tools/shell/shell.ts b/src/kimi_cli_ts/tools/shell/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/shell/shell.ts rename to src/kimi_cli_ts/tools/shell/index.ts diff --git a/src/kimi_cli_ts/tools/test.ts b/src/kimi_cli_ts/tools/test.ts new file mode 100644 index 000000000..358eff6a3 --- /dev/null +++ b/src/kimi_cli_ts/tools/test.ts @@ -0,0 +1,78 @@ +/** + * Test tools — simple tools for testing purposes. + * Corresponds to Python tools/test.py + */ + +import { z } from "zod/v4"; +import { CallableTool } from "./base.ts"; +import type { ToolContext, ToolResult } from "./types.ts"; +import { ToolOk } from "./types.ts"; + +// ── Plus ────────────────────────────────────────── + +const PlusParamsSchema = z.object({ + a: z.number(), + b: z.number(), +}); + +export class Plus extends CallableTool { + readonly name = "plus"; + readonly description = "Add two numbers"; + readonly schema = PlusParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + return ToolOk(String(params.a + params.b)); + } +} + +// ── Compare ─────────────────────────────────────── + +const CompareParamsSchema = z.object({ + a: z.number(), + b: z.number(), +}); + +export class Compare extends CallableTool { + readonly name = "compare"; + readonly description = "Compare two numbers"; + readonly schema = CompareParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + if (params.a > params.b) { + return ToolOk("greater"); + } else if (params.a < params.b) { + return ToolOk("less"); + } else { + return ToolOk("equal"); + } + } +} + +// ── Panic ───────────────────────────────────────── + +const PanicParamsSchema = z.object({ + message: z.string(), +}); + +export class Panic extends CallableTool { + readonly name = "panic"; + readonly description = + "Raise an exception to cause the tool call to fail."; + readonly schema = PanicParamsSchema; + + async execute( + params: z.infer, + _ctx: ToolContext, + ): Promise { + await Bun.sleep(2000); + throw new Error( + `panicked with a message with ${params.message.length} characters`, + ); + } +} diff --git a/src/kimi_cli_ts/tools/think/think.ts b/src/kimi_cli_ts/tools/think/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/think/think.ts rename to src/kimi_cli_ts/tools/think/index.ts diff --git a/src/kimi_cli_ts/tools/todo/todo.ts b/src/kimi_cli_ts/tools/todo/index.ts similarity index 100% rename from src/kimi_cli_ts/tools/todo/todo.ts rename to src/kimi_cli_ts/tools/todo/index.ts diff --git a/src/kimi_cli_ts/tools/web/index.ts b/src/kimi_cli_ts/tools/web/index.ts new file mode 100644 index 000000000..fb6397257 --- /dev/null +++ b/src/kimi_cli_ts/tools/web/index.ts @@ -0,0 +1,7 @@ +/** + * Web tools barrel export. + * Corresponds to Python tools/web/__init__.py + */ + +export { FetchURL } from "./fetch.ts"; +export { SearchWeb } from "./search.ts"; diff --git a/src/kimi_cli_ts/ui/shell/Shell.tsx b/src/kimi_cli_ts/ui/shell/Shell.tsx index f7fd67105..19821970b 100644 --- a/src/kimi_cli_ts/ui/shell/Shell.tsx +++ b/src/kimi_cli_ts/ui/shell/Shell.tsx @@ -15,7 +15,7 @@ import { WelcomeBox } from "../components/WelcomeBox.tsx"; import { StatusBar } from "../components/StatusBar.tsx"; import { ApprovalPanel } from "./ApprovalPanel.tsx"; import { QuestionPanel } from "./QuestionPanel.tsx"; -import { resolveQuestionRequest, rejectQuestionRequest } from "../../tools/ask_user/ask_user.ts"; +import { resolveQuestionRequest, rejectQuestionRequest } from "../../tools/ask_user/index.ts"; import { ChoicePanel, ContentPanel } from "../components/CommandPanel.tsx"; import { DebugPanel } from "./DebugPanel.tsx"; import { SlashMenu } from "../components/SlashMenu.tsx"; diff --git a/src/kimi_cli_ts/ui/shell/UsagePanel.tsx b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx index d59fc1437..58c2fb754 100644 --- a/src/kimi_cli_ts/ui/shell/UsagePanel.tsx +++ b/src/kimi_cli_ts/ui/shell/UsagePanel.tsx @@ -8,27 +8,27 @@ * Uses PanelShell for borders and usePanelKeyboard for Esc dismiss. */ -import React from "react"; import { Text } from "ink"; -import { PanelShell, PanelRow } from "../components/PanelShell.tsx"; +import type React from "react"; +import { PanelRow, PanelShell } from "../components/PanelShell.tsx"; import { usePanelKeyboard } from "../hooks/usePanelKeyboard.ts"; const BAR_BG_COLOR = "#3a3a3a"; // Rich color 237 const HINT_COLOR = "#808080"; // Rich grey50 / color 244 export interface UsageRow { - label: string; - used: number; - limit: number; - resetHint?: string | null; + label: string; + used: number; + limit: number; + resetHint?: string | null; } export interface UsagePanelProps { - summary?: UsageRow | null; - limits: UsageRow[]; - loading?: boolean; - error?: string | null; - onClose?: () => void; + summary?: UsageRow | null; + limits: UsageRow[]; + loading?: boolean; + error?: string | null; + onClose?: () => void; } /** @@ -36,270 +36,308 @@ export interface UsagePanelProps { * Exactly matches Python's rich.progress_bar.ProgressBar.__rich_console__. */ function RichProgressBar({ - completed, - total, - width = 20, + completed, + total, + width = 20, }: { - completed: number; - total: number; - width?: number; + completed: number; + total: number; + width?: number; }) { - const remainingRatio = total > 0 ? (total - completed) / total : 0; - const color = ratioColor(remainingRatio); - - if (completed <= 0) { - return {"\u2501".repeat(width)}; - } - if (completed >= total) { - return {"\u2501".repeat(width)}; - } - - const completeHalves = Math.floor(width * 2 * completed / total); - const barCount = Math.floor(completeHalves / 2); - const halfBarCount = completeHalves % 2; - - const parts: React.ReactNode[] = []; - - if (barCount > 0) { - parts.push({"\u2501".repeat(barCount)}); - } - - let remainingBars = width - barCount - halfBarCount; - - if (halfBarCount > 0) { - parts.push({"\u2578"}); - } - - if (remainingBars > 0) { - if (halfBarCount === 0 && barCount > 0) { - parts.push({"\u257A"}); - remainingBars -= 1; - } - if (remainingBars > 0) { - parts.push({"\u2501".repeat(remainingBars)}); - } - } - - return {parts}; + const remainingRatio = total > 0 ? (total - completed) / total : 0; + const color = ratioColor(remainingRatio); + + if (completed <= 0) { + return {"\u2501".repeat(width)}; + } + if (completed >= total) { + return {"\u2501".repeat(width)}; + } + + const completeHalves = Math.floor((width * 2 * completed) / total); + const barCount = Math.floor(completeHalves / 2); + const halfBarCount = completeHalves % 2; + + const parts: React.ReactNode[] = []; + + if (barCount > 0) { + parts.push( + + {"\u2501".repeat(barCount)} + , + ); + } + + let remainingBars = width - barCount - halfBarCount; + + if (halfBarCount > 0) { + parts.push( + + {"\u2578"} + , + ); + } + + if (remainingBars > 0) { + if (halfBarCount === 0 && barCount > 0) { + parts.push( + + {"\u257A"} + , + ); + remainingBars -= 1; + } + if (remainingBars > 0) { + parts.push( + + {"\u2501".repeat(remainingBars)} + , + ); + } + } + + return {parts}; } function ratioColor(ratio: number): string { - if (ratio >= 0.9) return "red"; - if (ratio >= 0.7) return "yellow"; - return "green"; + if (ratio >= 0.9) return "red"; + if (ratio >= 0.7) return "yellow"; + return "green"; } -function UsageRowView({ row, labelWidth, contentWidth }: { - row: UsageRow; - labelWidth: number; - contentWidth: number; +function UsageRowView({ + row, + labelWidth, + contentWidth, +}: { + row: UsageRow; + labelWidth: number; + contentWidth: number; }) { - const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; - const percent = remaining * 100; - - const pctText = ` ${percent.toFixed(0)}% left`; - const hintText = row.resetHint ? ` (${row.resetHint})` : ""; - const labelText = row.label.padEnd(labelWidth) + " "; - - const usedWidth = labelText.length + 20 + pctText.length + hintText.length; - const rightPad = Math.max(0, contentWidth - usedWidth); - - return ( - - {labelText} - - {pctText} - {hintText && {hintText}} - {rightPad > 0 && {" ".repeat(rightPad)}} - - ); + const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; + const percent = remaining * 100; + + const pctText = ` ${percent.toFixed(0)}% left`; + const hintText = row.resetHint ? ` (${row.resetHint})` : ""; + const labelText = row.label.padEnd(labelWidth) + " "; + + const usedWidth = labelText.length + 20 + pctText.length + hintText.length; + const rightPad = Math.max(0, contentWidth - usedWidth); + + return ( + + {labelText} + + {pctText} + {hintText && {hintText}} + {rightPad > 0 && {" ".repeat(rightPad)}} + + ); } -export function UsagePanel({ summary, limits, loading, error, onClose }: UsagePanelProps) { - // Keyboard: only Esc to close - usePanelKeyboard({ - onEscape: () => onClose?.(), - }); - - if (loading) { - const loadingText = "Fetching usage..."; - return ( - - - {loadingText} - - - ); - } - - if (error) { - return ( - - - {error} - - - ); - } - - const rows = [...(summary ? [summary] : []), ...limits]; - if (rows.length === 0) { - const noData = "No usage data"; - return ( - - - {noData} - - - ); - } - - const labelWidth = Math.max(6, ...rows.map((r) => r.label.length)); - - const contentWidth = Math.max(...rows.map((row) => { - const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; - const pctText = ` ${(remaining * 100).toFixed(0)}% left`; - const hintText = row.resetHint ? ` (${row.resetHint})` : ""; - const labelText = row.label.padEnd(labelWidth) + " "; - return labelText.length + 20 + pctText.length + hintText.length; - })); - - return ( - - {rows.map((row, idx) => ( - - - - ))} - - ); +export function UsagePanel({ + summary, + limits, + loading, + error, + onClose, +}: UsagePanelProps) { + // Keyboard: only Esc to close + usePanelKeyboard({ + onEscape: () => onClose?.(), + }); + + if (loading) { + const loadingText = "Fetching usage..."; + return ( + + + {loadingText} + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + const rows = [...(summary ? [summary] : []), ...limits]; + if (rows.length === 0) { + const noData = "No usage data"; + return ( + + + {noData} + + + ); + } + + const labelWidth = Math.max(6, ...rows.map((r) => r.label.length)); + + const contentWidth = Math.max( + ...rows.map((row) => { + const remaining = row.limit > 0 ? (row.limit - row.used) / row.limit : 0; + const pctText = ` ${(remaining * 100).toFixed(0)}% left`; + const hintText = row.resetHint ? ` (${row.resetHint})` : ""; + const labelText = row.label.padEnd(labelWidth) + " "; + return labelText.length + 20 + pctText.length + hintText.length; + }), + ); + + return ( + + {rows.map((row, idx) => ( + + + + ))} + + ); } // ── Usage data parsing helpers ────────────────────────── export function parseUsagePayload(payload: Record): { - summary: UsageRow | null; - limits: UsageRow[]; + summary: UsageRow | null; + limits: UsageRow[]; } { - let summary: UsageRow | null = null; - const limits: UsageRow[] = []; - - const usage = payload.usage; - if (usage && typeof usage === "object") { - summary = toUsageRow(usage as Record, "Weekly limit"); - } - - const rawLimits = payload.limits; - if (Array.isArray(rawLimits)) { - for (let idx = 0; idx < rawLimits.length; idx++) { - const item = rawLimits[idx]; - if (!item || typeof item !== "object") continue; - const itemMap = item as Record; - const detail = - itemMap.detail && typeof itemMap.detail === "object" - ? (itemMap.detail as Record) - : itemMap; - const window = - itemMap.window && typeof itemMap.window === "object" - ? (itemMap.window as Record) - : {}; - const label = limitLabel(itemMap, detail, window, idx); - const row = toUsageRow(detail, label); - if (row) limits.push(row); - } - } - - return { summary, limits }; + let summary: UsageRow | null = null; + const limits: UsageRow[] = []; + + const usage = payload.usage; + if (usage && typeof usage === "object") { + summary = toUsageRow(usage as Record, "Weekly limit"); + } + + const rawLimits = payload.limits; + if (Array.isArray(rawLimits)) { + for (let idx = 0; idx < rawLimits.length; idx++) { + const item = rawLimits[idx]; + if (!item || typeof item !== "object") continue; + const itemMap = item as Record; + const detail = + itemMap.detail && typeof itemMap.detail === "object" + ? (itemMap.detail as Record) + : itemMap; + const window = + itemMap.window && typeof itemMap.window === "object" + ? (itemMap.window as Record) + : {}; + const label = limitLabel(itemMap, detail, window, idx); + const row = toUsageRow(detail, label); + if (row) limits.push(row); + } + } + + return { summary, limits }; } function toUsageRow( - data: Record, - defaultLabel: string, + data: Record, + defaultLabel: string, ): UsageRow | null { - const limit = toInt(data.limit); - let used = toInt(data.used); - if (used == null) { - const remaining = toInt(data.remaining); - if (remaining != null && limit != null) { - used = limit - remaining; - } - } - if (used == null && limit == null) return null; - return { - label: String(data.name || data.title || defaultLabel), - used: used || 0, - limit: limit || 0, - resetHint: resetHint(data), - }; + const limit = toInt(data.limit); + let used = toInt(data.used); + if (used == null) { + const remaining = toInt(data.remaining); + if (remaining != null && limit != null) { + used = limit - remaining; + } + } + if (used == null && limit == null) return null; + return { + label: String(data.name || data.title || defaultLabel), + used: used || 0, + limit: limit || 0, + resetHint: resetHint(data), + }; } function limitLabel( - item: Record, - detail: Record, - window: Record, - idx: number, + item: Record, + detail: Record, + window: Record, + idx: number, ): string { - for (const key of ["name", "title", "scope"]) { - const val = item[key] || detail[key]; - if (val) return String(val); - } - const duration = toInt( - window.duration || item.duration || detail.duration, - ); - const timeUnit = String( - window.timeUnit || item.timeUnit || detail.timeUnit || "", - ); - if (duration) { - if (timeUnit.includes("MINUTE")) { - if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`; - return `${duration}m limit`; - } - if (timeUnit.includes("HOUR")) return `${duration}h limit`; - if (timeUnit.includes("DAY")) return `${duration}d limit`; - return `${duration}s limit`; - } - return `Limit #${idx + 1}`; + for (const key of ["name", "title", "scope"]) { + const val = item[key] || detail[key]; + if (val) return String(val); + } + const duration = toInt(window.duration || item.duration || detail.duration); + const timeUnit = String( + window.timeUnit || item.timeUnit || detail.timeUnit || "", + ); + if (duration) { + if (timeUnit.includes("MINUTE")) { + if (duration >= 60 && duration % 60 === 0) + return `${duration / 60}h limit`; + return `${duration}m limit`; + } + if (timeUnit.includes("HOUR")) return `${duration}h limit`; + if (timeUnit.includes("DAY")) return `${duration}d limit`; + return `${duration}s limit`; + } + return `Limit #${idx + 1}`; } function resetHint(data: Record): string | null { - for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { - if (data[key]) return formatResetTime(String(data[key])); - } - for (const key of ["reset_in", "resetIn", "ttl", "window"]) { - const seconds = toInt(data[key]); - if (seconds) return `resets in ${formatDuration(seconds)}`; - } - return null; + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + if (data[key]) return formatResetTime(String(data[key])); + } + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = toInt(data[key]); + if (seconds) return `resets in ${formatDuration(seconds)}`; + } + return null; } function formatResetTime(val: string): string { - try { - const dt = new Date(val); - const now = Date.now(); - const delta = dt.getTime() - now; - if (delta <= 0) return "reset"; - return `resets in ${formatDuration(Math.floor(delta / 1000))}`; - } catch { - return `resets at ${val}`; - } + try { + const dt = new Date(val); + const now = Date.now(); + const delta = dt.getTime() - now; + if (delta <= 0) return "reset"; + return `resets in ${formatDuration(Math.floor(delta / 1000))}`; + } catch { + return `resets at ${val}`; + } } function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) { + const parts = [`${days}d`]; + if (hours > 0) parts.push(`${hours}h`); + if (mins > 0) parts.push(`${mins}m`); + return parts.join(" "); + } + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } function toInt(value: unknown): number | null { - if (value == null) return null; - const n = Number(value); - return Number.isFinite(n) ? Math.floor(n) : null; + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? Math.floor(n) : null; } export default UsagePanel; diff --git a/src/kimi_cli_ts/ui/shell/Visualize.tsx b/src/kimi_cli_ts/ui/shell/Visualize.tsx index 4c166a6af..cbfada2cd 100644 --- a/src/kimi_cli_ts/ui/shell/Visualize.tsx +++ b/src/kimi_cli_ts/ui/shell/Visualize.tsx @@ -395,11 +395,13 @@ function DisplayBlockView({ block, isError }: DisplayBlockViewProps) { return {b.brief as string}; case "diff": return ( - ); @@ -989,14 +991,15 @@ function EnhancedDiffView({ const maxLineNum = Math.max(oldStart + oldLines.length, newStart + newLines.length); const lineNumWidth = String(maxLineNum).length; + // Build title: "+N path" for new file, "path" for edits + const isNewFile = oldLines.length === 0 && newLines.length > 0; + const title = isNewFile + ? ` +${newLines.length} ${block.path} ` + : ` ${block.path} `; + return ( - - - {block.path} - - - @@ -{oldStart},{oldLines.length} +{newStart},{newLines.length} @@ - + + {title} {oldLines.map((line, idx) => ( {String(oldStart + idx).padStart(lineNumWidth)} - {line} diff --git a/src/kimi_cli_ts/ui/shell/commands/export_import.ts b/src/kimi_cli_ts/ui/shell/commands/export_import.ts index c830b480e..94b7cac9d 100644 --- a/src/kimi_cli_ts/ui/shell/commands/export_import.ts +++ b/src/kimi_cli_ts/ui/shell/commands/export_import.ts @@ -1,110 +1,145 @@ -import type { Context } from "../../../soul/context.ts"; +import { statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { Session } from "../../../session.ts"; +import type { Context } from "../../../soul/context.ts"; import type { ContentPart } from "../../../types.ts"; -import { join } from "node:path"; -import { homedir } from "node:os"; -export async function handleExport(context: Context, session: Session, args: string): Promise { - const history = context.history; - if (!history.length) { - return "Nothing to export - context is empty."; - } +export async function handleExport( + context: Context, + session: Session, + args: string, +): Promise { + const history = context.history; + if (!history.length) { + return "Nothing to export - context is empty."; + } + + // Determine output path (matches Python's perform_export logic) + const cleaned = args.trim(); + let outputPath: string; + + if (cleaned) { + // Check if the path looks like a directory (ends with / or is an existing directory) + const isDirectoryHint = cleaned.endsWith("/") || cleaned.endsWith("\\"); + let isExistingDir = false; + try { + const stat = statSync(cleaned); + isExistingDir = stat.isDirectory(); + } catch { + // Path doesn't exist — not a directory + } - // Determine output path - const outputDir = args.trim() || session.workDir; - const now = new Date(); - const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; - const filename = `kimi-export-${session.id.slice(0, 8)}-${ts}.md`; - const outputPath = join(outputDir, filename); + if (isDirectoryHint || isExistingDir) { + // Treat as directory — generate filename inside it + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const filename = `kimi-export-${session.id.slice(0, 8)}-${ts}.md`; + outputPath = join(cleaned, filename); + } else { + // Treat as file path directly + outputPath = cleaned; + } + } else { + // No args — use working directory with generated filename + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const filename = `kimi-export-${session.id.slice(0, 8)}-${ts}.md`; + outputPath = join(session.workDir, filename); + } - // Build markdown - const lines: string[] = []; - lines.push(`# Kimi CLI Session Export`); - lines.push(`Session: ${session.id}`); - lines.push(`Exported: ${new Date().toISOString()}`); - lines.push(`Messages: ${history.length}`); - lines.push(`Tokens: ${context.tokenCountWithPending}`); - lines.push(""); + // Build markdown + const lines: string[] = []; + lines.push(`# Kimi CLI Session Export`); + lines.push(`Session: ${session.id}`); + lines.push(`Exported: ${new Date().toISOString()}`); + lines.push(`Messages: ${history.length}`); + lines.push(`Tokens: ${context.tokenCountWithPending}`); + lines.push(""); - for (let i = 0; i < history.length; i++) { - const msg = history[i]!; - lines.push(`## ${msg.role.toUpperCase()} (#${i + 1})`); - lines.push(""); - if (typeof msg.content === "string") { - lines.push(msg.content); - } else if (Array.isArray(msg.content)) { - for (const part of msg.content as ContentPart[]) { - if (part.type === "text") { - lines.push(part.text); - } else if (part.type === "tool_use") { - lines.push(`**Tool Call: ${part.name}**`); - lines.push("```json"); - lines.push(JSON.stringify(part.input, null, 2)); - lines.push("```"); - } else if (part.type === "tool_result") { - lines.push(`**Tool Result** (${part.isError ? "error" : "success"})`); - lines.push("```"); - lines.push(part.content); - lines.push("```"); - } - } - } - lines.push(""); - } + for (let i = 0; i < history.length; i++) { + const msg = history[i]!; + lines.push(`## ${msg.role.toUpperCase()} (#${i + 1})`); + lines.push(""); + if (typeof msg.content === "string") { + lines.push(msg.content); + } else if (Array.isArray(msg.content)) { + for (const part of msg.content as ContentPart[]) { + if (part.type === "text") { + lines.push(part.text); + } else if (part.type === "tool_use") { + lines.push(`**Tool Call: ${part.name}**`); + lines.push("```json"); + lines.push(JSON.stringify(part.input, null, 2)); + lines.push("```"); + } else if (part.type === "tool_result") { + lines.push(`**Tool Result** (${part.isError ? "error" : "success"})`); + lines.push("```"); + lines.push(part.content); + lines.push("```"); + } + } + } + lines.push(""); + } - try { - await Bun.write(outputPath, lines.join("\n")); - // Shorten home dir for display - const display = outputPath.replace(homedir(), "~"); - return ( - `Exported ${history.length} messages to ${display}\n` + - "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally." - ); - } catch (err) { - return `Failed to export: ${err instanceof Error ? err.message : err}`; - } + try { + await Bun.write(outputPath, lines.join("\n")); + // Shorten home dir for display + const display = outputPath.replace(homedir(), "~"); + return ( + `Exported ${history.length} messages to ${display}\n` + + "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally." + ); + } catch (err) { + return `Failed to export: ${err instanceof Error ? err.message : err}`; + } } -export async function handleImport(context: Context, session: Session, args: string): Promise { - const target = args.trim(); - if (!target) { - return "Usage: /import "; - } +export async function handleImport( + context: Context, + session: Session, + args: string, +): Promise { + const target = args.trim(); + if (!target) { + return "Usage: /import "; + } - // Check if it's a file path - const file = Bun.file(target); - if (await file.exists()) { - try { - const content = await file.text(); - // Append as a user message with import marker - await context.appendMessage({ - role: "user", - content: `[Imported from ${target}]\n\n${content}`, - }); - return `Imported ${content.length} chars from ${target}`; - } catch (err) { - return `Failed to import: ${err instanceof Error ? err.message : err}`; - } - } + // Check if it's a file path + const file = Bun.file(target); + if (await file.exists()) { + try { + const content = await file.text(); + // Append as a user message with import marker + await context.appendMessage({ + role: "user", + content: `[Imported from ${target}]\n\n${content}`, + }); + return `Imported ${content.length} chars from ${target}`; + } catch (err) { + return `Failed to import: ${err instanceof Error ? err.message : err}`; + } + } - // Try as session ID - const { Session: SessionClass } = await import("../../../session.ts"); - const otherSession = await SessionClass.find(session.workDir, target); - if (!otherSession) { - return `File not found and no session with ID: ${target}`; - } + // Try as session ID + const { Session: SessionClass } = await import("../../../session.ts"); + const otherSession = await SessionClass.find(session.workDir, target); + if (!otherSession) { + return `File not found and no session with ID: ${target}`; + } - // Read other session's context - const contextFile = Bun.file(otherSession.contextFile); - if (!(await contextFile.exists())) { - return "Target session has no context."; - } + // Read other session's context + const contextFile = Bun.file(otherSession.contextFile); + if (!(await contextFile.exists())) { + return "Target session has no context."; + } - const text = await contextFile.text(); - const messageCount = text.split("\n").filter(l => l.trim()).length; - await context.appendMessage({ - role: "user", - content: `[Imported context from session ${target}]\n\n${text}`, - }); - return `Imported context from session ${target} (~${messageCount} entries)`; + const text = await contextFile.text(); + const messageCount = text.split("\n").filter((l) => l.trim()).length; + await context.appendMessage({ + role: "user", + content: `[Imported context from session ${target}]\n\n${text}`, + }); + return `Imported context from session ${target} (~${messageCount} entries)`; } diff --git a/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts index 5f349daac..69bea2c70 100644 --- a/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts +++ b/src/kimi_cli_ts/ui/shell/useShellCallbacks.ts @@ -8,7 +8,7 @@ import { useCallback, useRef } from "react"; import { parseSlashCommand, findSlashCommand } from "./slash.ts"; import { runShellCommand, openExternalEditor } from "./shell-executor.ts"; -import { Reload } from "../../cli/index.ts"; +import { Reload } from "../../cli/errors.ts"; import { SHELL_MODE_COMMANDS } from "./shell-commands.ts"; import type { WireUIEvent } from "./events.ts"; import type { ApprovalRequest, ApprovalResponseKind } from "../../wire/types.ts"; diff --git a/src/kimi_cli_ts/utils/broadcast.ts b/src/kimi_cli_ts/utils/broadcast.ts new file mode 100644 index 000000000..75cde5fd6 --- /dev/null +++ b/src/kimi_cli_ts/utils/broadcast.ts @@ -0,0 +1,6 @@ +/** + * Broadcast queue — corresponds to Python utils/broadcast.py + * + * Re-exports BroadcastQueue from queue.ts where it is already implemented. + */ +export { BroadcastQueue } from "./queue.ts"; diff --git a/src/kimi_cli_ts/utils/datetime.ts b/src/kimi_cli_ts/utils/datetime.ts new file mode 100644 index 000000000..187c84c7e --- /dev/null +++ b/src/kimi_cli_ts/utils/datetime.ts @@ -0,0 +1,52 @@ +/** + * Date/time utilities — corresponds to Python utils/datetime.py + */ + +/** + * Format a timestamp as a relative time string. + */ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp * 1000; + + if (diffMs < 5 * 60 * 1000) { + return "just now"; + } + if (diffMs < 60 * 60 * 1000) { + const minutes = Math.floor(diffMs / (60 * 1000)); + return `${minutes}m ago`; + } + if (diffMs < 24 * 60 * 60 * 1000) { + const hours = Math.floor(diffMs / (60 * 60 * 1000)); + return `${hours}h ago`; + } + if (diffMs < 7 * 24 * 60 * 60 * 1000) { + const days = Math.floor(diffMs / (24 * 60 * 60 * 1000)); + return `${days}d ago`; + } + + const dt = new Date(timestamp * 1000); + const month = String(dt.getMonth() + 1).padStart(2, "0"); + const day = String(dt.getDate()).padStart(2, "0"); + return `${month}-${day}`; +} + +/** + * Format a duration in seconds using short units. + */ +export function formatDuration(seconds: number): string { + const parts: string[] = []; + const days = Math.floor(seconds / 86400); + if (days) parts.push(`${days}d`); + + const remainder = seconds % 86400; + const hours = Math.floor(remainder / 3600); + const minutes = Math.floor((remainder % 3600) / 60); + const secs = remainder % 60; + + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (secs && parts.length === 0) parts.push(`${secs}s`); + + return parts.join(" ") || "0s"; +} diff --git a/src/kimi_cli_ts/utils/diff.ts b/src/kimi_cli_ts/utils/diff.ts index 748d71466..3f21998fa 100644 --- a/src/kimi_cli_ts/utils/diff.ts +++ b/src/kimi_cli_ts/utils/diff.ts @@ -10,289 +10,339 @@ const HUGE_FILE_THRESHOLD = 10000; * Format a unified diff between old_text and new_text. */ export function formatUnifiedDiff( - oldText: string, - newText: string, - path = "", - opts?: { includeFileHeader?: boolean }, + oldText: string, + newText: string, + path = "", + opts?: { includeFileHeader?: boolean }, ): string { - const includeFileHeader = opts?.includeFileHeader ?? true; - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); + const includeFileHeader = opts?.includeFileHeader ?? true; + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); - const fromFile = path ? `a/${path}` : "a/file"; - const toFile = path ? `b/${path}` : "b/file"; + const fromFile = path ? `a/${path}` : "a/file"; + const toFile = path ? `b/${path}` : "b/file"; - // Simple unified diff implementation - const hunks = computeHunks(oldLines, newLines, N_CONTEXT_LINES); - if (hunks.length === 0) return ""; + // Simple unified diff implementation + const hunks = computeHunks(oldLines, newLines, N_CONTEXT_LINES); + if (hunks.length === 0) return ""; - const result: string[] = []; - if (includeFileHeader) { - result.push(`--- ${fromFile}`); - result.push(`+++ ${toFile}`); - } + const result: string[] = []; + if (includeFileHeader) { + result.push(`--- ${fromFile}`); + result.push(`+++ ${toFile}`); + } - for (const hunk of hunks) { - result.push(hunk); - } + for (const hunk of hunks) { + result.push(hunk); + } - return result.join("\n") + "\n"; + return result.join("\n") + "\n"; } -function computeHunks(oldLines: string[], newLines: string[], context: number): string[] { - // LCS-based diff - const ops = diffLines(oldLines, newLines); - if (ops.length === 0) return []; - - const hunks: string[] = []; - let i = 0; - - while (i < ops.length) { - // Find next change - while (i < ops.length && ops[i] === "equal") i++; - if (i >= ops.length) break; - - // Determine hunk boundaries - const changeStart = i; - let changeEnd = i; - while (changeEnd < ops.length) { - if (ops[changeEnd] === "equal") { - // Check if there's another change within context - let nextChange = changeEnd; - while (nextChange < ops.length && ops[nextChange] === "equal") nextChange++; - if (nextChange >= ops.length || nextChange - changeEnd > context * 2) break; - changeEnd = nextChange; - } - changeEnd++; - } - - // Build hunk with context - const start = Math.max(0, changeStart - context); - const end = Math.min(ops.length, changeEnd + context); - - let oldStart = 0; - let newStart = 0; - for (let j = 0; j < start; j++) { - if (ops[j] === "equal" || ops[j] === "delete") oldStart++; - if (ops[j] === "equal" || ops[j] === "insert") newStart++; - } - - let oldCount = 0; - let newCount = 0; - const lines: string[] = []; - - let oldIdx = oldStart; - let newIdx = newStart; - for (let j = start; j < end; j++) { - const op = ops[j]!; - if (op === "equal") { - lines.push(` ${oldLines[oldIdx] ?? ""}`); - oldIdx++; - newIdx++; - oldCount++; - newCount++; - } else if (op === "delete") { - lines.push(`-${oldLines[oldIdx] ?? ""}`); - oldIdx++; - oldCount++; - } else if (op === "insert") { - lines.push(`+${newLines[newIdx] ?? ""}`); - newIdx++; - newCount++; - } - } - - hunks.push(`@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@`); - hunks.push(...lines); - - i = changeEnd; - } - - return hunks; +function computeHunks( + oldLines: string[], + newLines: string[], + context: number, +): string[] { + // LCS-based diff + const ops = diffLines(oldLines, newLines); + if (ops.length === 0) return []; + + const hunks: string[] = []; + let i = 0; + + while (i < ops.length) { + // Find next change + while (i < ops.length && ops[i] === "equal") i++; + if (i >= ops.length) break; + + // Determine hunk boundaries + const changeStart = i; + let changeEnd = i; + while (changeEnd < ops.length) { + if (ops[changeEnd] === "equal") { + // Check if there's another change within context + let nextChange = changeEnd; + while (nextChange < ops.length && ops[nextChange] === "equal") + nextChange++; + if (nextChange >= ops.length || nextChange - changeEnd > context * 2) + break; + changeEnd = nextChange; + } + changeEnd++; + } + + // Build hunk with context + const start = Math.max(0, changeStart - context); + const end = Math.min(ops.length, changeEnd + context); + + let oldStart = 0; + let newStart = 0; + for (let j = 0; j < start; j++) { + if (ops[j] === "equal" || ops[j] === "delete") oldStart++; + if (ops[j] === "equal" || ops[j] === "insert") newStart++; + } + + let oldCount = 0; + let newCount = 0; + const lines: string[] = []; + + let oldIdx = oldStart; + let newIdx = newStart; + for (let j = start; j < end; j++) { + const op = ops[j]!; + if (op === "equal") { + lines.push(` ${oldLines[oldIdx] ?? ""}`); + oldIdx++; + newIdx++; + oldCount++; + newCount++; + } else if (op === "delete") { + lines.push(`-${oldLines[oldIdx] ?? ""}`); + oldIdx++; + oldCount++; + } else if (op === "insert") { + lines.push(`+${newLines[newIdx] ?? ""}`); + newIdx++; + newCount++; + } + } + + hunks.push( + `@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@`, + ); + hunks.push(...lines); + + i = changeEnd; + } + + return hunks; } -function diffLines(oldLines: string[], newLines: string[]): ("equal" | "delete" | "insert")[] { - const m = oldLines.length; - const n = newLines.length; - - if (m === 0 && n === 0) return []; - if (m === 0) return new Array(n).fill("insert"); - if (n === 0) return new Array(m).fill("delete"); - - // Myers diff algorithm (simplified) - const max = m + n; - const v = new Array(2 * max + 1).fill(0); - const trace: number[][] = []; - - outer: for (let d = 0; d <= max; d++) { - trace.push([...v]); - for (let k = -d; k <= d; k += 2) { - let x: number; - if (k === -d || (k !== d && v[k - 1 + max]! < v[k + 1 + max]!)) { - x = v[k + 1 + max]!; - } else { - x = v[k - 1 + max]! + 1; - } - let y = x - k; - while (x < m && y < n && oldLines[x] === newLines[y]) { - x++; - y++; - } - v[k + max] = x; - if (x >= m && y >= n) break outer; - } - } - - // Backtrack to build edit script - const ops: ("equal" | "delete" | "insert")[] = []; - let x = m; - let y = n; - - for (let d = trace.length - 1; d > 0; d--) { - const prev = trace[d - 1]!; - const k = x - y; - let prevK: number; - if (k === -d || (k !== d && prev[k - 1 + max]! < prev[k + 1 + max]!)) { - prevK = k + 1; - } else { - prevK = k - 1; - } - const prevX = prev[prevK + max]!; - const prevY = prevX - prevK; - - // Diagonal moves (equal) - while (x > prevX + (prevK < k ? 1 : 0) && y > prevY + (prevK < k ? 0 : 1)) { - ops.push("equal"); - x--; - y--; - } - - if (prevK < k) { - ops.push("delete"); - x--; - } else { - ops.push("insert"); - y--; - } - } - - // Remaining diagonal - while (x > 0 && y > 0 && oldLines[x - 1] === newLines[y - 1]) { - ops.push("equal"); - x--; - y--; - } - - ops.reverse(); - return ops; +function diffLines( + oldLines: string[], + newLines: string[], +): ("equal" | "delete" | "insert")[] { + const m = oldLines.length; + const n = newLines.length; + + if (m === 0 && n === 0) return []; + if (m === 0) return new Array(n).fill("insert"); + if (n === 0) return new Array(m).fill("delete"); + + // Myers diff algorithm (simplified) + const max = m + n; + const v = new Array(2 * max + 1).fill(0); + const trace: number[][] = []; + + outer: for (let d = 0; d <= max; d++) { + trace.push([...v]); + for (let k = -d; k <= d; k += 2) { + let x: number; + if (k === -d || (k !== d && v[k - 1 + max]! < v[k + 1 + max]!)) { + x = v[k + 1 + max]!; + } else { + x = v[k - 1 + max]! + 1; + } + let y = x - k; + while (x < m && y < n && oldLines[x] === newLines[y]) { + x++; + y++; + } + v[k + max] = x; + if (x >= m && y >= n) break outer; + } + } + + // Backtrack to build edit script + const ops: ("equal" | "delete" | "insert")[] = []; + let x = m; + let y = n; + + for (let d = trace.length - 1; d > 0; d--) { + const prev = trace[d - 1]!; + const k = x - y; + let prevK: number; + if (k === -d || (k !== d && prev[k - 1 + max]! < prev[k + 1 + max]!)) { + prevK = k + 1; + } else { + prevK = k - 1; + } + const prevX = prev[prevK + max]!; + const prevY = prevX - prevK; + + // Diagonal moves (equal) + while (x > prevX + (prevK < k ? 1 : 0) && y > prevY + (prevK < k ? 0 : 1)) { + ops.push("equal"); + x--; + y--; + } + + if (prevK < k) { + ops.push("delete"); + x--; + } else { + ops.push("insert"); + y--; + } + } + + // Remaining diagonal + while (x > 0 && y > 0 && oldLines[x - 1] === newLines[y - 1]) { + ops.push("equal"); + x--; + y--; + } + + ops.reverse(); + return ops; } export interface DiffBlock { - path: string; - oldText: string; - newText: string; - oldStart: number; - newStart: number; - isSummary?: boolean; + path: string; + oldText: string; + newText: string; + oldStart: number; + newStart: number; + isSummary?: boolean; } /** * Build diff display blocks grouped with small context windows. */ export function buildDiffBlocks( - path: string, - oldText: string, - newText: string, + path: string, + oldText: string, + newText: string, ): DiffBlock[] { - if (oldText === newText) return []; - - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); - const maxLines = Math.max(oldLines.length, newLines.length); - - // Huge files: skip diff entirely - if (maxLines > HUGE_FILE_THRESHOLD) { - const oldDesc = `(${oldLines.length} lines)`; - const newDesc = - oldLines.length === newLines.length - ? `(${newLines.length} lines, modified)` - : `(${newLines.length} lines)`; - return [ - { - path, - oldText: oldDesc, - newText: newDesc, - oldStart: 1, - newStart: 1, - isSummary: true, - }, - ]; - } - - // Use simple sequence matching for blocks - const blocks: DiffBlock[] = []; - const ops = diffLines(oldLines, newLines); - - let i = 0; - while (i < ops.length) { - // Skip equal ops - while (i < ops.length && ops[i] === "equal") i++; - if (i >= ops.length) break; - - // Find change range - const changeStart = i; - let changeEnd = i; - while (changeEnd < ops.length) { - if (ops[changeEnd] === "equal") { - let nextChange = changeEnd; - while (nextChange < ops.length && ops[nextChange] === "equal") nextChange++; - if (nextChange >= ops.length || nextChange - changeEnd > N_CONTEXT_LINES * 2) break; - changeEnd = nextChange; - } - changeEnd++; - } - - const start = Math.max(0, changeStart - N_CONTEXT_LINES); - const end = Math.min(ops.length, changeEnd + N_CONTEXT_LINES); - - let oldIdx = 0; - let newIdx = 0; - for (let j = 0; j < start; j++) { - if (ops[j] === "equal" || ops[j] === "delete") oldIdx++; - if (ops[j] === "equal" || ops[j] === "insert") newIdx++; - } - - const oldStart = oldIdx; - const newStart = newIdx; - const blockOldLines: string[] = []; - const blockNewLines: string[] = []; - - for (let j = start; j < end; j++) { - const op = ops[j]!; - if (op === "equal") { - blockOldLines.push(oldLines[oldIdx]!); - blockNewLines.push(newLines[newIdx]!); - oldIdx++; - newIdx++; - } else if (op === "delete") { - blockOldLines.push(oldLines[oldIdx]!); - oldIdx++; - } else { - blockNewLines.push(newLines[newIdx]!); - newIdx++; - } - } - - blocks.push({ - path, - oldText: blockOldLines.join("\n"), - newText: blockNewLines.join("\n"), - oldStart: oldStart + 1, - newStart: newStart + 1, - }); - - i = changeEnd; - } - - return blocks; + if (oldText === newText) return []; + + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const maxLines = Math.max(oldLines.length, newLines.length); + + // Huge files: skip diff entirely + if (maxLines > HUGE_FILE_THRESHOLD) { + const oldDesc = `(${oldLines.length} lines)`; + const newDesc = + oldLines.length === newLines.length + ? `(${newLines.length} lines, modified)` + : `(${newLines.length} lines)`; + return [ + { + path, + oldText: oldDesc, + newText: newDesc, + oldStart: 1, + newStart: 1, + isSummary: true, + }, + ]; + } + + // Use simple sequence matching for blocks + const blocks: DiffBlock[] = []; + const ops = diffLines(oldLines, newLines); + + let i = 0; + while (i < ops.length) { + // Skip equal ops + while (i < ops.length && ops[i] === "equal") i++; + if (i >= ops.length) break; + + // Find change range + const changeStart = i; + let changeEnd = i; + while (changeEnd < ops.length) { + if (ops[changeEnd] === "equal") { + let nextChange = changeEnd; + while (nextChange < ops.length && ops[nextChange] === "equal") + nextChange++; + if ( + nextChange >= ops.length || + nextChange - changeEnd > N_CONTEXT_LINES * 2 + ) + break; + changeEnd = nextChange; + } + changeEnd++; + } + + const start = Math.max(0, changeStart - N_CONTEXT_LINES); + const end = Math.min(ops.length, changeEnd + N_CONTEXT_LINES); + + let oldIdx = 0; + let newIdx = 0; + for (let j = 0; j < start; j++) { + if (ops[j] === "equal" || ops[j] === "delete") oldIdx++; + if (ops[j] === "equal" || ops[j] === "insert") newIdx++; + } + + const oldStart = oldIdx; + const newStart = newIdx; + const blockOldLines: string[] = []; + const blockNewLines: string[] = []; + + for (let j = start; j < end; j++) { + const op = ops[j]!; + if (op === "equal") { + blockOldLines.push(oldLines[oldIdx]!); + blockNewLines.push(newLines[newIdx]!); + oldIdx++; + newIdx++; + } else if (op === "delete") { + blockOldLines.push(oldLines[oldIdx]!); + oldIdx++; + } else { + blockNewLines.push(newLines[newIdx]!); + newIdx++; + } + } + + blocks.push({ + path, + oldText: blockOldLines.join("\n"), + newText: blockNewLines.join("\n"), + oldStart: oldStart + 1, + newStart: newStart + 1, + }); + + i = changeEnd; + } + + return blocks; +} + +/** + * Wire-compatible diff display block (snake_case fields matching wire/types.ts DiffDisplayBlock). + */ +export interface WireDiffDisplayBlock { + type: "diff"; + path: string; + old_text: string; + new_text: string; + old_start: number; + new_start: number; + is_summary: boolean; +} + +/** + * Build wire-compatible diff display blocks for approval and tool result display. + * Returns objects matching the wire DiffDisplayBlock schema (snake_case). + */ +export function buildWireDiffBlocks( + path: string, + oldText: string, + newText: string, +): WireDiffDisplayBlock[] { + const blocks = buildDiffBlocks(path, oldText, newText); + return blocks.map((b) => ({ + type: "diff" as const, + path: b.path, + old_text: b.oldText, + new_text: b.newText, + old_start: b.oldStart, + new_start: b.newStart, + is_summary: b.isSummary ?? false, + })); } diff --git a/src/kimi_cli_ts/utils/editor.ts b/src/kimi_cli_ts/utils/editor.ts new file mode 100644 index 000000000..e0e6078ee --- /dev/null +++ b/src/kimi_cli_ts/utils/editor.ts @@ -0,0 +1,100 @@ +/** + * External editor utilities — corresponds to Python utils/editor.py + * Opens text in $VISUAL/$EDITOR for editing. + */ + +import { statSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { logger } from "./logging.ts"; +import { getCleanEnv } from "./subprocess_env.ts"; + +/** VSCode needs --wait to block until the file is closed. */ +const EDITOR_CANDIDATES: Array<[string[], string]> = [ + [["code", "--wait"], "code"], + [["vim"], "vim"], + [["vi"], "vi"], + [["nano"], "nano"], +]; + +/** + * Determine the editor command to use. + * + * Priority: configured (from config) -> $VISUAL -> $EDITOR -> auto-detect. + * Auto-detect order: code --wait -> vim -> vi -> nano. + */ +export function getEditorCommand(configured = ""): string[] | null { + if (configured) { + return configured.split(/\s+/).filter(Boolean); + } + + for (const varName of ["VISUAL", "EDITOR"]) { + const value = process.env[varName]; + if (value) { + return value.split(/\s+/).filter(Boolean); + } + } + + for (const [cmd, binary] of EDITOR_CANDIDATES) { + if (Bun.which(binary) !== null) { + return cmd; + } + } + + return null; +} + +/** + * Open text in an external editor and return the edited result. + * Returns null if the editor failed or the user quit without saving. + */ +export async function editTextInEditor(text: string, configured = ""): Promise { + const editorCmd = getEditorCommand(configured); + if (editorCmd === null) { + logger.warn("No editor found. Set $VISUAL or $EDITOR."); + return null; + } + + const tmpFile = join( + tmpdir(), + `kimi-edit-${Date.now()}-${Math.random().toString(36).slice(2)}.md`, + ); + try { + await Bun.write(tmpFile, text); + const mtimeBefore = statSync(tmpFile).mtimeMs; + + const proc = Bun.spawn([...editorCmd, tmpFile], { + env: getCleanEnv(), + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + logger.warn(`Editor exited with non-zero return code: ${exitCode}`); + return null; + } + + const mtimeAfter = statSync(tmpFile).mtimeMs; + if (mtimeAfter === mtimeBefore) { + return null; + } + + let edited = await Bun.file(tmpFile).text(); + if (edited.endsWith("\n")) { + edited = edited.slice(0, -1); + } + + return edited; + } catch (err) { + logger.warn(`Failed to launch editor ${editorCmd}: ${err}`); + return null; + } finally { + try { + unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/src/kimi_cli_ts/utils/envvar.ts b/src/kimi_cli_ts/utils/envvar.ts new file mode 100644 index 000000000..909672f5f --- /dev/null +++ b/src/kimi_cli_ts/utils/envvar.ts @@ -0,0 +1,24 @@ +/** + * Environment variable helpers — corresponds to Python utils/envvar.py + */ + +const TRUE_VALUES = new Set(["1", "true", "t", "yes", "y"]); + +/** + * Read an environment variable as a boolean. + */ +export function getEnvBool(name: string, defaultValue = false): boolean { + const value = process.env[name]; + if (value === undefined) return defaultValue; + return TRUE_VALUES.has(value.trim().toLowerCase()); +} + +/** + * Read an environment variable as an integer. + */ +export function getEnvInt(name: string, defaultValue: number): number { + const value = process.env[name]; + if (value === undefined) return defaultValue; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; +} diff --git a/src/kimi_cli_ts/utils/frontmatter.ts b/src/kimi_cli_ts/utils/frontmatter.ts new file mode 100644 index 000000000..4882f4ad4 --- /dev/null +++ b/src/kimi_cli_ts/utils/frontmatter.ts @@ -0,0 +1,50 @@ +/** + * YAML frontmatter parsing — corresponds to Python utils/frontmatter.py + */ + +import * as yaml from "./yaml.ts"; + +/** + * Parse YAML frontmatter from a text blob. + * Returns null if no frontmatter found. + * Throws if the frontmatter YAML is invalid. + */ +export function parseFrontmatter(text: string): Record | null { + const lines = text.split("\n"); + if (lines.length === 0 || lines[0]!.trim() !== "---") { + return null; + } + + const frontmatterLines: string[] = []; + let foundEnd = false; + for (let i = 1; i < lines.length; i++) { + if (lines[i]!.trim() === "---") { + foundEnd = true; + break; + } + frontmatterLines.push(lines[i]!); + } + + if (!foundEnd) return null; + + const frontmatter = frontmatterLines.join("\n").trim(); + if (!frontmatter) return null; + + try { + const rawData = yaml.parse(frontmatter); + if (typeof rawData !== "object" || rawData === null || Array.isArray(rawData)) { + throw new Error("Frontmatter YAML must be a mapping."); + } + return rawData as Record; + } catch (err) { + throw new Error(`Invalid frontmatter YAML: ${err}`); + } +} + +/** + * Read the YAML frontmatter at the start of a file. + */ +export async function readFrontmatter(path: string): Promise | null> { + const text = await Bun.file(path).text(); + return parseFrontmatter(text); +} diff --git a/src/kimi_cli_ts/utils/index.ts b/src/kimi_cli_ts/utils/index.ts new file mode 100644 index 000000000..fe50bce25 --- /dev/null +++ b/src/kimi_cli_ts/utils/index.ts @@ -0,0 +1,36 @@ +/** + * Utils barrel exports — corresponds to Python utils/__init__.py + */ + +export { formatRelativeTime, formatDuration } from "./datetime.ts"; +export { getEditorCommand, editTextInEditor } from "./editor.ts"; +export { getEnvBool, getEnvInt } from "./envvar.ts"; +export { parseFrontmatter, readFrontmatter } from "./frontmatter.ts"; +export { atomicJsonWrite } from "./io.ts"; +export { wrapMediaPart } from "./media_tags.ts"; +export { normalizeProxyEnv } from "./proxy.ts"; +export { + formatUrl, + isLocalHost, + findAvailablePort, + getNetworkAddresses, + printBanner, +} from "./server.ts"; +export { + type SlashCommand, + slashName, + SlashCommandRegistry, + type SlashCommandCall, + parseSlashCommandCall, +} from "./slashcmd.ts"; +export { getCleanEnv, getNoninteractiveEnv } from "./subprocess_env.ts"; +export { ensureNewLine, getTerminalSize } from "./term.ts"; + +// Re-export existing utils +export { sleep, withTimeout, TimeoutError, Deferred, mapConcurrent } from "./async.ts"; +export { shorten, shortenMiddle, randomString } from "./string.ts"; +export { expandHome, resolvePath, shortPath, isInsideDir, ensureDir } from "./path.ts"; +export { detectEnvironment } from "./environment.ts"; +export { logger } from "./logging.ts"; +export { installSigintHandler, installSigtermHandler, installShutdownHandlers } from "./signals.ts"; +export { AsyncQueue, BroadcastQueue, QueueShutDown } from "./queue.ts"; diff --git a/src/kimi_cli_ts/utils/io.ts b/src/kimi_cli_ts/utils/io.ts new file mode 100644 index 000000000..46b1e9a12 --- /dev/null +++ b/src/kimi_cli_ts/utils/io.ts @@ -0,0 +1,30 @@ +/** + * I/O utilities — corresponds to Python utils/io.py + * Atomic file write operations. + */ + +import { dirname, join } from "node:path"; +import { renameSync, unlinkSync } from "node:fs"; + +/** + * Write JSON data to a file atomically using tmp-file + rename. + * + * This prevents data corruption if the process crashes mid-write: either the + * old file is kept intact or the new file is fully committed. + */ +export async function atomicJsonWrite(data: unknown, path: string): Promise { + const dir = dirname(path); + const tmpPath = join(dir, `.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`); + try { + const content = JSON.stringify(data, null, 2); + await Bun.write(tmpPath, content); + renameSync(tmpPath, path); + } catch (err) { + try { + unlinkSync(tmpPath); + } catch { + // Ignore cleanup errors + } + throw err; + } +} diff --git a/src/kimi_cli_ts/utils/media_tags.ts b/src/kimi_cli_ts/utils/media_tags.ts new file mode 100644 index 000000000..f00bd0105 --- /dev/null +++ b/src/kimi_cli_ts/utils/media_tags.ts @@ -0,0 +1,35 @@ +/** + * Media file type detection and tag wrapping — corresponds to Python utils/media_tags.py + */ + +import type { ContentPart } from "../types.ts"; + +interface TextPartLiteral { + type: "text"; + text: string; +} + +function formatTag(tag: string, attrs?: Record): string { + if (!attrs) return `<${tag}>`; + const rendered: string[] = []; + for (const key of Object.keys(attrs).sort()) { + const value = attrs[key]; + if (!value) continue; + const escaped = value.replace(/&/g, "&").replace(/"/g, """); + rendered.push(`${key}="${escaped}"`); + } + if (rendered.length === 0) return `<${tag}>`; + return `<${tag} ${rendered.join(" ")}>`; +} + +/** + * Wrap a content part in XML-like tags. + */ +export function wrapMediaPart( + part: ContentPart, + options: { tag: string; attrs?: Record }, +): ContentPart[] { + const openTag: TextPartLiteral = { type: "text", text: formatTag(options.tag, options.attrs) }; + const closeTag: TextPartLiteral = { type: "text", text: `` }; + return [openTag, part, closeTag]; +} diff --git a/src/kimi_cli_ts/utils/proxy.ts b/src/kimi_cli_ts/utils/proxy.ts new file mode 100644 index 000000000..d99fa868e --- /dev/null +++ b/src/kimi_cli_ts/utils/proxy.ts @@ -0,0 +1,33 @@ +/** + * Proxy environment normalization — corresponds to Python utils/proxy.py + * Rewrites socks:// to socks5:// in proxy environment variables. + */ + +const PROXY_ENV_VARS = [ + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", +]; + +const SOCKS_PREFIX = "socks://"; +const SOCKS5_PREFIX = "socks5://"; + +/** + * Rewrite `socks://` to `socks5://` in proxy environment variables. + * + * Many proxy tools (V2RayN, Clash, etc.) set `ALL_PROXY=socks://...`, but + * HTTP clients typically only recognise `socks5://`. Since `socks://` is + * effectively an alias for `socks5://`, this function performs a safe + * in-place replacement so that downstream HTTP clients work correctly. + */ +export function normalizeProxyEnv(): void { + for (const varName of PROXY_ENV_VARS) { + const value = process.env[varName]; + if (value !== undefined && value.toLowerCase().startsWith(SOCKS_PREFIX)) { + process.env[varName] = SOCKS5_PREFIX + value.slice(SOCKS_PREFIX.length); + } + } +} diff --git a/src/kimi_cli_ts/utils/server.ts b/src/kimi_cli_ts/utils/server.ts new file mode 100644 index 000000000..4328fed70 --- /dev/null +++ b/src/kimi_cli_ts/utils/server.ts @@ -0,0 +1,143 @@ +/** + * Server utilities — corresponds to Python utils/server.py + * Shared utilities for server startup, port finding, and banner display. + */ + +import { createServer } from "node:net"; +import { hostname } from "node:os"; + +/** + * Return address family hint based on whether host contains `:`. + */ +export function getAddressFamily(host: string): 4 | 6 { + return host.includes(":") ? 6 : 4; +} + +/** + * Build `http://host:port`, bracketing IPv6 literals per RFC 2732. + */ +export function formatUrl(host: string, port: number): string { + if (host.includes(":")) { + return `http://[${host}]:${port}`; + } + return `http://${host}:${port}`; +} + +/** + * Check whether host resolves to a loopback address. + */ +export function isLocalHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +/** + * Find an available port starting from startPort. + * Throws if no port is available within the range. + */ +export async function findAvailablePort( + host: string, + startPort: number, + maxAttempts = 10, +): Promise { + if (maxAttempts <= 0) throw new Error("maxAttempts must be positive"); + if (startPort < 1 || startPort > 65535) throw new Error("startPort must be between 1 and 65535"); + + for (let offset = 0; offset < maxAttempts; offset++) { + const port = startPort + offset; + const available = await isPortAvailable(host, port); + if (available) return port; + } + + throw new Error( + `Cannot find available port in range ${startPort}-${startPort + maxAttempts - 1}`, + ); +} + +async function isPortAvailable(host: string, port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, host); + }); +} + +/** + * Get non-loopback IPv4 addresses for this machine. + */ +export function getNetworkAddresses(): string[] { + const addresses: string[] = []; + try { + const { networkInterfaces } = require("node:os"); + const interfaces = networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const info of interfaces[name] ?? []) { + if (info.family === "IPv4" && !info.internal && !addresses.includes(info.address)) { + addresses.push(info.address); + } + } + } + } catch { + // Ignore + } + return addresses; +} + +/** + * Print a boxed banner with tag conventions (
, ,
). + */ +export function printBanner(lines: string[]): void { + const processed: string[] = []; + for (const line of lines) { + if (line === "
") { + processed.push(line); + } else if (!line) { + processed.push(""); + } else if (line.startsWith("
") || line.startsWith("")) { + processed.push(line); + } else { + // Simple word wrap at 78 chars + if (line.length <= 78) { + processed.push(line); + } else { + let remaining = line; + while (remaining.length > 78) { + const space = remaining.lastIndexOf(" ", 78); + const cut = space > 0 ? space : 78; + processed.push(remaining.slice(0, cut)); + remaining = remaining.slice(cut).trimStart(); + } + if (remaining) processed.push(remaining); + } + } + } + + function stripTags(s: string): string { + return s.replace(/^
/, "").replace(/^/, ""); + } + + const contentLines = processed.filter((l) => l !== "
").map(stripTags); + const width = Math.max(60, ...contentLines.map((l) => l.length)); + const top = `+${"=".repeat(width + 2)}+`; + + console.log(top); + for (const line of processed) { + if (line === "
") { + console.log(`|${"-".repeat(width + 2)}|`); + } else if (line.startsWith("
")) { + const content = line.slice("
".length); + const pad = width - content.length; + const left = Math.floor(pad / 2); + const right = pad - left; + console.log(`| ${" ".repeat(left)}${content}${" ".repeat(right)} |`); + } else if (line.startsWith("")) { + const content = line.slice("".length); + console.log(`| ${content.padEnd(width)} |`); + } else { + console.log(`| ${line.padEnd(width)} |`); + } + } + console.log(top); +} diff --git a/src/kimi_cli_ts/utils/slashcmd.ts b/src/kimi_cli_ts/utils/slashcmd.ts new file mode 100644 index 000000000..908e00dfe --- /dev/null +++ b/src/kimi_cli_ts/utils/slashcmd.ts @@ -0,0 +1,90 @@ +/** + * Slash command parsing — corresponds to Python utils/slashcmd.py + * Provides SlashCommand, SlashCommandRegistry, and parse utilities. + */ + +// ── SlashCommand ────────────────────────────────────────── + +export interface SlashCommand< + F extends (...args: never[]) => unknown = (...args: never[]) => unknown, +> { + readonly name: string; + readonly description: string; + readonly func: F; + readonly aliases: string[]; +} + +export function slashName(cmd: SlashCommand): string { + if (cmd.aliases.length > 0) { + return `/${cmd.name} (${cmd.aliases.join(", ")})`; + } + return `/${cmd.name}`; +} + +// ── SlashCommandRegistry ────────────────────────────────── + +export class SlashCommandRegistry< + F extends (...args: never[]) => unknown = (...args: never[]) => unknown, +> { + private _commands = new Map>(); + private _commandAliases = new Map>(); + + /** + * Register a slash command. + */ + register( + func: F, + options?: { name?: string; aliases?: string[]; description?: string }, + ): void { + const name = options?.name ?? func.name; + const aliases = options?.aliases ?? []; + const description = options?.description ?? ""; + + const cmd: SlashCommand = { name, description, func, aliases }; + + this._commands.set(name, cmd); + this._commandAliases.set(name, cmd); + + for (const alias of aliases) { + this._commandAliases.set(alias, cmd); + } + } + + findCommand(name: string): SlashCommand | null { + return this._commandAliases.get(name) ?? null; + } + + listCommands(): SlashCommand[] { + return Array.from(this._commands.values()); + } +} + +// ── SlashCommandCall ────────────────────────────────────── + +export interface SlashCommandCall { + readonly name: string; + readonly args: string; + readonly rawInput: string; +} + +const SLASH_CMD_RE = /^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)/; + +/** + * Parse a slash command call from user input. + * Returns null if no slash command is found. + */ +export function parseSlashCommandCall(userInput: string): SlashCommandCall | null { + const trimmed = userInput.trim(); + if (!trimmed || !trimmed.startsWith("/")) return null; + + const match = SLASH_CMD_RE.exec(trimmed); + if (!match) return null; + + const commandName = match[1]!; + if (trimmed.length > match[0].length && !/\s/.test(trimmed[match[0].length]!)) { + return null; + } + + const rawArgs = trimmed.slice(match[0].length).trimStart(); + return { name: commandName, args: rawArgs, rawInput: trimmed }; +} diff --git a/src/kimi_cli_ts/utils/subprocess_env.ts b/src/kimi_cli_ts/utils/subprocess_env.ts new file mode 100644 index 000000000..2545c5373 --- /dev/null +++ b/src/kimi_cli_ts/utils/subprocess_env.ts @@ -0,0 +1,43 @@ +/** + * Subprocess environment handling — corresponds to Python utils/subprocess_env.py + * + * In the Python version, this handles PyInstaller's LD_LIBRARY_PATH modifications. + * In the Bun/TS version, we don't need PyInstaller handling, but we keep the same + * interface for compatibility and the non-interactive env setup is still useful. + */ + +/** + * Get a clean environment suitable for spawning subprocesses. + * + * In the Bun runtime there's no PyInstaller, so this simply returns a copy + * of the current environment (or the provided base). + */ +export function getCleanEnv(baseEnv?: Record): Record { + const env = baseEnv ?? process.env; + const clean: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + clean[key] = value; + } + } + return clean; +} + +/** + * Get an environment for subprocesses that must not block on interactive prompts. + * + * Builds on getCleanEnv and additionally configures git to fail fast instead + * of waiting for user input that will never arrive. + */ +export function getNoninteractiveEnv( + baseEnv?: Record, +): Record { + const env = getCleanEnv(baseEnv); + + // GIT_TERMINAL_PROMPT=0 makes git fail instead of prompting for credentials. + if (!env.GIT_TERMINAL_PROMPT) { + env.GIT_TERMINAL_PROMPT = "0"; + } + + return env; +} diff --git a/src/kimi_cli_ts/utils/term.ts b/src/kimi_cli_ts/utils/term.ts new file mode 100644 index 000000000..b4999b681 --- /dev/null +++ b/src/kimi_cli_ts/utils/term.ts @@ -0,0 +1,27 @@ +/** + * Terminal utilities — corresponds to Python utils/term.py + * Cursor position detection and terminal state management. + */ + +/** + * Ensure the next prompt starts at column 0 regardless of prior command output. + * + * Note: In the TS/Bun version with React Ink, this is less commonly needed + * as Ink manages terminal state. Kept for CLI/print mode compatibility. + */ +export function ensureNewLine(): void { + if (!process.stdout.isTTY || !process.stdin.isTTY) return; + // In Bun, we can't easily query cursor position synchronously like Python does + // with termios. Write a newline as a safe fallback. + process.stdout.write("\n"); +} + +/** + * Get the terminal size (columns, rows). + */ +export function getTerminalSize(): { columns: number; rows: number } { + return { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }; +} diff --git a/src/kimi_cli_ts/vis/api/index.ts b/src/kimi_cli_ts/vis/api/index.ts new file mode 100644 index 000000000..7db4d27a6 --- /dev/null +++ b/src/kimi_cli_ts/vis/api/index.ts @@ -0,0 +1,7 @@ +/** + * Vis API barrel — corresponds to Python vis/api/__init__.py + */ + +export { handleSessionsRoute } from "./sessions.ts"; +export { handleStatisticsRoute } from "./statistics.ts"; +export { handleSystemRoute } from "./system.ts"; diff --git a/src/kimi_cli_ts/vis/api/sessions.ts b/src/kimi_cli_ts/vis/api/sessions.ts new file mode 100644 index 000000000..0e8233483 --- /dev/null +++ b/src/kimi_cli_ts/vis/api/sessions.ts @@ -0,0 +1,763 @@ +/** + * Vis API for reading session tracing data. + * Corresponds to Python vis/api/sessions.py + */ + +import { join, resolve, relative } from "node:path"; +import { + existsSync, + statSync, + readdirSync, + readFileSync, + mkdirSync, + rmSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { getShareDir } from "../../share.ts"; +import { loadMetadata } from "../../metadata.ts"; +import { loadSessionState } from "../../session_state.ts"; +import { parseWireFileLine, type WireFileMetadata, type WireMessageRecord } from "../../wire/file.ts"; +import { logger } from "../../utils/logging.ts"; + +const SESSION_ID_RE = /^[a-zA-Z0-9_-]+$/; +const IMPORTED_HASH = "__imported__"; + +// ── Helpers ─────────────────────────────────────────────── + +function getImportedRoot(): string { + return join(getShareDir(), "imported_sessions"); +} + +function findSessionDir(workDirHash: string, sessionId: string): string | null { + if (!SESSION_ID_RE.test(sessionId)) return null; + + if (workDirHash === IMPORTED_HASH) { + const sessionDir = join(getImportedRoot(), sessionId); + if (existsSync(sessionDir) && statSync(sessionDir).isDirectory()) { + return sessionDir; + } + return null; + } + + if (!SESSION_ID_RE.test(workDirHash)) return null; + + const sessionsRoot = join(getShareDir(), "sessions"); + const sessionDir = join(sessionsRoot, workDirHash, sessionId); + if (existsSync(sessionDir) && statSync(sessionDir).isDirectory()) { + return sessionDir; + } + return null; +} + +async function getWorkDirForHash(hashDirName: string): Promise { + try { + const metadata = await loadMetadata(); + for (const wd of metadata.workDirs) { + const pathMd5 = createHash("md5").update(wd.path, "utf-8").digest("hex"); + const dirBasename = wd.kaos === "local" ? pathMd5 : `${wd.kaos}_${pathMd5}`; + if (dirBasename === hashDirName) { + return wd.path; + } + } + } catch { + // Ignore + } + return null; +} + +/** + * Recursively unwrap SubagentEvent and collect (type, payload) pairs. + */ +export function collectEvents( + msgType: string, + payload: Record, + out: Array<{ type: string; payload: Record }>, +): void { + if (msgType === "SubagentEvent") { + const inner = payload.event; + if (inner && typeof inner === "object") { + const innerType = inner.type ?? ""; + const innerPayload = inner.payload ?? {}; + if (innerType) { + collectEvents(innerType, innerPayload, out); + } + } + } else { + out.push({ type: msgType, payload }); + } +} + +// ── Wire parsing helpers ────────────────────────────────── + +function isWireFileMetadata(parsed: WireFileMetadata | WireMessageRecord): parsed is WireFileMetadata { + return "type" in parsed && (parsed as any).type === "metadata"; +} + +function extractTitleFromWire(wirePath: string, maxBytes = 8192): { title: string; turnCount: number } { + let title = ""; + let turnCount = 0; + + try { + const content = readFileSync(wirePath, "utf-8"); + let bytesRead = 0; + + for (const rawLine of content.split("\n")) { + bytesRead += Buffer.byteLength(rawLine, "utf-8"); + const line = rawLine.trim(); + if (!line) continue; + + try { + const parsed = parseWireFileLine(line); + if (isWireFileMetadata(parsed)) continue; + + const record = parsed as WireMessageRecord; + if (record.message.type === "TurnBegin") { + turnCount++; + if (turnCount === 1) { + const userInput = record.message.payload?.user_input; + if (typeof userInput === "string") { + title = userInput.slice(0, 100); + } else if (Array.isArray(userInput) && userInput.length > 0) { + const first = userInput[0]; + if (typeof first === "object" && first !== null) { + title = String(first.text ?? "").slice(0, 100); + } + } + } + } + } catch { + continue; + } + + if (bytesRead > maxBytes) break; + } + } catch { + // Ignore + } + + return { title, turnCount }; +} + +// ── Session scanning ────────────────────────────────────── + +async function scanSessionDir( + sessionDir: string, + workDirHash: string, + workDir: string | null, + imported = false, +): Promise | null> { + if (!existsSync(sessionDir) || !statSync(sessionDir).isDirectory()) { + return null; + } + + const wirePath = join(sessionDir, "wire.jsonl"); + const contextPath = join(sessionDir, "context.jsonl"); + const statePath = join(sessionDir, "state.json"); + + const wireExists = existsSync(wirePath); + const contextExists = existsSync(contextPath); + const stateExists = existsSync(statePath); + + const mtimes: number[] = []; + let wireSize = 0; + let contextSize = 0; + let stateSize = 0; + + if (wireExists) { + const st = statSync(wirePath); + mtimes.push(st.mtimeMs / 1000); + wireSize = st.size; + } + if (contextExists) { + const st = statSync(contextPath); + mtimes.push(st.mtimeMs / 1000); + contextSize = st.size; + } + if (stateExists) { + const st = statSync(statePath); + mtimes.push(st.mtimeMs / 1000); + stateSize = st.size; + } + + const sessionState = await loadSessionState(sessionDir); + + let title = ""; + let turnCount = 0; + if (wireExists) { + const extracted = extractTitleFromWire(wirePath); + title = extracted.title; + turnCount = extracted.turnCount; + } + if (sessionState.custom_title) { + title = sessionState.custom_title; + } + + // Count sub-agents + let subagentCount = 0; + const subagentsDir = join(sessionDir, "subagents"); + if (existsSync(subagentsDir) && statSync(subagentsDir).isDirectory()) { + subagentCount = readdirSync(subagentsDir).filter((name) => { + const p = join(subagentsDir, name); + return statSync(p).isDirectory(); + }).length; + } + + const sessionId = sessionDir.split("/").pop() ?? ""; + + return { + session_id: sessionId, + session_dir: sessionDir, + work_dir: workDir, + work_dir_hash: workDirHash, + title, + last_updated: mtimes.length > 0 ? Math.max(...mtimes) : 0, + has_wire: wireExists, + has_context: contextExists, + has_state: stateExists, + metadata: sessionState, + wire_size: wireSize, + context_size: contextSize, + state_size: stateSize, + total_size: wireSize + contextSize + stateSize, + turns: turnCount, + imported, + subagent_count: subagentCount, + }; +} + +async function listSessionsSync(): Promise>> { + const results: Array> = []; + + const sessionsRoot = join(getShareDir(), "sessions"); + if (existsSync(sessionsRoot)) { + for (const workDirHashName of readdirSync(sessionsRoot)) { + const workDirHashDir = join(sessionsRoot, workDirHashName); + if (!statSync(workDirHashDir).isDirectory()) continue; + + const workDir = await getWorkDirForHash(workDirHashName); + + for (const sessionName of readdirSync(workDirHashDir)) { + const sessionDir = join(workDirHashDir, sessionName); + const info = await scanSessionDir(sessionDir, workDirHashName, workDir); + if (info) results.push(info); + } + } + } + + const importedRoot = getImportedRoot(); + if (existsSync(importedRoot)) { + for (const sessionName of readdirSync(importedRoot)) { + const sessionDir = join(importedRoot, sessionName); + const info = await scanSessionDir(sessionDir, IMPORTED_HASH, null, true); + if (info) results.push(info); + } + } + + results.sort((a, b) => b.last_updated - a.last_updated); + return results; +} + +// ── Wire/context reading helpers ────────────────────────── + +async function readWireEvents(sessionDir: string): Promise> { + const wirePath = join(sessionDir, "wire.jsonl"); + if (!existsSync(wirePath)) return { total: 0, events: [] }; + + const content = await Bun.file(wirePath).text(); + const events: Array> = []; + let index = 0; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + const parsed = parseWireFileLine(line); + if (isWireFileMetadata(parsed)) continue; + const record = parsed as WireMessageRecord; + events.push({ + index, + timestamp: record.timestamp, + type: record.message.type, + payload: record.message.payload, + }); + index++; + } catch { + logger.debug(`Skipped malformed line in ${wirePath}`); + } + } + + return { total: events.length, events }; +} + +async function readContextMessages(sessionDir: string): Promise> { + const contextPath = join(sessionDir, "context.jsonl"); + if (!existsSync(contextPath)) return { total: 0, messages: [] }; + + const content = await Bun.file(contextPath).text(); + const messages: Array> = []; + let index = 0; + + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + const msg = JSON.parse(line); + msg.index = index; + messages.push(msg); + index++; + } catch { + logger.debug(`Skipped malformed line in ${contextPath}`); + } + } + + return { total: messages.length, messages }; +} + +async function readSessionState(sessionDir: string): Promise> { + const statePath = join(sessionDir, "state.json"); + if (!existsSync(statePath)) return {}; + + try { + return JSON.parse(await Bun.file(statePath).text()); + } catch { + return { error: "Invalid state.json" }; + } +} + +async function computeSessionSummary(sessionDir: string): Promise> { + const wirePath = join(sessionDir, "wire.jsonl"); + const contextPath = join(sessionDir, "context.jsonl"); + const statePath = join(sessionDir, "state.json"); + + const wireSize = existsSync(wirePath) ? statSync(wirePath).size : 0; + const contextSize = existsSync(contextPath) ? statSync(contextPath).size : 0; + const stateSize = existsSync(statePath) ? statSync(statePath).size : 0; + + const zeros = { + turns: 0, + steps: 0, + tool_calls: 0, + errors: 0, + compactions: 0, + duration_sec: 0, + input_tokens: 0, + output_tokens: 0, + wire_size: wireSize, + context_size: contextSize, + state_size: stateSize, + total_size: wireSize + contextSize + stateSize, + }; + + if (!existsSync(wirePath)) return zeros; + + let turns = 0; + let steps = 0; + let toolCalls = 0; + let errors = 0; + let compactions = 0; + let inputTokens = 0; + let outputTokens = 0; + let firstTs = 0; + let lastTs = 0; + + const content = await Bun.file(wirePath).text(); + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + + let parsed: WireFileMetadata | WireMessageRecord; + try { + parsed = parseWireFileLine(line); + } catch { + continue; + } + if (isWireFileMetadata(parsed)) continue; + + const record = parsed as WireMessageRecord; + const ts = record.timestamp; + const msgType = record.message.type; + const payload = record.message.payload ?? {}; + + if (firstTs === 0) firstTs = ts; + lastTs = ts; + + const eventsToProcess: Array<{ type: string; payload: Record }> = []; + collectEvents(msgType, payload, eventsToProcess); + + for (const ev of eventsToProcess) { + switch (ev.type) { + case "TurnBegin": + turns++; + break; + case "StepBegin": + steps++; + break; + case "ToolCall": + toolCalls++; + break; + case "CompactionBegin": + compactions++; + break; + case "StepInterrupted": + errors++; + break; + case "ToolResult": { + const rv = ev.payload.return_value; + if (rv && typeof rv === "object" && rv.is_error) errors++; + break; + } + case "ApprovalResponse": + if (ev.payload.response === "reject") errors++; + break; + case "StatusUpdate": { + const tu = ev.payload.token_usage; + if (tu && typeof tu === "object") { + inputTokens += + (Number(tu.input_other) || 0) + + (Number(tu.input_cache_read) || 0) + + (Number(tu.input_cache_creation) || 0); + outputTokens += Number(tu.output) || 0; + } + break; + } + } + } + } + + return { + turns, + steps, + tool_calls: toolCalls, + errors, + compactions, + duration_sec: lastTs > firstTs ? lastTs - firstTs : 0, + input_tokens: inputTokens, + output_tokens: outputTokens, + wire_size: wireSize, + context_size: contextSize, + state_size: stateSize, + total_size: wireSize + contextSize + stateSize, + }; +} + +// ── Subagent helpers ────────────────────────────────────── + +function listSubagents(sessionDir: string): Array> { + const subagentsDir = join(sessionDir, "subagents"); + if (!existsSync(subagentsDir) || !statSync(subagentsDir).isDirectory()) { + return []; + } + + const results: Array> = []; + for (const entry of readdirSync(subagentsDir)) { + const entryPath = join(subagentsDir, entry); + if (!statSync(entryPath).isDirectory()) continue; + if (!SESSION_ID_RE.test(entry)) continue; + + let meta: Record = {}; + const metaPath = join(entryPath, "meta.json"); + if (existsSync(metaPath)) { + try { + meta = JSON.parse(readFileSync(metaPath, "utf-8")); + } catch { + // Ignore + } + } + + const wirePath = join(entryPath, "wire.jsonl"); + const contextPath = join(entryPath, "context.jsonl"); + + results.push({ + agent_id: meta.agent_id ?? entry, + subagent_type: meta.subagent_type ?? "unknown", + status: meta.status ?? "unknown", + description: meta.description ?? "", + created_at: meta.created_at ?? 0, + updated_at: meta.updated_at ?? 0, + last_task_id: meta.last_task_id ?? null, + launch_spec: meta.launch_spec ?? {}, + wire_size: existsSync(wirePath) ? statSync(wirePath).size : 0, + context_size: existsSync(contextPath) ? statSync(contextPath).size : 0, + }); + } + + results.sort((a, b) => (a.created_at ?? 0) - (b.created_at ?? 0)); + return results; +} + +// ── Download / Import / Delete ──────────────────────────── + +async function downloadSession(sessionDir: string, sessionId: string): Promise { + // Use Bun's zip utilities or node:child_process for zip creation + const { execSync } = await import("node:child_process"); + + // Create zip in a temp file + const tmpZip = join(require("node:os").tmpdir(), `session-${sessionId}-${Date.now()}.zip`); + try { + execSync(`cd "${sessionDir}" && zip -r "${tmpZip}" .`, { stdio: "pipe" }); + const zipContent = readFileSync(tmpZip); + rmSync(tmpZip, { force: true }); + + return new Response(zipContent, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="session-${sessionId}.zip"`, + "Access-Control-Allow-Origin": "*", + }, + }); + } catch (err) { + rmSync(tmpZip, { force: true }); + throw err; + } +} + +async function importSession(req: Request): Promise { + const formData = await req.formData(); + const file = formData.get("file"); + + if (!(file instanceof File) || !file.name.endsWith(".zip")) { + return jsonResponse({ detail: "Only .zip files are accepted" }, 400); + } + + const content = await file.arrayBuffer(); + if (content.byteLength === 0) { + return jsonResponse({ detail: "Empty file" }, 400); + } + + const MAX_UPLOAD_BYTES = 200 * 1024 * 1024; + if (content.byteLength > MAX_UPLOAD_BYTES) { + return jsonResponse({ detail: "File too large (max 200 MB)" }, 413); + } + + // Generate session ID and extract + const { randomBytes } = await import("node:crypto"); + const sessionId = randomBytes(8).toString("hex"); + const importedRoot = getImportedRoot(); + const sessionDir = join(importedRoot, sessionId); + mkdirSync(sessionDir, { recursive: true }); + + try { + // Write zip to temp, extract with unzip + const tmpZip = join(require("node:os").tmpdir(), `import-${sessionId}.zip`); + await Bun.write(tmpZip, content); + + const { execSync } = await import("node:child_process"); + execSync(`unzip -o "${tmpZip}" -d "${sessionDir}"`, { stdio: "pipe" }); + rmSync(tmpZip, { force: true }); + + // Flatten if all files are under a single subdirectory + const entries = readdirSync(sessionDir); + if (entries.length === 1) { + const nested = join(sessionDir, entries[0]!); + if (statSync(nested).isDirectory()) { + for (const item of readdirSync(nested)) { + const { renameSync } = require("node:fs"); + renameSync(join(nested, item), join(sessionDir, item)); + } + rmSync(nested, { recursive: true }); + } + } + + // Verify it has wire.jsonl or context.jsonl + const hasValid = + existsSync(join(sessionDir, "wire.jsonl")) || + existsSync(join(sessionDir, "context.jsonl")); + if (!hasValid) { + rmSync(sessionDir, { recursive: true, force: true }); + return jsonResponse( + { + detail: + "ZIP must contain wire.jsonl or context.jsonl at the top level (or inside a single directory)", + }, + 400, + ); + } + } catch (err) { + rmSync(sessionDir, { recursive: true, force: true }); + return jsonResponse({ detail: "Failed to extract ZIP file" }, 400); + } + + return jsonResponse({ session_id: sessionId, work_dir_hash: IMPORTED_HASH }); +} + +function deleteSession(sessionId: string): Response { + if (!SESSION_ID_RE.test(sessionId)) { + return jsonResponse({ detail: "Invalid session ID" }, 400); + } + + const sessionDir = join(getImportedRoot(), sessionId); + if (!existsSync(sessionDir) || !statSync(sessionDir).isDirectory()) { + return jsonResponse({ detail: "Session not found" }, 404); + } + + rmSync(sessionDir, { recursive: true }); + return jsonResponse({ status: "deleted" }); +} + +// ── JSON response helper ────────────────────────────────── + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +function errorResponse(status: number, detail: string): Response { + return jsonResponse({ detail }, status); +} + +// ── Route handler ───────────────────────────────────────── + +/** + * Handle all /api/vis/sessions routes. + * @param apiPath - path after /api/vis (e.g. "/sessions", "/sessions/{hash}/{id}/wire") + */ +export async function handleSessionsRoute( + req: Request, + url: URL, + apiPath: string, +): Promise { + // GET /sessions + if (apiPath === "/sessions" && req.method === "GET") { + const sessions = await listSessionsSync(); + return jsonResponse(sessions); + } + + // POST /sessions/import + if (apiPath === "/sessions/import" && req.method === "POST") { + return importSession(req); + } + + // Parse /sessions/{workDirHash}/{sessionId}... + const parts = apiPath.split("/").filter(Boolean); // ["sessions", hash, id, ...] + if (parts.length < 3 || parts[0] !== "sessions") { + return errorResponse(404, "Not found"); + } + + const workDirHash = parts[1]!; + const sessionId = parts[2]!; + const rest = parts.slice(3).join("/"); // e.g. "wire", "context", "state", "summary", "subagents", etc. + + // DELETE /sessions/{hash}/{id} + if (req.method === "DELETE" && rest === "") { + if (workDirHash !== IMPORTED_HASH) { + return errorResponse(403, "Only imported sessions can be deleted"); + } + return deleteSession(sessionId); + } + + const sessionDir = findSessionDir(workDirHash, sessionId); + if (!sessionDir) { + return errorResponse(404, "Session not found"); + } + + // GET /sessions/{hash}/{id}/wire + if (rest === "wire" && req.method === "GET") { + return jsonResponse(await readWireEvents(sessionDir)); + } + + // GET /sessions/{hash}/{id}/context + if (rest === "context" && req.method === "GET") { + return jsonResponse(await readContextMessages(sessionDir)); + } + + // GET /sessions/{hash}/{id}/state + if (rest === "state" && req.method === "GET") { + return jsonResponse(await readSessionState(sessionDir)); + } + + // GET /sessions/{hash}/{id}/summary + if (rest === "summary" && req.method === "GET") { + return jsonResponse(await computeSessionSummary(sessionDir)); + } + + // GET /sessions/{hash}/{id}/download + if (rest === "download" && req.method === "GET") { + return downloadSession(sessionDir, sessionId); + } + + // GET /sessions/{hash}/{id}/subagents + if (rest === "subagents" && req.method === "GET") { + return jsonResponse(listSubagents(sessionDir)); + } + + // Subagent routes: /sessions/{hash}/{id}/subagents/{agentId}/{resource} + if (parts.length >= 5 && parts[3] === "subagents") { + const agentId = parts[4]!; + if (!SESSION_ID_RE.test(agentId)) { + return errorResponse(400, "Invalid agent ID"); + } + + const subResource = parts[5] ?? ""; + + // GET .../subagents/{agentId}/wire + if (subResource === "wire" && req.method === "GET") { + const subDir = join(sessionDir, "subagents", agentId); + const wirePath = join(subDir, "wire.jsonl"); + if (!existsSync(wirePath)) return jsonResponse({ total: 0, events: [] }); + + const content = await Bun.file(wirePath).text(); + const events: Array> = []; + let index = 0; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + const parsed = parseWireFileLine(line); + if (isWireFileMetadata(parsed)) continue; + const record = parsed as WireMessageRecord; + events.push({ + index, + timestamp: record.timestamp, + type: record.message.type, + payload: record.message.payload, + }); + index++; + } catch { + // skip + } + } + return jsonResponse({ total: events.length, events }); + } + + // GET .../subagents/{agentId}/context + if (subResource === "context" && req.method === "GET") { + const contextPath = join(sessionDir, "subagents", agentId, "context.jsonl"); + if (!existsSync(contextPath)) return jsonResponse({ total: 0, messages: [] }); + + const content = await Bun.file(contextPath).text(); + const messages: Array> = []; + let index = 0; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + try { + const msg = JSON.parse(line); + msg.index = index; + messages.push(msg); + index++; + } catch { + // skip + } + } + return jsonResponse({ total: messages.length, messages }); + } + + // GET .../subagents/{agentId}/meta + if (subResource === "meta" && req.method === "GET") { + const metaPath = join(sessionDir, "subagents", agentId, "meta.json"); + if (!existsSync(metaPath)) return errorResponse(404, "Sub-agent not found"); + try { + return jsonResponse(JSON.parse(await Bun.file(metaPath).text())); + } catch { + return errorResponse(500, "Invalid meta.json"); + } + } + } + + return errorResponse(404, "Not found"); +} diff --git a/src/kimi_cli_ts/vis/api/statistics.ts b/src/kimi_cli_ts/vis/api/statistics.ts new file mode 100644 index 000000000..e809cddc0 --- /dev/null +++ b/src/kimi_cli_ts/vis/api/statistics.ts @@ -0,0 +1,259 @@ +/** + * Vis API for aggregate statistics across all sessions. + * Corresponds to Python vis/api/statistics.py + */ + +import { join } from "node:path"; +import { existsSync, statSync, readdirSync, readFileSync } from "node:fs"; +import { getShareDir } from "../../share.ts"; +import { collectEvents } from "./sessions.ts"; +import { parseWireFileLine, type WireFileMetadata, type WireMessageRecord } from "../../wire/file.ts"; +import { loadMetadata } from "../../metadata.ts"; +import { createHash } from "node:crypto"; + +// ── Cache ───────────────────────────────────────────────── + +let _cache: { result: Record; timestamp: number } | null = null; +const CACHE_TTL = 60; // seconds + +// ── Helper ──────────────────────────────────────────────── + +function isWireFileMetadata(parsed: WireFileMetadata | WireMessageRecord): parsed is WireFileMetadata { + return "type" in parsed && (parsed as any).type === "metadata"; +} + +async function getWorkDirForHash(hashDirName: string): Promise { + try { + const metadata = await loadMetadata(); + for (const wd of metadata.workDirs) { + const pathMd5 = createHash("md5").update(wd.path, "utf-8").digest("hex"); + const dirBasename = wd.kaos === "local" ? pathMd5 : `${wd.kaos}_${pathMd5}`; + if (dirBasename === hashDirName) return wd.path; + } + } catch { + // Ignore + } + return null; +} + +// ── Route handler ───────────────────────────────────────── + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export async function handleStatisticsRoute(): Promise { + const now = Date.now() / 1000; + if (_cache && now - _cache.timestamp < CACHE_TTL) { + return jsonResponse(_cache.result); + } + + const sessionsRoot = join(getShareDir(), "sessions"); + if (!existsSync(sessionsRoot)) { + const empty = { + total_sessions: 0, + total_turns: 0, + total_tokens: { input: 0, output: 0 }, + total_duration_sec: 0, + tool_usage: [], + daily_usage: [], + per_project: [], + }; + _cache = { result: empty, timestamp: now }; + return jsonResponse(empty); + } + + let totalSessions = 0; + let totalTurns = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalDurationSec = 0; + + // tool_name -> { count, error_count } + const toolStats = new Map(); + + // date_str -> { sessions, turns } + const dailyStats = new Map(); + + // work_dir -> { sessions, turns } + const projectStats = new Map(); + + for (const workDirHashName of readdirSync(sessionsRoot)) { + const workDirHashDir = join(sessionsRoot, workDirHashName); + if (!statSync(workDirHashDir).isDirectory()) continue; + + const workDir = (await getWorkDirForHash(workDirHashName)) ?? workDirHashName; + + for (const sessionName of readdirSync(workDirHashDir)) { + const sessionDir = join(workDirHashDir, sessionName); + if (!statSync(sessionDir).isDirectory()) continue; + + const wirePath = join(sessionDir, "wire.jsonl"); + if (!existsSync(wirePath)) continue; + + totalSessions++; + let sessionTurns = 0; + let sessionInputTokens = 0; + let sessionOutputTokens = 0; + let firstTs = 0; + let lastTs = 0; + let sessionDate: string | null = null; + + const pendingTools = new Map(); + + try { + const content = readFileSync(wirePath, "utf-8"); + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + + let parsed: WireFileMetadata | WireMessageRecord; + try { + parsed = parseWireFileLine(line); + } catch { + continue; + } + if (isWireFileMetadata(parsed)) continue; + + const record = parsed as WireMessageRecord; + const ts = record.timestamp; + const msgType = record.message.type; + const payload = record.message.payload ?? {}; + + if (firstTs === 0) { + firstTs = ts; + try { + const dt = new Date(ts * 1000); + sessionDate = dt.toISOString().slice(0, 10); + } catch { + // Ignore + } + } + lastTs = ts; + + const eventsToProcess: Array<{ type: string; payload: Record }> = []; + collectEvents(msgType, payload, eventsToProcess); + + for (const ev of eventsToProcess) { + switch (ev.type) { + case "TurnBegin": + sessionTurns++; + break; + case "ToolCall": { + const fn = ev.payload.function; + const toolId = ev.payload.id ?? ""; + if (fn && typeof fn === "object") { + const name = fn.name ?? "unknown"; + const stats = toolStats.get(name) ?? { count: 0, error_count: 0 }; + stats.count++; + toolStats.set(name, stats); + if (toolId) pendingTools.set(toolId, name); + } + break; + } + case "ToolResult": { + const toolCallId = ev.payload.tool_call_id ?? ""; + const rv = ev.payload.return_value; + if (rv && typeof rv === "object" && rv.is_error) { + const toolName = pendingTools.get(toolCallId); + if (toolName) { + const stats = toolStats.get(toolName); + if (stats) stats.error_count++; + } + } + pendingTools.delete(toolCallId); + break; + } + case "StatusUpdate": { + const tu = ev.payload.token_usage; + if (tu && typeof tu === "object") { + sessionInputTokens += + (Number(tu.input_other) || 0) + + (Number(tu.input_cache_read) || 0) + + (Number(tu.input_cache_creation) || 0); + sessionOutputTokens += Number(tu.output) || 0; + } + break; + } + } + } + } + } catch { + continue; + } + + totalTurns += sessionTurns; + totalInputTokens += sessionInputTokens; + totalOutputTokens += sessionOutputTokens; + + const duration = lastTs > firstTs ? lastTs - firstTs : 0; + totalDurationSec += duration; + + if (sessionDate) { + const ds = dailyStats.get(sessionDate) ?? { sessions: 0, turns: 0 }; + ds.sessions++; + ds.turns += sessionTurns; + dailyStats.set(sessionDate, ds); + } + + const ps = projectStats.get(workDir) ?? { sessions: 0, turns: 0 }; + ps.sessions++; + ps.turns += sessionTurns; + projectStats.set(workDir, ps); + } + } + + // Build tool_usage: top 20 by count + const toolUsage = [...toolStats.entries()] + .map(([name, stats]) => ({ + name, + count: stats.count, + error_count: stats.error_count, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + // Build daily_usage: last 30 days + const today = new Date(); + const dailyUsage: Array> = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().slice(0, 10); + const entry = dailyStats.get(dateStr) ?? { sessions: 0, turns: 0 }; + dailyUsage.push({ + date: dateStr, + sessions: entry.sessions, + turns: entry.turns, + }); + } + + // Build per_project: top 10 by turns + const perProject = [...projectStats.entries()] + .map(([workDir, stats]) => ({ + work_dir: workDir, + sessions: stats.sessions, + turns: stats.turns, + })) + .sort((a, b) => b.turns - a.turns) + .slice(0, 10); + + const result = { + total_sessions: totalSessions, + total_turns: totalTurns, + total_tokens: { input: totalInputTokens, output: totalOutputTokens }, + total_duration_sec: totalDurationSec, + tool_usage: toolUsage, + daily_usage: dailyUsage, + per_project: perProject, + }; + + _cache = { result, timestamp: now }; + return jsonResponse(result); +} diff --git a/src/kimi_cli_ts/vis/api/system.ts b/src/kimi_cli_ts/vis/api/system.ts new file mode 100644 index 000000000..a80cd663b --- /dev/null +++ b/src/kimi_cli_ts/vis/api/system.ts @@ -0,0 +1,23 @@ +/** + * Vis API for server capabilities and metadata. + * Corresponds to Python vis/api/system.py + */ + +import type { VisAppState } from "../app.ts"; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export function handleSystemRoute(state: VisAppState): Response { + return jsonResponse({ + open_in_supported: + (process.platform === "darwin" || process.platform === "win32") && !state.restrictOpenIn, + }); +} diff --git a/src/kimi_cli_ts/vis/app.ts b/src/kimi_cli_ts/vis/app.ts new file mode 100644 index 000000000..1229726f4 --- /dev/null +++ b/src/kimi_cli_ts/vis/app.ts @@ -0,0 +1,371 @@ +/** + * Kimi Agent Tracing Visualizer application. + * Corresponds to Python vis/app.py + * + * Uses Bun.serve instead of FastAPI + uvicorn. + */ + +import { join, resolve } from "node:path"; +import { existsSync, statSync, readFileSync } from "node:fs"; +import { + findAvailablePort, + formatUrl, + getNetworkAddresses, + isLocalHost, + printBanner, +} from "../utils/server.ts"; +import { handleSessionsRoute } from "./api/sessions.ts"; +import { handleStatisticsRoute } from "./api/statistics.ts"; +import { handleSystemRoute } from "./api/system.ts"; + +const STATIC_DIR = join(import.meta.dir, "..", "..", "kimi_cli", "vis", "static"); +const GZIP_MINIMUM_SIZE = 1024; +const DEFAULT_PORT = 5495; +const ENV_RESTRICT_OPEN_IN = "KIMI_VIS_RESTRICT_OPEN_IN"; + +// ── Request context ─────────────────────────────────────── + +export interface VisAppState { + restrictOpenIn: boolean; +} + +// ── MIME helper ─────────────────────────────────────────── + +function getMimeType(path: string): string { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".ico")) return "image/x-icon"; + if (path.endsWith(".woff2")) return "font/woff2"; + if (path.endsWith(".woff")) return "font/woff"; + return "application/octet-stream"; +} + +// ── CORS helper ─────────────────────────────────────────── + +function corsHeaders(): Record { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + }; +} + +function jsonResponse(data: unknown, status = 200): Response { + const body = JSON.stringify(data); + return new Response(body, { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...corsHeaders(), + }, + }); +} + +function errorResponse(status: number, detail: string): Response { + return jsonResponse({ detail }, status); +} + +// ── Open-in handler (imported from web/api) ─────────────── + +async function handleOpenIn(req: Request): Promise { + const { platform } = process; + if (platform !== "darwin" && platform !== "win32") { + return errorResponse(400, "Open-in is only supported on macOS and Windows."); + } + + let body: { app: string; path: string }; + try { + body = (await req.json()) as { app: string; path: string }; + } catch { + return errorResponse(400, "Invalid JSON body"); + } + + const validApps = ["finder", "cursor", "vscode", "iterm", "terminal", "antigravity"]; + if (!validApps.includes(body.app)) { + return errorResponse(400, `Unsupported app: ${body.app}`); + } + + const resolvedPath = resolve(body.path); + if (!existsSync(resolvedPath)) { + return errorResponse(400, `Path does not exist: ${body.path}`); + } + + const isFile = statSync(resolvedPath).isFile(); + + try { + if (platform === "darwin") { + await openInMacOS(body.app, resolvedPath, isFile); + } else { + await openInWindows(body.app, resolvedPath, isFile); + } + } catch (err) { + const detail = err instanceof Error ? err.message : "Failed to open application."; + return errorResponse(500, detail); + } + + return jsonResponse({ ok: true }); +} + +async function openInMacOS(app: string, path: string, isFile: boolean): Promise { + const proc = (args: string[]) => Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + + switch (app) { + case "finder": + if (isFile) { + await proc(["open", "-R", path]).exited; + } else { + await proc(["open", path]).exited; + } + break; + case "cursor": + await proc(["open", "-a", "Cursor", path]).exited; + break; + case "vscode": + try { + await proc(["open", "-a", "Visual Studio Code", path]).exited; + } catch { + await proc(["open", "-a", "Code", path]).exited; + } + break; + case "antigravity": + await proc(["open", "-a", "Antigravity", path]).exited; + break; + case "iterm": { + const dir = isFile ? resolve(path, "..") : path; + const script = [ + 'tell application "iTerm"', + " create window with default profile", + " tell current session of current window", + ` write text "cd " & quoted form of "${dir}"`, + " end tell", + "end tell", + ].join("\n"); + try { + await proc(["osascript", "-e", script]).exited; + } catch { + await proc(["osascript", "-e", script.replace('"iTerm"', '"iTerm2"')]).exited; + } + break; + } + case "terminal": { + const dir = isFile ? resolve(path, "..") : path; + const script = `tell application "Terminal" to do script "cd " & quoted form of "${dir}"`; + await proc(["osascript", "-e", script]).exited; + break; + } + } +} + +async function openInWindows(app: string, path: string, isFile: boolean): Promise { + const proc = (args: string[]) => Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + + switch (app) { + case "finder": + if (isFile) { + Bun.spawn(["explorer", `/select,${path}`]); + } else { + Bun.spawn(["explorer", path]); + } + break; + case "cursor": + await proc(["cmd", "/c", "start", "", "cursor", path]).exited; + break; + case "vscode": + await proc(["cmd", "/c", "start", "", "code", path]).exited; + break; + case "terminal": { + const dir = isFile ? resolve(path, "..") : path; + try { + await proc(["cmd", "/c", "start", "", "wt.exe", "-d", dir]).exited; + } catch { + await proc(["cmd", "/c", "start", "", "cmd.exe", "/K", `cd /d "${dir}"`]).exited; + } + break; + } + case "iterm": + case "antigravity": + throw new Error(`${app} is not supported on Windows.`); + } +} + +// ── Static file serving ─────────────────────────────────── + +function serveStaticFile(pathname: string): Response | null { + if (!existsSync(STATIC_DIR)) return null; + + let filePath = join(STATIC_DIR, pathname); + + // Try exact path, then index.html for SPA + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + filePath = join(STATIC_DIR, "index.html"); + if (!existsSync(filePath)) return null; + } + + const content = readFileSync(filePath); + return new Response(content, { + headers: { + "Content-Type": getMimeType(filePath), + ...corsHeaders(), + }, + }); +} + +// ── Server factory ──────────────────────────────────────── + +export function createVisServer( + host: string, + port: number, + state: VisAppState, +): ReturnType { + return Bun.serve({ + hostname: host, + port, + async fetch(req) { + const url = new URL(req.url); + const { pathname } = url; + + // CORS preflight + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + + // Health check + if (pathname === "/healthz" && req.method === "GET") { + return jsonResponse({ status: "ok" }); + } + + // API routes + if (pathname.startsWith("/api/vis/")) { + const apiPath = pathname.slice("/api/vis".length); + + // Sessions routes + if (apiPath.startsWith("/sessions")) { + return handleSessionsRoute(req, url, apiPath); + } + + // Statistics + if (apiPath === "/statistics" && req.method === "GET") { + return handleStatisticsRoute(); + } + + // System / capabilities + if (apiPath === "/capabilities" && req.method === "GET") { + return handleSystemRoute(state); + } + + return errorResponse(404, "Not found"); + } + + // Open-in API (only in local mode) + if (pathname === "/api/open-in" && req.method === "POST" && !state.restrictOpenIn) { + return handleOpenIn(req); + } + + // Static files + const staticResponse = serveStaticFile(pathname); + if (staticResponse) return staticResponse; + + return errorResponse(404, "Not found"); + }, + }); +} + +// ── Server runner ───────────────────────────────────────── + +export async function runVisServer(options?: { + host?: string; + port?: number; + openBrowser?: boolean; +}): Promise { + const host = options?.host ?? "127.0.0.1"; + const port = options?.port ?? DEFAULT_PORT; + const openBrowser = options?.openBrowser ?? true; + + const actualPort = await findAvailablePort(host, port); + if (actualPort !== port) { + console.log(`\nPort ${port} is in use, using port ${actualPort} instead`); + } + + const publicMode = !isLocalHost(host); + + const state: VisAppState = { + restrictOpenIn: publicMode, + }; + + // Build display hosts + const displayHosts: Array<{ label: string; host: string }> = []; + if (host === "0.0.0.0") { + displayHosts.push({ label: "Local", host: "localhost" }); + for (const addr of getNetworkAddresses()) { + displayHosts.push({ label: "Network", host: addr }); + } + } else { + const label = isLocalHost(host) ? "Local" : "Network"; + displayHosts.push({ label, host }); + } + + const browserHost = host === "0.0.0.0" ? "localhost" : host; + const browserUrl = formatUrl(browserHost, actualPort); + + const bannerLines: string[] = [ + "
\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557", + "
\u2588\u2588\u2551 \u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d", + "
\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557", + "
\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2557 \u2588\u2588\u2554\u255d\u2588\u2588\u2551\u255a\u2550\u2550\u2550\u2550\u2588\u2588\u2551", + "
\u2588\u2588\u2551 \u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551", + "
\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d", + "", + "
AGENT TRACING VISUALIZER (Technical Preview)", + "", + "
", + "", + ]; + + for (const dh of displayHosts) { + bannerLines.push(` \u279c ${dh.label.padEnd(8)} ${formatUrl(dh.host, actualPort)}`); + } + + bannerLines.push(""); + bannerLines.push("
"); + bannerLines.push(""); + + if (!publicMode) { + bannerLines.push(" Tips:"); + bannerLines.push(" \u2022 Use -n / --network to share on LAN"); + bannerLines.push(""); + } else { + bannerLines.push(" This feature is in Technical Preview and may be unstable."); + bannerLines.push(" Please report issues to the kimi-cli team."); + bannerLines.push(""); + } + + printBanner(bannerLines); + + const server = createVisServer(host, actualPort, state); + + if (openBrowser) { + setTimeout(async () => { + const { exec } = await import("node:child_process"); + const { platform } = process; + const cmd = + platform === "darwin" + ? `open "${browserUrl}"` + : platform === "win32" + ? `start "" "${browserUrl}"` + : `xdg-open "${browserUrl}"`; + exec(cmd); + }, 1500); + } + + console.log(`\nServer running on ${host}:${actualPort}`); + + // Keep alive — Bun.serve runs in the background, we just need to not exit + await new Promise(() => { + // Intentionally never resolves — server runs until process is killed + }); +} diff --git a/src/kimi_cli_ts/vis/index.ts b/src/kimi_cli_ts/vis/index.ts new file mode 100644 index 000000000..6c6a1a176 --- /dev/null +++ b/src/kimi_cli_ts/vis/index.ts @@ -0,0 +1,6 @@ +/** + * Vis module — corresponds to Python vis/__init__.py + * Barrel exports for the tracing visualizer. + */ + +export { createVisServer, runVisServer } from "./app.ts"; diff --git a/src/kimi_cli_ts/web/api/config.ts b/src/kimi_cli_ts/web/api/config.ts new file mode 100644 index 000000000..0e0ece0bd --- /dev/null +++ b/src/kimi_cli_ts/web/api/config.ts @@ -0,0 +1,191 @@ +/** + * Web API config — corresponds to Python web/api/config.py + * Global configuration routes (get/update model settings, raw config.toml). + */ + +import { existsSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; +import { getShareDir } from "../../config.ts"; +import { join } from "node:path"; +import type { KimiCLIRunner } from "../runner/process.ts"; + +// ── Types ──────────────────────────────────────────────── + +interface ConfigModel { + name: string; + provider_type: string; + model: string; + base_url?: string; + api_key?: string; + max_context_size?: number; + max_output_tokens?: number; + temperature?: number; + top_p?: number; + capabilities?: string[]; +} + +interface GlobalConfig { + default_model: string | null; + default_thinking: boolean; + models: ConfigModel[]; +} + +interface UpdateGlobalConfigRequest { + default_model?: string; + default_thinking?: boolean; +} + +interface UpdateGlobalConfigResponse { + config: GlobalConfig; + restarted_sessions: string[]; + skipped_busy_sessions: string[]; +} + +// ── Helpers ────────────────────────────────────────────── + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +function getConfigTomlPath(): string { + return join(getShareDir(), "config.toml"); +} + +function buildGlobalConfig(): GlobalConfig { + const configPath = getConfigTomlPath(); + let defaultModel: string | null = null; + let defaultThinking = false; + const models: ConfigModel[] = []; + + if (existsSync(configPath)) { + try { + const raw = readFileSync(configPath, "utf-8"); + // Simple TOML parsing for the fields we need + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("default_model")) { + const match = trimmed.match(/=\s*"(.+)"/); + if (match) defaultModel = match[1]!; + } + if (trimmed.startsWith("default_thinking")) { + defaultThinking = trimmed.includes("true"); + } + } + } catch { + // ignore + } + } + + return { default_model: defaultModel, default_thinking: defaultThinking, models }; +} + +// ── Route handler ──────────────────────────────────────── + +export async function handleConfigRoute( + req: Request, + url: URL, + apiPath: string, + runner: KimiCLIRunner, + restrictSensitiveApis: boolean, +): Promise { + // GET /api/config/ + if (apiPath === "/config" && req.method === "GET") { + return jsonResponse(buildGlobalConfig()); + } + + // PATCH /api/config/ + if (apiPath === "/config" && req.method === "PATCH") { + if (restrictSensitiveApis) { + return jsonResponse({ detail: "Sensitive API restricted in public mode" }, 403); + } + + let body: UpdateGlobalConfigRequest; + try { + body = (await req.json()) as UpdateGlobalConfigRequest; + } catch { + return jsonResponse({ detail: "Invalid JSON body" }, 400); + } + + // Update config.toml — simplified version + const configPath = getConfigTomlPath(); + let content = ""; + if (existsSync(configPath)) { + content = readFileSync(configPath, "utf-8"); + } + + if (body.default_model !== undefined) { + if (content.includes("default_model")) { + content = content.replace( + /default_model\s*=\s*".+"/, + `default_model = "${body.default_model}"`, + ); + } else { + content += `\ndefault_model = "${body.default_model}"\n`; + } + } + if (body.default_thinking !== undefined) { + if (content.includes("default_thinking")) { + content = content.replace( + /default_thinking\s*=\s*(true|false)/, + `default_thinking = ${body.default_thinking}`, + ); + } else { + content += `\ndefault_thinking = ${body.default_thinking}\n`; + } + } + + writeFileSync(configPath, content); + + // Restart running workers + const summary = await runner.restartRunningWorkers("config_change"); + + const response: UpdateGlobalConfigResponse = { + config: buildGlobalConfig(), + restarted_sessions: summary.restartedSessionIds, + skipped_busy_sessions: summary.skippedBusySessionIds, + }; + + return jsonResponse(response); + } + + // GET /api/config/toml + if (apiPath === "/config/toml" && req.method === "GET") { + const configPath = getConfigTomlPath(); + let content = ""; + if (existsSync(configPath)) { + content = readFileSync(configPath, "utf-8"); + } + return jsonResponse({ content }); + } + + // PUT /api/config/toml + if (apiPath === "/config/toml" && req.method === "PUT") { + if (restrictSensitiveApis) { + return jsonResponse({ detail: "Sensitive API restricted in public mode" }, 403); + } + + let body: { content: string }; + try { + body = (await req.json()) as { content: string }; + } catch { + return jsonResponse({ detail: "Invalid JSON body" }, 400); + } + + if (typeof body.content !== "string") { + return jsonResponse({ detail: "content must be a string" }, 400); + } + + const configPath = getConfigTomlPath(); + writeFileSync(configPath, body.content); + + return jsonResponse({ content: body.content }); + } + + return jsonResponse({ detail: "Not found" }, 404); +} diff --git a/src/kimi_cli_ts/web/api/index.ts b/src/kimi_cli_ts/web/api/index.ts new file mode 100644 index 000000000..b1a172556 --- /dev/null +++ b/src/kimi_cli_ts/web/api/index.ts @@ -0,0 +1,7 @@ +/** + * Web API barrel — corresponds to Python web/api/__init__.py + */ + +export { handleSessionsRoute } from "./sessions.ts"; +export { handleConfigRoute } from "./config.ts"; +export { handleOpenInRoute } from "./open_in.ts"; diff --git a/src/kimi_cli_ts/web/api/open_in.ts b/src/kimi_cli_ts/web/api/open_in.ts new file mode 100644 index 000000000..5ba715863 --- /dev/null +++ b/src/kimi_cli_ts/web/api/open_in.ts @@ -0,0 +1,153 @@ +/** + * Web API open-in — corresponds to Python web/api/open_in.py + * Open files/folders in local applications (macOS, Windows). + * Nearly identical to vis/app.ts handleOpenIn, but as a standalone handler. + */ + +import { existsSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +// ── Types ──────────────────────────────────────────────── + +interface OpenInRequest { + app: string; + path: string; +} + +const VALID_APPS = ["finder", "cursor", "vscode", "iterm", "terminal", "antigravity"]; + +// ── Helpers ────────────────────────────────────────────── + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +// ── Platform-specific openers ──────────────────────────── + +async function openInMacOS(app: string, path: string, isFile: boolean): Promise { + const proc = (args: string[]) => Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + + switch (app) { + case "finder": + if (isFile) { + await proc(["open", "-R", path]).exited; + } else { + await proc(["open", path]).exited; + } + break; + case "cursor": + await proc(["open", "-a", "Cursor", path]).exited; + break; + case "vscode": + try { + await proc(["open", "-a", "Visual Studio Code", path]).exited; + } catch { + await proc(["open", "-a", "Code", path]).exited; + } + break; + case "antigravity": + await proc(["open", "-a", "Antigravity", path]).exited; + break; + case "iterm": { + const dir = isFile ? resolve(path, "..") : path; + const script = [ + 'tell application "iTerm"', + " create window with default profile", + " tell current session of current window", + ` write text "cd " & quoted form of "${dir}"`, + " end tell", + "end tell", + ].join("\n"); + try { + await proc(["osascript", "-e", script]).exited; + } catch { + await proc(["osascript", "-e", script.replace('"iTerm"', '"iTerm2"')]).exited; + } + break; + } + case "terminal": { + const dir = isFile ? resolve(path, "..") : path; + const script = `tell application "Terminal" to do script "cd " & quoted form of "${dir}"`; + await proc(["osascript", "-e", script]).exited; + break; + } + } +} + +async function openInWindows(app: string, path: string, isFile: boolean): Promise { + const proc = (args: string[]) => Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + + switch (app) { + case "finder": + if (isFile) { + Bun.spawn(["explorer", `/select,${path}`]); + } else { + Bun.spawn(["explorer", path]); + } + break; + case "cursor": + await proc(["cmd", "/c", "start", "", "cursor", path]).exited; + break; + case "vscode": + await proc(["cmd", "/c", "start", "", "code", path]).exited; + break; + case "terminal": { + const dir = isFile ? resolve(path, "..") : path; + try { + await proc(["cmd", "/c", "start", "", "wt.exe", "-d", dir]).exited; + } catch { + await proc(["cmd", "/c", "start", "", "cmd.exe", "/K", `cd /d "${dir}"`]).exited; + } + break; + } + case "iterm": + case "antigravity": + throw new Error(`${app} is not supported on Windows.`); + } +} + +// ── Route handler ──────────────────────────────────────── + +export async function handleOpenInRoute(req: Request): Promise { + const { platform } = process; + if (platform !== "darwin" && platform !== "win32") { + return jsonResponse({ detail: "Open-in is only supported on macOS and Windows." }, 400); + } + + let body: OpenInRequest; + try { + body = (await req.json()) as OpenInRequest; + } catch { + return jsonResponse({ detail: "Invalid JSON body" }, 400); + } + + if (!VALID_APPS.includes(body.app)) { + return jsonResponse({ detail: `Unsupported app: ${body.app}` }, 400); + } + + const resolvedPath = resolve(body.path); + if (!existsSync(resolvedPath)) { + return jsonResponse({ detail: `Path does not exist: ${body.path}` }, 400); + } + + const isFile = statSync(resolvedPath).isFile(); + + try { + if (platform === "darwin") { + await openInMacOS(body.app, resolvedPath, isFile); + } else { + await openInWindows(body.app, resolvedPath, isFile); + } + } catch (err) { + const detail = err instanceof Error ? err.message : "Failed to open application."; + return jsonResponse({ detail }, 500); + } + + return jsonResponse({ ok: true }); +} diff --git a/src/kimi_cli_ts/web/api/sessions.ts b/src/kimi_cli_ts/web/api/sessions.ts new file mode 100644 index 000000000..b825753c1 --- /dev/null +++ b/src/kimi_cli_ts/web/api/sessions.ts @@ -0,0 +1,708 @@ +/** + * Web API sessions — corresponds to Python web/api/sessions.py + * Session CRUD, file access, WebSocket streaming, work dirs, git diff. + */ + +import { join, resolve, relative, basename, dirname } from "node:path"; +import { + existsSync, + statSync, + readdirSync, + readFileSync, + lstatSync, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { ServerWebSocket } from "bun"; +import type { KimiCLIRunner, SessionProcess } from "../runner/process.ts"; +import { + loadSessionsPage, + loadSessionById, + getSessionIndexEntry, + invalidateSessionsCache, + runAutoArchive, + listWorkDirs, +} from "../store/sessions.ts"; +import { loadSessionState, saveSessionState } from "../../session_state.ts"; +import { sendHistoryComplete } from "../runner/messages.ts"; +import type { + WebSession, + UpdateSessionRequest, + CreateSessionRequest, + ForkSessionRequest, + GitDiffStats, + GitFileDiff, +} from "../models.ts"; +import { logger } from "../../utils/logging.ts"; +import { Session } from "../../session.ts"; +import { loadMetadata, getSessionsDir, getWorkDirMeta, newWorkDirMeta, saveMetadata } from "../../metadata.ts"; + +// ── Security constants ─────────────────────────────────── + +const SENSITIVE_PATH_PARTS = new Set([ + ".env", + ".git", + "node_modules", + "__pycache__", + ".ssh", + ".gnupg", + ".aws", + ".kube", + ".docker", +]); + +const SENSITIVE_PATH_EXTENSIONS = new Set([ + ".pem", + ".key", + ".p12", + ".pfx", + ".jks", + ".keystore", +]); + +const SENSITIVE_HOME_PATHS = new Set([ + ".bashrc", + ".zshrc", + ".bash_profile", + ".profile", + ".netrc", + ".npmrc", + ".pypirc", +]); + +const DEFAULT_MAX_PUBLIC_PATH_DEPTH = 6; + +// ── Security helpers ───────────────────────────────────── + +function sanitizeFilename(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_"); +} + +function relativeParts(basePath: string, targetPath: string): string[] { + const rel = relative(basePath, targetPath); + return rel.split("/").filter(Boolean); +} + +function isSensitiveRelativePath(parts: string[]): boolean { + for (const part of parts) { + if (SENSITIVE_PATH_PARTS.has(part)) return true; + const ext = part.includes(".") ? `.${part.split(".").pop()}` : ""; + if (SENSITIVE_PATH_EXTENSIONS.has(ext)) return true; + } + return false; +} + +function containsSymlink(path: string): boolean { + const parts = path.split("/"); + let current = "/"; + for (const part of parts) { + if (!part) continue; + current = join(current, part); + try { + if (lstatSync(current).isSymbolicLink()) return true; + } catch { + return false; + } + } + return false; +} + +function isPathInSensitiveLocation(path: string): boolean { + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (home && path.startsWith(home)) { + const rel = relative(home, path); + const first = rel.split("/")[0]; + if (first && SENSITIVE_HOME_PATHS.has(first)) return true; + } + return false; +} + +function ensurePublicFileAccessAllowed( + filePath: string, + workDir: string, + maxDepth: number, +): string | null { + const resolved = resolve(filePath); + + // Must be within work dir + if (!resolved.startsWith(resolve(workDir) + "/") && resolved !== resolve(workDir)) { + return "Path traversal detected"; + } + + // Symlink check + if (containsSymlink(resolved)) { + return "Symlink traversal not allowed"; + } + + // Depth check + const parts = relativeParts(workDir, resolved); + if (parts.length > maxDepth) { + return `Path depth exceeds limit (${maxDepth})`; + } + + // Sensitive path check + if (isSensitiveRelativePath(parts)) { + return "Access to sensitive path denied"; + } + + if (isPathInSensitiveLocation(resolved)) { + return "Access to sensitive location denied"; + } + + return null; +} + +// ── Wire replay ────────────────────────────────────────── + +function readWireLines(wireFile: string): string[] { + if (!existsSync(wireFile)) return []; + + const lines: string[] = []; + try { + const text = readFileSync(wireFile, "utf-8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + // Wrap each wire event in a JSON-RPC event envelope + try { + const event = JSON.parse(trimmed); + const envelope = { + jsonrpc: "2.0", + method: "event", + params: event, + }; + lines.push(JSON.stringify(envelope)); + } catch { + continue; + } + } + } catch { + // ignore + } + return lines; +} + +function replayHistory(ws: ServerWebSocket, sessionDir: string): void { + const wireFile = join(sessionDir, "wire.jsonl"); + const lines = readWireLines(wireFile); + for (const line of lines) { + try { + ws.send(line); + } catch { + break; + } + } + sendHistoryComplete(ws); +} + +// ── Helpers ────────────────────────────────────────────── + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +// ── Git diff ───────────────────────────────────────────── + +async function getGitDiffStats(workDir: string): Promise { + const result: GitDiffStats = { + is_git_repo: false, + has_changes: false, + total_additions: 0, + total_deletions: 0, + files: [], + error: null, + }; + + try { + // Check if git repo + const gitCheck = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], { + cwd: workDir, + stdout: "pipe", + stderr: "pipe", + }); + if (gitCheck.exitCode !== 0) return result; + result.is_git_repo = true; + + // Get diff stats + const diff = Bun.spawnSync(["git", "diff", "--numstat", "HEAD"], { + cwd: workDir, + stdout: "pipe", + stderr: "pipe", + }); + + const output = new TextDecoder().decode(diff.stdout).trim(); + if (!output) return result; + + for (const line of output.split("\n")) { + const parts = line.split("\t"); + if (parts.length < 3) continue; + + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0]!, 10) || 0; + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1]!, 10) || 0; + const path = parts[2]!; + + const file: GitFileDiff = { + path, + additions, + deletions, + status: additions > 0 && deletions === 0 ? "added" : deletions > 0 && additions === 0 ? "deleted" : "modified", + }; + + result.files.push(file); + result.total_additions += additions; + result.total_deletions += deletions; + } + + result.has_changes = result.files.length > 0; + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + } + + return result; +} + +// ── Route handler ──────────────────────────────────────── + +export async function handleSessionsRoute( + req: Request, + url: URL, + apiPath: string, + runner: KimiCLIRunner, + restrictSensitiveApis: boolean, + maxPublicPathDepth: number = DEFAULT_MAX_PUBLIC_PATH_DEPTH, +): Promise { + // GET /api/sessions/ — list sessions + if (apiPath === "/sessions" && req.method === "GET") { + // Trigger auto-archive (non-blocking) + runAutoArchive().catch(() => {}); + + const limit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10); + const offset = Number.parseInt(url.searchParams.get("offset") ?? "0", 10); + const query = url.searchParams.get("query") ?? undefined; + const archivedParam = url.searchParams.get("archived"); + const archived = archivedParam !== null ? archivedParam === "true" : undefined; + + const result = loadSessionsPage(limit, offset, query, archived); + return jsonResponse(result); + } + + // POST /api/sessions/ — create session + if (apiPath === "/sessions" && req.method === "POST") { + let body: CreateSessionRequest = {}; + try { + body = (await req.json()) as CreateSessionRequest; + } catch { + // empty body is ok + } + + const workDir = resolve(body.work_dir ?? process.cwd()); + + if (body.create_dir && !existsSync(workDir)) { + try { + mkdirSync(workDir, { recursive: true }); + } catch (err) { + return jsonResponse( + { detail: `Failed to create directory: ${err instanceof Error ? err.message : err}` }, + 400, + ); + } + } + + if (!existsSync(workDir)) { + return jsonResponse({ detail: `Work directory does not exist: ${workDir}` }, 400); + } + + try { + const session = await Session.create(workDir); + invalidateSessionsCache(); + const ws: WebSession = { + session_id: session.id, + title: session.title, + last_updated: new Date().toISOString(), + is_running: false, + status: null, + work_dir: workDir, + session_dir: session.dir, + archived: false, + }; + return jsonResponse(ws, 201); + } catch (err) { + return jsonResponse( + { detail: `Failed to create session: ${err instanceof Error ? err.message : err}` }, + 500, + ); + } + } + + // ── Session-specific routes (/sessions/{id}/...) ─────── + + const sessionIdMatch = apiPath.match(/^\/sessions\/([^/]+)(\/.*)?$/); + if (!sessionIdMatch) { + // Check for work-dirs routes + return handleWorkDirsRoute(req, url, apiPath); + } + + const sessionId = sessionIdMatch[1]!; + const subPath = sessionIdMatch[2] ?? ""; + + // GET /api/sessions/{id} + if (!subPath && req.method === "GET") { + const session = loadSessionById(sessionId); + if (!session) return jsonResponse({ detail: "Session not found" }, 404); + return jsonResponse(session); + } + + // DELETE /api/sessions/{id} + if (!subPath && req.method === "DELETE") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + // Stop the worker if running + const sp = runner.getSession(sessionId); + if (sp) await sp.stop(); + + // Delete session directory + try { + await Bun.$`rm -rf ${entry.sessionDir}`.quiet(); + } catch { + // ignore + } + + invalidateSessionsCache(); + return jsonResponse({ ok: true }); + } + + // PATCH /api/sessions/{id} + if (!subPath && req.method === "PATCH") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + let body: UpdateSessionRequest; + try { + body = (await req.json()) as UpdateSessionRequest; + } catch { + return jsonResponse({ detail: "Invalid JSON body" }, 400); + } + + const state = await loadSessionState(entry.sessionDir); + let changed = false; + + if (body.title !== undefined) { + if (body.title.length < 1 || body.title.length > 200) { + return jsonResponse({ detail: "Title must be 1-200 characters" }, 400); + } + state.custom_title = body.title; + changed = true; + } + + if (body.archived !== undefined) { + state.archived = body.archived; + if (body.archived) { + state.archived_at = Date.now() / 1000; + } else { + state.archived_at = null; + } + changed = true; + } + + if (changed) { + await saveSessionState(state, entry.sessionDir); + invalidateSessionsCache(); + } + + const session = loadSessionById(sessionId); + return jsonResponse(session); + } + + // POST /api/sessions/{id}/files — upload file + if (subPath === "/files" && req.method === "POST") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + const contentType = req.headers.get("content-type") ?? ""; + if (!contentType.includes("multipart/form-data")) { + return jsonResponse({ detail: "Expected multipart/form-data" }, 400); + } + + try { + const formData = await req.formData(); + const file = formData.get("file") as File | null; + if (!file) return jsonResponse({ detail: "No file uploaded" }, 400); + + // 100MB limit + if (file.size > 100 * 1024 * 1024) { + return jsonResponse({ detail: "File too large (max 100MB)" }, 400); + } + + const uploadsDir = join(entry.sessionDir, "uploads"); + mkdirSync(uploadsDir, { recursive: true }); + + const safeName = sanitizeFilename(file.name); + const filePath = join(uploadsDir, safeName); + const content = await file.arrayBuffer(); + writeFileSync(filePath, Buffer.from(content)); + + return jsonResponse({ + filename: safeName, + size: file.size, + content_type: file.type, + path: filePath, + }, 201); + } catch (err) { + return jsonResponse( + { detail: `Upload failed: ${err instanceof Error ? err.message : err}` }, + 500, + ); + } + } + + // GET /api/sessions/{id}/uploads/{path} — get uploaded file + const uploadsMatch = subPath.match(/^\/uploads\/(.+)$/); + if (uploadsMatch && req.method === "GET") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + const uploadsDir = join(entry.sessionDir, "uploads"); + const filePath = join(uploadsDir, uploadsMatch[1]!); + const resolved = resolve(filePath); + + // Path traversal protection + if (!resolved.startsWith(resolve(uploadsDir))) { + return jsonResponse({ detail: "Path traversal detected" }, 403); + } + + if (!existsSync(resolved)) { + return jsonResponse({ detail: "File not found" }, 404); + } + + const content = readFileSync(resolved); + return new Response(content, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${basename(resolved)}"`, + }, + }); + } + + // GET /api/sessions/{id}/files/{path} — get file from work dir + const filesMatch = subPath.match(/^\/files\/(.+)$/); + if (filesMatch && req.method === "GET") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + const requestedPath = filesMatch[1]!; + const filePath = resolve(join(entry.workDir, requestedPath)); + + // Security check + const securityError = ensurePublicFileAccessAllowed( + filePath, + entry.workDir, + maxPublicPathDepth, + ); + if (securityError) { + return jsonResponse({ detail: securityError }, 403); + } + + if (!existsSync(filePath)) { + return jsonResponse({ detail: "File not found" }, 404); + } + + const stat = statSync(filePath); + if (!stat.isFile()) { + return jsonResponse({ detail: "Not a file" }, 400); + } + + const content = readFileSync(filePath); + return new Response(content, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${basename(filePath)}"`, + }, + }); + } + + // POST /api/sessions/{id}/fork + if (subPath === "/fork" && req.method === "POST") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + let body: ForkSessionRequest; + try { + body = (await req.json()) as ForkSessionRequest; + } catch { + return jsonResponse({ detail: "Invalid JSON body" }, 400); + } + + try { + // Create new session + const newSession = await Session.create(entry.workDir); + + // Copy context up to turn_index + const contextFile = entry.contextFile; + if (existsSync(contextFile)) { + const text = readFileSync(contextFile, "utf-8"); + const lines = text.split("\n").filter(Boolean); + let turnCount = 0; + const forkedLines: string[] = []; + + for (const line of lines) { + try { + const record = JSON.parse(line); + if (record.role === "user") turnCount++; + if (turnCount > body.turn_index) break; + forkedLines.push(line); + } catch { + forkedLines.push(line); + } + } + + writeFileSync(newSession.contextFile, forkedLines.join("\n") + "\n"); + } + + invalidateSessionsCache(); + + const ws: WebSession = { + session_id: newSession.id, + title: `Fork of ${entry.title}`, + last_updated: new Date().toISOString(), + is_running: false, + status: null, + work_dir: entry.workDir, + session_dir: newSession.dir, + archived: false, + }; + return jsonResponse(ws, 201); + } catch (err) { + return jsonResponse( + { detail: `Fork failed: ${err instanceof Error ? err.message : err}` }, + 500, + ); + } + } + + // POST /api/sessions/{id}/generate-title + if (subPath === "/generate-title" && req.method === "POST") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + // Simple title generation: use first user message + let title = entry.title; + if (title === "Untitled" || !title) { + const wireFile = join(entry.sessionDir, "wire.jsonl"); + if (existsSync(wireFile)) { + const text = readFileSync(wireFile, "utf-8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed); + if (record.type === "turn_begin" && record.user_input) { + title = + typeof record.user_input === "string" + ? record.user_input.slice(0, 50) + : JSON.stringify(record.user_input).slice(0, 50); + break; + } + } catch { + continue; + } + } + } + } + + // Save as custom title + const state = await loadSessionState(entry.sessionDir); + state.custom_title = title; + state.title_generated = true; + state.title_generate_attempts = (state.title_generate_attempts ?? 0) + 1; + await saveSessionState(state, entry.sessionDir); + invalidateSessionsCache(); + + return jsonResponse({ title }); + } + + // GET /api/sessions/{id}/git-diff + if (subPath === "/git-diff" && req.method === "GET") { + const entry = getSessionIndexEntry(sessionId); + if (!entry) return jsonResponse({ detail: "Session not found" }, 404); + + const stats = await getGitDiffStats(entry.workDir); + return jsonResponse(stats); + } + + return jsonResponse({ detail: "Not found" }, 404); +} + +// ── WebSocket session stream handler ───────────────────── + +export function handleSessionStream( + ws: ServerWebSocket<{ sessionId: string }>, + runner: KimiCLIRunner, +): void { + const { sessionId } = ws.data; + const entry = getSessionIndexEntry(sessionId); + if (!entry) { + ws.close(4004, "Session not found"); + return; + } + + const sp = runner.getOrCreateSession(sessionId, entry.sessionDir); + + // Replay history first + replayHistory(ws, entry.sessionDir); + + // Then attach to live stream + sp.addWebsocketAndBeginReplay(ws as ServerWebSocket); + sp.endReplay(ws as ServerWebSocket); +} + +export function handleSessionStreamMessage( + ws: ServerWebSocket<{ sessionId: string }>, + message: string, + runner: KimiCLIRunner, +): void { + const { sessionId } = ws.data; + const sp = runner.getSession(sessionId); + if (!sp) return; + + sp.sendMessage(message).catch((err) => { + logger.warn(`Failed to send message to worker: ${err}`); + }); +} + +export function handleSessionStreamClose( + ws: ServerWebSocket<{ sessionId: string }>, + runner: KimiCLIRunner, +): void { + runner.detachWebsocket(ws as ServerWebSocket); +} + +// ── Work dirs routes ───────────────────────────────────── + +function handleWorkDirsRoute( + req: Request, + url: URL, + apiPath: string, +): Response { + // GET /api/work-dirs/ + if (apiPath === "/work-dirs" && req.method === "GET") { + const dirs = listWorkDirs(); + return jsonResponse({ work_dirs: dirs }); + } + + // GET /api/work-dirs/startup + if (apiPath === "/work-dirs/startup" && req.method === "GET") { + return jsonResponse({ work_dir: process.cwd() }); + } + + return jsonResponse({ detail: "Not found" }, 404); +} diff --git a/src/kimi_cli_ts/web/app.ts b/src/kimi_cli_ts/web/app.ts new file mode 100644 index 000000000..b5287448f --- /dev/null +++ b/src/kimi_cli_ts/web/app.ts @@ -0,0 +1,397 @@ +/** + * Kimi Web Server application — corresponds to Python web/app.py + * Uses Bun.serve instead of FastAPI + uvicorn. + */ + +import { join, resolve } from "node:path"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { ServerWebSocket } from "bun"; +import { + findAvailablePort, + formatUrl, + getNetworkAddresses, + isLocalHost, + printBanner, +} from "../utils/server.ts"; +import { + authCheck, + normalizeAllowedOrigins, + type AuthConfig, +} from "./auth.ts"; +import { KimiCLIRunner } from "./runner/process.ts"; +import { + handleSessionsRoute, + handleSessionStream, + handleSessionStreamMessage, + handleSessionStreamClose, +} from "./api/sessions.ts"; +import { handleConfigRoute } from "./api/config.ts"; +import { handleOpenInRoute } from "./api/open_in.ts"; +import { handleStatisticsRoute } from "../vis/api/statistics.ts"; +import { handleSystemRoute } from "../vis/api/system.ts"; + +// ── Constants ──────────────────────────────────────────── + +const STATIC_DIR = join(import.meta.dir, "..", "..", "kimi_cli", "web", "static"); +const DEFAULT_PORT = 5494; +const GZIP_MINIMUM_SIZE = 1024; + +// ── Environment variables ──────────────────────────────── + +const ENV_SESSION_TOKEN = "KIMI_WEB_SESSION_TOKEN"; +const ENV_ALLOWED_ORIGINS = "KIMI_WEB_ALLOWED_ORIGINS"; +const ENV_ENFORCE_ORIGIN = "KIMI_WEB_ENFORCE_ORIGIN"; +const ENV_RESTRICT_SENSITIVE_APIS = "KIMI_WEB_RESTRICT_SENSITIVE_APIS"; +const ENV_MAX_PUBLIC_PATH_DEPTH = "KIMI_WEB_MAX_PUBLIC_PATH_DEPTH"; +const ENV_LAN_ONLY = "KIMI_WEB_LAN_ONLY"; + +// ── App state ──────────────────────────────────────────── + +export interface WebAppState { + runner: KimiCLIRunner; + auth: AuthConfig; + restrictSensitiveApis: boolean; + restrictOpenIn: boolean; + maxPublicPathDepth: number; +} + +// ── MIME helper ────────────────────────────────────────── + +function getMimeType(path: string): string { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".ico")) return "image/x-icon"; + if (path.endsWith(".woff2")) return "font/woff2"; + if (path.endsWith(".woff")) return "font/woff"; + return "application/octet-stream"; +} + +// ── CORS / response helpers ────────────────────────────── + +function corsHeaders(): Record { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Credentials": "true", + }; +} + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...corsHeaders(), + }, + }); +} + +function errorResponse(status: number, detail: string): Response { + return jsonResponse({ detail }, status); +} + +// ── Static file serving with cache headers ─────────────── + +function serveStaticFile(pathname: string): Response | null { + if (!existsSync(STATIC_DIR)) return null; + + let filePath = join(STATIC_DIR, pathname); + + // Try exact path, then index.html for SPA + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + filePath = join(STATIC_DIR, "index.html"); + if (!existsSync(filePath)) return null; + } + + const content = readFileSync(filePath); + + // Cache headers: assets/ → immutable 1yr, .html → no-cache + let cacheControl = "no-cache"; + if (pathname.startsWith("/assets/")) { + cacheControl = "public, max-age=31536000, immutable"; + } + + return new Response(content, { + headers: { + "Content-Type": getMimeType(filePath), + "Cache-Control": cacheControl, + ...corsHeaders(), + }, + }); +} + +// ── Server factory ─────────────────────────────────────── + +export function createWebServer( + host: string, + port: number, + state: WebAppState, +): ReturnType { + return Bun.serve({ + hostname: host, + port, + async fetch(req, server) { + const url = new URL(req.url); + const { pathname } = url; + + // CORS preflight + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + + // Health check + if (pathname === "/healthz" && req.method === "GET") { + return jsonResponse({ status: "ok" }); + } + + // Auth check for API routes + const authResult = authCheck(req, url, state.auth, server); + if (authResult) return authResult; + + // ── WebSocket upgrade for /api/sessions/{id}/stream ── + const wsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/stream$/); + if (wsMatch && req.headers.get("upgrade")?.toLowerCase() === "websocket") { + const sessionId = wsMatch[1]!; + const upgraded = server.upgrade(req, { + data: { sessionId }, + }); + if (!upgraded) { + return errorResponse(500, "WebSocket upgrade failed"); + } + return undefined as unknown as Response; + } + + // ── API routes ── + + // Sessions routes + if (pathname.startsWith("/api/sessions") || pathname.startsWith("/api/work-dirs")) { + const apiPath = pathname.slice("/api".length); + return handleSessionsRoute( + req, + url, + apiPath, + state.runner, + state.restrictSensitiveApis, + state.maxPublicPathDepth, + ); + } + + // Config routes + if (pathname.startsWith("/api/config")) { + const apiPath = pathname.slice("/api".length); + return handleConfigRoute(req, url, apiPath, state.runner, state.restrictSensitiveApis); + } + + // Open-in API (only in local mode) + if (pathname === "/api/open-in" && req.method === "POST" && !state.restrictOpenIn) { + return handleOpenInRoute(req); + } + + // Vis API routes (reuse from vis module) + if (pathname.startsWith("/api/vis/")) { + const apiPath = pathname.slice("/api/vis".length); + + if (apiPath === "/statistics" && req.method === "GET") { + return handleStatisticsRoute(); + } + + if (apiPath === "/capabilities" && req.method === "GET") { + return handleSystemRoute({ + restrictOpenIn: state.restrictOpenIn, + }); + } + } + + // ── Static files ── + const staticResponse = serveStaticFile(pathname); + if (staticResponse) return staticResponse; + + return errorResponse(404, "Not found"); + }, + + websocket: { + open(ws: ServerWebSocket<{ sessionId: string }>) { + handleSessionStream(ws, state.runner); + }, + message(ws: ServerWebSocket<{ sessionId: string }>, message: string | Buffer) { + const data = typeof message === "string" ? message : message.toString(); + handleSessionStreamMessage(ws, data, state.runner); + }, + close(ws: ServerWebSocket<{ sessionId: string }>) { + handleSessionStreamClose(ws, state.runner); + }, + }, + }); +} + +// ── Server runner ──────────────────────────────────────── + +export async function runWebServer(options?: { + host?: string; + port?: number; + openBrowser?: boolean; + token?: string; + allowedOrigins?: string; + enforceOrigin?: boolean; + restrictSensitiveApis?: boolean; + maxPublicPathDepth?: number; + lanOnly?: boolean; +}): Promise { + const host = options?.host ?? "127.0.0.1"; + const port = options?.port ?? DEFAULT_PORT; + const openBrowser = options?.openBrowser ?? true; + + const actualPort = await findAvailablePort(host, port); + if (actualPort !== port) { + console.log(`\nPort ${port} is in use, using port ${actualPort} instead`); + } + + const publicMode = !isLocalHost(host); + + // Auth configuration + const token = + options?.token ?? + process.env[ENV_SESSION_TOKEN] ?? + (publicMode ? randomUUID() : null); + + const allowedOriginsRaw = + options?.allowedOrigins ?? process.env[ENV_ALLOWED_ORIGINS]; + + const enforceOrigin = + options?.enforceOrigin ?? + (process.env[ENV_ENFORCE_ORIGIN] === "true" || publicMode); + + const lanOnly = + options?.lanOnly ?? + (process.env[ENV_LAN_ONLY] === "true" || false); + + const restrictSensitiveApis = + options?.restrictSensitiveApis ?? + (process.env[ENV_RESTRICT_SENSITIVE_APIS] === "true" || publicMode); + + const maxPublicPathDepth = + options?.maxPublicPathDepth ?? + Number.parseInt(process.env[ENV_MAX_PUBLIC_PATH_DEPTH] ?? "6", 10); + + // Create runner + const runner = new KimiCLIRunner(); + await runner.start(); + + const state: WebAppState = { + runner, + auth: { + token, + allowedOrigins: normalizeAllowedOrigins(allowedOriginsRaw), + enforceOrigin, + lanOnly, + }, + restrictSensitiveApis, + restrictOpenIn: publicMode, + maxPublicPathDepth, + }; + + // Build display hosts + const displayHosts: Array<{ label: string; host: string }> = []; + if (host === "0.0.0.0") { + displayHosts.push({ label: "Local", host: "localhost" }); + for (const addr of getNetworkAddresses()) { + displayHosts.push({ label: "Network", host: addr }); + } + } else { + const label = isLocalHost(host) ? "Local" : "Network"; + displayHosts.push({ label, host }); + } + + const browserHost = host === "0.0.0.0" ? "localhost" : host; + const browserUrl = formatUrl(browserHost, actualPort); + + const bannerLines: string[] = [ + "
\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557", + "
\u2588\u2588\u2551 \u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d", + "
\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557", + "
\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2557 \u2588\u2588\u2554\u255d\u2588\u2588\u2551\u255a\u2550\u2550\u2550\u2550\u2588\u2588\u2551", + "
\u2588\u2588\u2551 \u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551", + "
\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u2550\u2550\u255d \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d", + "", + "
WEB SERVER", + "", + "
", + "", + ]; + + for (const dh of displayHosts) { + bannerLines.push(` \u279c ${dh.label.padEnd(8)} ${formatUrl(dh.host, actualPort)}`); + } + + bannerLines.push(""); + + if (token) { + bannerLines.push(` \u279c Token ${token}`); + bannerLines.push(""); + } + + bannerLines.push("
"); + bannerLines.push(""); + + if (publicMode) { + bannerLines.push(" \u26a0\ufe0f Public mode — security features enabled:"); + if (token) { + bannerLines.push(" \u2022 Bearer token authentication required"); + } + if (enforceOrigin) { + bannerLines.push(" \u2022 Origin enforcement enabled"); + } + if (restrictSensitiveApis) { + bannerLines.push(" \u2022 Sensitive APIs restricted"); + } + bannerLines.push(""); + } else { + bannerLines.push(" Tips:"); + bannerLines.push(" \u2022 Use -n / --network to share on LAN"); + bannerLines.push(""); + } + + printBanner(bannerLines); + + const server = createWebServer(host, actualPort, state); + + if (openBrowser) { + setTimeout(async () => { + const { exec } = await import("node:child_process"); + const { platform } = process; + const cmd = + platform === "darwin" + ? `open "${browserUrl}"` + : platform === "win32" + ? `start "" "${browserUrl}"` + : `xdg-open "${browserUrl}"`; + exec(cmd); + }, 1500); + } + + console.log(`\nWeb server running on ${host}:${actualPort}`); + + // Keep alive — Bun.serve runs in the background + process.on("SIGINT", async () => { + console.log("\nShutting down..."); + await runner.stop(); + server.stop(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + await runner.stop(); + server.stop(); + process.exit(0); + }); + + await new Promise(() => { + // Intentionally never resolves — server runs until process is killed + }); +} diff --git a/src/kimi_cli_ts/web/auth.ts b/src/kimi_cli_ts/web/auth.ts new file mode 100644 index 000000000..2709bf927 --- /dev/null +++ b/src/kimi_cli_ts/web/auth.ts @@ -0,0 +1,213 @@ +/** + * Web auth — corresponds to Python web/auth.py + * Authentication, origin checking, LAN-only enforcement, IP helpers. + */ + +import { timingSafeEqual, createHash } from "node:crypto"; + +// ── Constants ──────────────────────────────────────────── + +const DEFAULT_ALLOWED_ORIGIN_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; + +// ── Timing-safe compare ────────────────────────────────── + +export function timingSafeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf-8"); + const bufB = Buffer.from(b, "utf-8"); + if (bufA.length !== bufB.length) { + // Hash both to avoid leaking length info via timing + const hA = createHash("sha256").update(bufA).digest(); + const hB = createHash("sha256").update(bufB).digest(); + timingSafeEqual(hA, hB); + return false; + } + return timingSafeEqual(bufA, bufB); +} + +// ── Bearer token parsing ───────────────────────────────── + +export function parseBearerToken(value: string): string | null { + if (!value.startsWith("Bearer ")) return null; + const token = value.slice(7).trim(); + return token || null; +} + +// ── Origin checking ────────────────────────────────────── + +/** + * Parse comma-separated origins string into array. + * Returns null if value is empty/undefined (meaning "use default localhost regex"). + */ +export function normalizeAllowedOrigins(value: string | undefined): string[] | null { + if (!value) return null; + const origins = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return origins.length > 0 ? origins : null; +} + +/** + * Check if an origin is allowed. + * - allowedOrigins === null → use default localhost regex + * - allowedOrigins === [] → reject all + * - allowedOrigins includes "*" → allow all + * - otherwise → exact match + */ +export function isOriginAllowed( + origin: string | null, + allowedOrigins: string[] | null, +): boolean { + if (!origin) return false; + + if (allowedOrigins === null) { + return DEFAULT_ALLOWED_ORIGIN_REGEX.test(origin); + } + + if (allowedOrigins.length === 0) return false; + if (allowedOrigins.includes("*")) return true; + + return allowedOrigins.includes(origin); +} + +// ── Token extraction ───────────────────────────────────── + +/** + * Extract auth token from request: Authorization header, or query param (GET only). + */ +export function extractTokenFromRequest(req: Request, url: URL): string | null { + const authHeader = req.headers.get("authorization"); + if (authHeader) { + return parseBearerToken(authHeader); + } + + // For GET requests, also check query param + if (req.method === "GET") { + const token = url.searchParams.get("token"); + if (token) return token; + } + + return null; +} + +// ── Token verification ─────────────────────────────────── + +export function verifyToken(provided: string | null, expected: string | null): boolean { + if (!expected) return true; // No token configured = open access + if (!provided) return false; + return timingSafeCompare(provided, expected); +} + +// ── IP helpers ─────────────────────────────────────────── + +/** + * Check if an IP address is a private/local address (RFC 1918, loopback, link-local). + */ +export function isPrivateIp(ip: string): boolean { + // IPv4 loopback + if (ip === "127.0.0.1" || ip.startsWith("127.")) return true; + // IPv6 loopback + if (ip === "::1") return true; + // RFC 1918 + if (ip.startsWith("10.")) return true; + if (ip.startsWith("172.")) { + const second = Number.parseInt(ip.split(".")[1]!, 10); + if (second >= 16 && second <= 31) return true; + } + if (ip.startsWith("192.168.")) return true; + // Link-local + if (ip.startsWith("169.254.")) return true; + // IPv6 link-local + if (ip.toLowerCase().startsWith("fe80:")) return true; + // IPv6 unique local (fd00::/8) + if (ip.toLowerCase().startsWith("fd")) return true; + + return false; +} + +/** + * Get the client IP from a request, optionally trusting X-Forwarded-For. + */ +export function getClientIp( + req: Request, + server: { requestIP?: (req: Request) => { address: string } | null }, + trustProxy = false, +): string | null { + if (trustProxy) { + const xff = req.headers.get("x-forwarded-for"); + if (xff) { + const first = xff.split(",")[0]!.trim(); + if (first) return first; + } + } + + if (server.requestIP) { + const info = server.requestIP(req); + if (info) return info.address; + } + + return null; +} + +// ── Auth check (inline middleware equivalent) ──────────── + +export interface AuthConfig { + token: string | null; + allowedOrigins: string[] | null; + enforceOrigin: boolean; + lanOnly: boolean; +} + +/** + * Run authentication checks for an API request. + * Returns null if the request is allowed, or a Response to return. + */ +export function authCheck( + req: Request, + url: URL, + config: AuthConfig, + server: { requestIP?: (req: Request) => { address: string } | null }, +): Response | null { + const { pathname } = url; + + // Skip auth for non-API routes, OPTIONS, healthz + if (!pathname.startsWith("/api/")) return null; + if (req.method === "OPTIONS") return null; + if (pathname === "/healthz") return null; + + // LAN-only check + if (config.lanOnly) { + const clientIp = getClientIp(req, server); + if (clientIp && !isPrivateIp(clientIp)) { + return new Response(JSON.stringify({ detail: "Forbidden: non-LAN client" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + } + + // Origin check + if (config.enforceOrigin) { + const origin = req.headers.get("origin"); + // For requests with an Origin header, validate it + if (origin && !isOriginAllowed(origin, config.allowedOrigins)) { + return new Response(JSON.stringify({ detail: "Forbidden: origin not allowed" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + } + + // Token check + if (config.token) { + const provided = extractTokenFromRequest(req, url); + if (!verifyToken(provided, config.token)) { + return new Response(JSON.stringify({ detail: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + } + + return null; +} diff --git a/src/kimi_cli_ts/web/index.ts b/src/kimi_cli_ts/web/index.ts new file mode 100644 index 000000000..1b64cad12 --- /dev/null +++ b/src/kimi_cli_ts/web/index.ts @@ -0,0 +1,13 @@ +/** + * Web barrel — corresponds to Python web/__init__.py + */ + +export { createWebServer, runWebServer } from "./app.ts"; +export type { WebAppState } from "./app.ts"; +export type { + SessionRunState, + SessionStatus, + WebSession, + GitDiffStats, + GitFileDiff, +} from "./models.ts"; diff --git a/src/kimi_cli_ts/web/models.ts b/src/kimi_cli_ts/web/models.ts new file mode 100644 index 000000000..4855e778d --- /dev/null +++ b/src/kimi_cli_ts/web/models.ts @@ -0,0 +1,101 @@ +/** + * Web models — corresponds to Python web/models.py + * Uses plain TS interfaces (no Zod needed for simple request/response shapes). + * + * NOTE: Python defines `SessionState` as a string literal type here, but TS + * `session_state.ts` already exports a `SessionState` Zod schema, so we call + * the web version `SessionRunState` to avoid the naming conflict. + */ + +// ── Session run state (NOT the same as session_state.ts SessionState) ── + +export type SessionRunState = "stopped" | "idle" | "busy" | "restarting" | "error"; + +// ── Session status (real-time worker status) ── + +export interface SessionStatus { + session_id: string; + state: SessionRunState; + seq: number; + worker_id: string | null; + reason: string | null; + detail: string | null; + updated_at: string; // ISO datetime +} + +// ── Session notice (sent to WebSocket clients) ── + +export interface SessionNoticePayload { + text: string; + kind: "restart"; + reason: string | null; + restart_ms: number | null; +} + +export interface SessionNoticeEvent { + type: "SessionNotice"; + payload: SessionNoticePayload; +} + +// ── Git diff ── + +export interface GitFileDiff { + path: string; + additions: number; + deletions: number; + status: "added" | "modified" | "deleted" | "renamed"; +} + +export interface GitDiffStats { + is_git_repo: boolean; + has_changes: boolean; + total_additions: number; + total_deletions: number; + files: GitFileDiff[]; + error: string | null; +} + +// ── Session (list / detail model) ── + +export interface WebSession { + session_id: string; + title: string; + last_updated: string; // ISO datetime + is_running: boolean; + status: SessionStatus | null; + work_dir: string; + session_dir: string; + archived: boolean; +} + +// ── Requests / Responses ── + +export interface UpdateSessionRequest { + title?: string; // 1-200 chars + archived?: boolean; +} + +export interface GenerateTitleRequest { + user_message?: string; + assistant_response?: string; +} + +export interface GenerateTitleResponse { + title: string; +} + +export interface CreateSessionRequest { + work_dir?: string; + create_dir?: boolean; +} + +export interface ForkSessionRequest { + turn_index: number; +} + +export interface UploadSessionFileResponse { + filename: string; + size: number; + content_type: string; + path: string; +} diff --git a/src/kimi_cli_ts/web/runner/index.ts b/src/kimi_cli_ts/web/runner/index.ts new file mode 100644 index 000000000..98526e597 --- /dev/null +++ b/src/kimi_cli_ts/web/runner/index.ts @@ -0,0 +1,11 @@ +/** + * Web runner barrel — corresponds to Python web/runner/__init__.py + */ + +export { SessionProcess, KimiCLIRunner } from "./process.ts"; +export type { RestartWorkersSummary } from "./process.ts"; +export { + newSessionStatusMessage, + newHistoryCompleteMessage, + sendHistoryComplete, +} from "./messages.ts"; diff --git a/src/kimi_cli_ts/web/runner/messages.ts b/src/kimi_cli_ts/web/runner/messages.ts new file mode 100644 index 000000000..4bf21d9b7 --- /dev/null +++ b/src/kimi_cli_ts/web/runner/messages.ts @@ -0,0 +1,53 @@ +/** + * Web runner messages — corresponds to Python web/runner/messages.py + * JSON-RPC message helpers for session status and history replay. + */ + +import { randomUUID } from "node:crypto"; +import type { SessionStatus } from "../models.ts"; +import type { ServerWebSocket } from "bun"; + +// ── JSON-RPC session status notification ───────────────── + +export interface JSONRPCSessionStatusMessage { + jsonrpc: "2.0"; + method: "session_status"; + params: SessionStatus; +} + +export function newSessionStatusMessage(status: SessionStatus): JSONRPCSessionStatusMessage { + return { + jsonrpc: "2.0", + method: "session_status", + params: status, + }; +} + +// ── JSON-RPC history_complete notification ──────────────── + +export interface JSONRPCHistoryCompleteMessage { + jsonrpc: "2.0"; + method: "history_complete"; + id: string; +} + +export function newHistoryCompleteMessage(): JSONRPCHistoryCompleteMessage { + return { + jsonrpc: "2.0", + method: "history_complete", + id: randomUUID(), + }; +} + +/** + * Send history_complete message to a WebSocket. + * Returns true if sent successfully, false otherwise. + */ +export function sendHistoryComplete(ws: ServerWebSocket): boolean { + try { + ws.send(JSON.stringify(newHistoryCompleteMessage())); + return true; + } catch { + return false; + } +} diff --git a/src/kimi_cli_ts/web/runner/process.ts b/src/kimi_cli_ts/web/runner/process.ts new file mode 100644 index 000000000..6c621fbf9 --- /dev/null +++ b/src/kimi_cli_ts/web/runner/process.ts @@ -0,0 +1,400 @@ +/** + * Web runner process — corresponds to Python web/runner/process.py + * SessionProcess manages a single session's worker subprocess + WebSocket fanout. + * KimiCLIRunner manages multiple SessionProcess instances. + */ + +import { join } from "node:path"; +import type { Subprocess } from "bun"; +import type { ServerWebSocket } from "bun"; +import type { SessionRunState, SessionStatus, SessionNoticeEvent } from "../models.ts"; +import { newSessionStatusMessage, sendHistoryComplete } from "./messages.ts"; +import { logger } from "../../utils/logging.ts"; + +// ── SessionProcess ─────────────────────────────────────── + +export class SessionProcess { + readonly sessionId: string; + readonly sessionDir: string; + private worker: Subprocess | null = null; + private workerId: string | null = null; + private _state: SessionRunState = "stopped"; + private _seq = 0; + private _reason: string | null = null; + private _detail: string | null = null; + private websockets = new Set>(); + private replayMode = new Set>(); + private replayBuffer: string[] = []; + private inFlightPrompts = new Set(); + private _readLoopPromise: Promise | null = null; + + constructor(sessionId: string, sessionDir: string) { + this.sessionId = sessionId; + this.sessionDir = sessionDir; + } + + get isAlive(): boolean { + return this.worker !== null; + } + + get isRunning(): boolean { + return this._state === "idle" || this._state === "busy"; + } + + get isBusy(): boolean { + return this._state === "busy"; + } + + get status(): SessionStatus { + return this._buildStatus(); + } + + get websocketCount(): number { + return this.websockets.size; + } + + private _buildStatus(): SessionStatus { + return { + session_id: this.sessionId, + state: this._state, + seq: this._seq, + worker_id: this.workerId, + reason: this._reason, + detail: this._detail, + updated_at: new Date().toISOString(), + }; + } + + private _emitStatus( + state: SessionRunState, + reason: string | null = null, + detail: string | null = null, + ): void { + this._state = state; + this._reason = reason; + this._detail = detail; + this._seq++; + const msg = JSON.stringify(newSessionStatusMessage(this._buildStatus())); + this._broadcast(msg); + } + + // ── Worker lifecycle ─────────────────────────────────── + + async start(): Promise { + if (this.worker) return; + + const workerId = crypto.randomUUID(); + this.workerId = workerId; + + // Spawn worker subprocess + // The worker command should be the same binary with a worker subcommand + const workerScript = join(import.meta.dir, "worker.ts"); + const proc = Bun.spawn(["bun", "run", workerScript, "--session-id", this.sessionId], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + KIMI_WORKER_ID: workerId, + KIMI_SESSION_DIR: this.sessionDir, + }, + }); + + this.worker = proc; + this._emitStatus("idle", "started"); + + // Start read loop + this._readLoopPromise = this._readLoop(); + + // Handle process exit + proc.exited.then((code) => { + this.worker = null; + this._readLoopPromise = null; + if (this._state !== "stopped") { + this._emitStatus("stopped", "exited", `exit code: ${code}`); + } + }); + } + + async stop(): Promise { + await this.stopWorker(); + + // Close all WebSockets + for (const ws of this.websockets) { + try { + ws.close(1000, "Server shutting down"); + } catch { + // ignore + } + } + this.websockets.clear(); + this.replayMode.clear(); + this.replayBuffer = []; + this.inFlightPrompts.clear(); + } + + async stopWorker(): Promise { + if (!this.worker) return; + + const proc = this.worker; + this.worker = null; + this._emitStatus("stopped", "stopped"); + + try { + proc.kill("SIGTERM"); + // Give it 5 seconds before SIGKILL + const timeout = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + // ignore + } + }, 5000); + await proc.exited; + clearTimeout(timeout); + } catch { + // ignore + } + } + + async restartWorker(reason = "restart"): Promise { + const restartMs = 1000; + + // Emit restart notice + const notice: SessionNoticeEvent = { + type: "SessionNotice", + payload: { + text: `Restarting worker: ${reason}`, + kind: "restart", + reason, + restart_ms: restartMs, + }, + }; + + this._emitStatus("restarting", reason); + this._broadcast( + JSON.stringify({ jsonrpc: "2.0", method: "event", params: notice }), + ); + + await this.stopWorker(); + await new Promise((r) => setTimeout(r, restartMs)); + await this.start(); + } + + // ── Read loop (stdout from worker) ───────────────────── + + private async _readLoop(): Promise { + if (!this.worker?.stdout) return; + + const stdout = this.worker.stdout as ReadableStream; + const reader = stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + + if (!line) continue; + + try { + this._handleOutLine(line); + } catch (err) { + logger.warn(`Error handling worker output: ${err}`); + } + } + } + } catch (err) { + logger.warn(`Read loop error for session ${this.sessionId}: ${err}`); + } + } + + private _handleOutLine(line: string): void { + // Try to parse as JSON-RPC + let msg: any; + try { + msg = JSON.parse(line); + } catch { + // Not JSON, ignore + return; + } + + // Check if it's a response (success or error) + if (msg.id && !msg.method) { + // It's a response — remove from in-flight + this.inFlightPrompts.delete(msg.id); + if (this.inFlightPrompts.size === 0 && this._state === "busy") { + this._emitStatus("idle", "response_complete"); + } + } + + // Broadcast to all WebSockets + this._broadcast(line); + } + + // ── WebSocket management ─────────────────────────────── + + addWebsocketAndBeginReplay(ws: ServerWebSocket): void { + this.websockets.add(ws); + this.replayMode.add(ws); + + // Send buffered messages + for (const msg of this.replayBuffer) { + try { + ws.send(msg); + } catch { + break; + } + } + } + + endReplay(ws: ServerWebSocket): void { + this.replayMode.delete(ws); + sendHistoryComplete(ws); + + // Send current status + const statusMsg = JSON.stringify(newSessionStatusMessage(this._buildStatus())); + try { + ws.send(statusMsg); + } catch { + // ignore + } + } + + removeWebsocket(ws: ServerWebSocket): void { + this.websockets.delete(ws); + this.replayMode.delete(ws); + } + + // ── Message sending (from WebSocket client to worker) ── + + async sendMessage(data: string): Promise { + if (!this.worker) { + await this.start(); + } + + // Parse to check if it's a prompt request + try { + const msg = JSON.parse(data); + if (msg.method === "prompt" && msg.id) { + this.inFlightPrompts.add(msg.id); + this._emitStatus("busy", "prompt"); + } + } catch { + // ignore parse errors + } + + // Write to worker stdin + if (this.worker?.stdin) { + const stdin = this.worker.stdin as import("bun").FileSink; + stdin.write(new TextEncoder().encode(data + "\n")); + stdin.flush(); + } + } + + // ── Broadcast ────────────────────────────────────────── + + private _broadcast(data: string): void { + // Buffer for replay + this.replayBuffer.push(data); + // Limit buffer size + if (this.replayBuffer.length > 10000) { + this.replayBuffer = this.replayBuffer.slice(-5000); + } + + const dead = new Set>(); + for (const ws of this.websockets) { + // Skip WebSockets still in replay mode + if (this.replayMode.has(ws)) continue; + try { + ws.send(data); + } catch { + dead.add(ws); + } + } + + for (const ws of dead) { + this.websockets.delete(ws); + this.replayMode.delete(ws); + } + } +} + +// ── KimiCLIRunner ──────────────────────────────────────── + +export interface RestartWorkersSummary { + restartedSessionIds: string[]; + skippedBusySessionIds: string[]; +} + +export class KimiCLIRunner { + private sessions = new Map(); + + async start(): Promise { + // Nothing to do on start — sessions are lazy-created + } + + async stop(): Promise { + const promises: Promise[] = []; + for (const sp of this.sessions.values()) { + promises.push(sp.stop()); + } + await Promise.all(promises); + this.sessions.clear(); + } + + getOrCreateSession(sessionId: string, sessionDir: string): SessionProcess { + let sp = this.sessions.get(sessionId); + if (!sp) { + sp = new SessionProcess(sessionId, sessionDir); + this.sessions.set(sessionId, sp); + } + return sp; + } + + getSession(sessionId: string): SessionProcess | undefined { + return this.sessions.get(sessionId); + } + + detachWebsocket(ws: ServerWebSocket): void { + for (const sp of this.sessions.values()) { + sp.removeWebsocket(ws); + } + } + + async restartRunningWorkers( + reason = "config_change", + force = false, + ): Promise { + const restarted: string[] = []; + const skipped: string[] = []; + + for (const [id, sp] of this.sessions) { + if (!sp.isRunning) continue; + + if (sp.isBusy && !force) { + skipped.push(id); + continue; + } + + try { + await sp.restartWorker(reason); + restarted.push(id); + } catch (err) { + logger.warn(`Failed to restart worker ${id}: ${err}`); + } + } + + return { + restartedSessionIds: restarted, + skippedBusySessionIds: skipped, + }; + } +} diff --git a/src/kimi_cli_ts/web/runner/worker.ts b/src/kimi_cli_ts/web/runner/worker.ts new file mode 100644 index 000000000..0738c8ce6 --- /dev/null +++ b/src/kimi_cli_ts/web/runner/worker.ts @@ -0,0 +1,101 @@ +/** + * Web runner worker — corresponds to Python web/runner/worker.py + * Entry point for the worker subprocess that runs a kimi-cli session. + * + * The worker reads JSON-RPC messages from stdin and writes responses to stdout. + * It is spawned by SessionProcess.start(). + */ + +import { parseArgs } from "node:util"; +import { logger } from "../../utils/logging.ts"; + +async function runWorker(sessionId: string): Promise { + logger.info(`Worker starting for session: ${sessionId}`); + + const sessionDir = process.env.KIMI_SESSION_DIR; + if (!sessionDir) { + throw new Error("KIMI_SESSION_DIR not set"); + } + + // Dynamic import to avoid circular dependencies + const { loadSessionById } = await import("../store/sessions.ts"); + const session = loadSessionById(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // TODO: Wire up KimiCLI.create + wire_stdio when the full integration is ready. + // For now, the worker is a stub that reads stdin and logs messages. + const decoder = new TextDecoder(); + const reader = Bun.stdin.stream().getReader(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newlineIdx).trim(); + buffer = buffer.slice(newlineIdx + 1); + + if (!line) continue; + + try { + const msg = JSON.parse(line); + logger.debug(`Worker received: ${msg.method ?? "response"}`); + + // Echo back a success response for now + if (msg.id && msg.method) { + const response = { + jsonrpc: "2.0", + id: msg.id, + result: { status: "ok" }, + }; + process.stdout.write(JSON.stringify(response) + "\n"); + } + } catch (err) { + logger.warn(`Worker parse error: ${err}`); + } + } + } + } catch (err) { + logger.error(`Worker error: ${err}`); + } + + logger.info(`Worker exiting for session: ${sessionId}`); +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { + "session-id": { type: "string" }, + }, + strict: true, + allowPositionals: false, + }); + + const sessionId = values["session-id"]; + if (!sessionId) { + console.error("Usage: worker --session-id "); + process.exit(1); + } + + // Set process title + try { + process.title = `kimi-worker-${sessionId.slice(0, 8)}`; + } catch { + // ignore + } + + await runWorker(sessionId); +} + +// Run if executed directly +main().catch((err) => { + console.error("Worker fatal error:", err); + process.exit(1); +}); diff --git a/src/kimi_cli_ts/web/store/index.ts b/src/kimi_cli_ts/web/store/index.ts new file mode 100644 index 000000000..58cfd9d8b --- /dev/null +++ b/src/kimi_cli_ts/web/store/index.ts @@ -0,0 +1,15 @@ +/** + * Web store barrel — corresponds to Python web/store/__init__.py + */ + +export { + loadAllSessions, + loadAllSessionsCached, + loadSessionsPage, + loadSessionById, + getSessionIndexEntry, + invalidateSessionsCache, + runAutoArchive, + listWorkDirs, +} from "./sessions.ts"; +export type { SessionIndexEntry, LoadSessionsPageResult } from "./sessions.ts"; diff --git a/src/kimi_cli_ts/web/store/sessions.ts b/src/kimi_cli_ts/web/store/sessions.ts new file mode 100644 index 000000000..8f6fc2c99 --- /dev/null +++ b/src/kimi_cli_ts/web/store/sessions.ts @@ -0,0 +1,405 @@ +/** + * Web store sessions — corresponds to Python web/store/sessions.py + * Session listing, caching, pagination, auto-archive. + */ + +import { join, resolve } from "node:path"; +import { existsSync, readdirSync, statSync, readFileSync } from "node:fs"; +import { getShareDir } from "../../config.ts"; +import { loadMetadata, getSessionsDir, type Metadata, type WorkDirMeta } from "../../metadata.ts"; +import { + loadSessionState, + saveSessionState, + type SessionState, +} from "../../session_state.ts"; +import type { WebSession } from "../models.ts"; +import { logger } from "../../utils/logging.ts"; + +// ── Constants ──────────────────────────────────────────── + +const CACHE_TTL = 5.0; // seconds +const AUTO_ARCHIVE_DAYS = 15; +const AUTO_ARCHIVE_INTERVAL = 300; // seconds + +// ── Cache state (module-level) ─────────────────────────── + +let _sessionsCache: WebSession[] | null = null; +let _cacheTimestamp = 0; + +let _sessionsIndexCache: SessionIndexEntry[] | null = null; +let _indexCacheTimestamp = 0; + +let _lastAutoArchiveTime = 0; + +export function invalidateSessionsCache(): void { + _sessionsCache = null; + _cacheTimestamp = 0; + _sessionsIndexCache = null; + _indexCacheTimestamp = 0; +} + +// ── Index entry ────────────────────────────────────────── + +export interface SessionIndexEntry { + sessionId: string; + sessionDir: string; + contextFile: string; + workDir: string; + workDirMeta: WorkDirMeta; + lastUpdated: number; // epoch seconds + title: string; + state: SessionState; +} + +// ── Title derivation ───────────────────────────────────── + +function deriveTitleFromWire(sessionDir: string): string | null { + const wireFile = join(sessionDir, "wire.jsonl"); + if (!existsSync(wireFile)) return null; + + try { + const text = readFileSync(wireFile, "utf-8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed); + if (record.type === "turn_begin" && record.user_input) { + const raw = + typeof record.user_input === "string" + ? record.user_input + : JSON.stringify(record.user_input); + return raw.slice(0, 50); + } + } catch { + continue; + } + } + } catch { + // ignore + } + return null; +} + +function ensureTitle(entry: SessionIndexEntry): void { + if (entry.state.custom_title) { + entry.title = entry.state.custom_title; + return; + } + const wireTitle = deriveTitleFromWire(entry.sessionDir); + if (wireTitle) { + entry.title = wireTitle; + } +} + +// ── Session dir iteration ──────────────────────────────── + +function iterSessionDirs(workDirMeta: WorkDirMeta): SessionIndexEntry[] { + const sessionsDir = getSessionsDir(workDirMeta); + if (!existsSync(sessionsDir)) return []; + + const entries: SessionIndexEntry[] = []; + let dirEntries: string[]; + try { + dirEntries = readdirSync(sessionsDir); + } catch { + return []; + } + + for (const name of dirEntries) { + const sessionDir = join(sessionsDir, name); + const contextFile = join(sessionDir, "context.jsonl"); + + // Check if it's a valid session directory + if (!existsSync(contextFile)) continue; + + let lastUpdated = 0; + try { + const stat = statSync(contextFile); + lastUpdated = stat.mtimeMs / 1000; + } catch { + // ignore + } + + // Load state synchronously for index building (best-effort) + let state: SessionState; + try { + const stateFile = join(sessionDir, "state.json"); + if (existsSync(stateFile)) { + const data = JSON.parse( + require("node:fs").readFileSync(stateFile, "utf-8"), + ); + // Minimal parse — just grab what we need + state = { + version: data.version ?? 1, + approval: data.approval ?? { yolo: false, auto_approve_actions: [] }, + additional_dirs: data.additional_dirs ?? [], + custom_title: data.custom_title ?? null, + title_generated: data.title_generated ?? false, + title_generate_attempts: data.title_generate_attempts ?? 0, + plan_mode: data.plan_mode ?? false, + plan_session_id: data.plan_session_id ?? null, + plan_slug: data.plan_slug ?? null, + wire_mtime: data.wire_mtime ?? null, + archived: data.archived ?? false, + archived_at: data.archived_at ?? null, + auto_archive_exempt: data.auto_archive_exempt ?? false, + todos: data.todos ?? [], + }; + } else { + state = { + version: 1, + approval: { yolo: false, auto_approve_actions: [] }, + additional_dirs: [], + custom_title: null, + title_generated: false, + title_generate_attempts: 0, + plan_mode: false, + plan_session_id: null, + plan_slug: null, + wire_mtime: null, + archived: false, + archived_at: null, + auto_archive_exempt: false, + todos: [], + }; + } + } catch { + state = { + version: 1, + approval: { yolo: false, auto_approve_actions: [] }, + additional_dirs: [], + custom_title: null, + title_generated: false, + title_generate_attempts: 0, + plan_mode: false, + plan_session_id: null, + plan_slug: null, + wire_mtime: null, + archived: false, + archived_at: null, + auto_archive_exempt: false, + todos: [], + }; + } + + const entry: SessionIndexEntry = { + sessionId: name, + sessionDir, + contextFile, + workDir: workDirMeta.path, + workDirMeta, + lastUpdated, + title: "Untitled", + state, + }; + + ensureTitle(entry); + entries.push(entry); + } + + return entries; +} + +// ── Auto-archive ───────────────────────────────────────── + +function shouldAutoArchive(lastUpdated: number, state: SessionState): boolean { + if (state.archived) return false; + if (state.auto_archive_exempt) return false; + const ageDays = (Date.now() / 1000 - lastUpdated) / 86400; + return ageDays >= AUTO_ARCHIVE_DAYS; +} + +export async function runAutoArchive(): Promise { + const now = Date.now() / 1000; + if (now - _lastAutoArchiveTime < AUTO_ARCHIVE_INTERVAL) return; + _lastAutoArchiveTime = now; + + const index = buildSessionsIndex(); + for (const entry of index) { + if (shouldAutoArchive(entry.lastUpdated, entry.state)) { + entry.state.archived = true; + entry.state.archived_at = now; + try { + await saveSessionState(entry.state, entry.sessionDir); + } catch (err) { + logger.warn(`Failed to auto-archive session ${entry.sessionId}: ${err}`); + } + } + } + + invalidateSessionsCache(); +} + +// ── Index building ─────────────────────────────────────── + +function buildSessionsIndex(): SessionIndexEntry[] { + const now = Date.now() / 1000; + if (_sessionsIndexCache && now - _indexCacheTimestamp < CACHE_TTL) { + return _sessionsIndexCache; + } + + let metadata: Metadata; + try { + // Use sync metadata loading for index building + const metadataFile = join(getShareDir(), "kimi.json"); + if (!existsSync(metadataFile)) { + metadata = { workDirs: [] }; + } else { + const data = JSON.parse( + require("node:fs").readFileSync(metadataFile, "utf-8"), + ); + const workDirs = (data.work_dirs ?? data.workDirs ?? []).map( + (wd: any) => ({ + path: wd.path ?? "", + kaos: wd.kaos ?? "local", + lastSessionId: wd.last_session_id ?? wd.lastSessionId ?? null, + }), + ); + metadata = { workDirs }; + } + } catch { + metadata = { workDirs: [] }; + } + + const allEntries: SessionIndexEntry[] = []; + for (const wdMeta of metadata.workDirs) { + const entries = iterSessionDirs(wdMeta); + allEntries.push(...entries); + } + + // Sort by last_updated descending + allEntries.sort((a, b) => b.lastUpdated - a.lastUpdated); + + _sessionsIndexCache = allEntries; + _indexCacheTimestamp = now; + return allEntries; +} + +// ── Public API ─────────────────────────────────────────── + +function entryToWebSession(entry: SessionIndexEntry, isRunning = false): WebSession { + return { + session_id: entry.sessionId, + title: entry.title, + last_updated: new Date(entry.lastUpdated * 1000).toISOString(), + is_running: isRunning, + status: null, + work_dir: entry.workDir, + session_dir: entry.sessionDir, + archived: entry.state.archived, + }; +} + +export function loadAllSessions(): WebSession[] { + const index = buildSessionsIndex(); + return index.map((e) => entryToWebSession(e)); +} + +export function loadAllSessionsCached(): WebSession[] { + const now = Date.now() / 1000; + if (_sessionsCache && now - _cacheTimestamp < CACHE_TTL) { + return _sessionsCache; + } + const sessions = loadAllSessions(); + _sessionsCache = sessions; + _cacheTimestamp = now; + return sessions; +} + +export interface LoadSessionsPageResult { + items: WebSession[]; + total: number; + limit: number; + offset: number; +} + +export function loadSessionsPage( + limit = 50, + offset = 0, + query?: string, + archived?: boolean, +): LoadSessionsPageResult { + let index = buildSessionsIndex(); + + // Filter by archived status + if (archived !== undefined) { + index = index.filter((e) => e.state.archived === archived); + } + + // Filter by query + if (query) { + const lowerQuery = query.toLowerCase(); + index = index.filter( + (e) => + e.title.toLowerCase().includes(lowerQuery) || + e.workDir.toLowerCase().includes(lowerQuery) || + e.sessionId.toLowerCase().includes(lowerQuery), + ); + } + + const total = index.length; + const page = index.slice(offset, offset + limit); + + return { + items: page.map((e) => entryToWebSession(e)), + total, + limit, + offset, + }; +} + +export function loadSessionById(sessionId: string): WebSession | null { + const index = buildSessionsIndex(); + const entry = index.find((e) => e.sessionId === sessionId); + if (!entry) return null; + return entryToWebSession(entry); +} + +export function getSessionIndexEntry(sessionId: string): SessionIndexEntry | null { + const index = buildSessionsIndex(); + return index.find((e) => e.sessionId === sessionId) ?? null; +} + +// ── Work dirs listing ──────────────────────────────────── + +let _workDirsCache: string[] | null = null; +let _workDirsCacheTimestamp = 0; +const WORK_DIRS_CACHE_TTL = 30; + +export function listWorkDirs(): string[] { + const now = Date.now() / 1000; + if (_workDirsCache && now - _workDirsCacheTimestamp < WORK_DIRS_CACHE_TTL) { + return _workDirsCache; + } + + let metadata: Metadata; + try { + const metadataFile = join(getShareDir(), "kimi.json"); + if (!existsSync(metadataFile)) { + metadata = { workDirs: [] }; + } else { + const data = JSON.parse( + require("node:fs").readFileSync(metadataFile, "utf-8"), + ); + const workDirs = (data.work_dirs ?? data.workDirs ?? []).map( + (wd: any) => ({ + path: wd.path ?? "", + kaos: wd.kaos ?? "local", + lastSessionId: wd.last_session_id ?? wd.lastSessionId ?? null, + }), + ); + metadata = { workDirs }; + } + } catch { + metadata = { workDirs: [] }; + } + + const dirs = metadata.workDirs + .map((wd) => wd.path) + .filter((p) => existsSync(p)); + + _workDirsCache = dirs; + _workDirsCacheTimestamp = now; + return dirs; +} diff --git a/tests/tools/ask_user.test.ts b/tests/tools/ask_user.test.ts index 144328b3b..ae9ce1e66 100644 --- a/tests/tools/ask_user.test.ts +++ b/tests/tools/ask_user.test.ts @@ -4,7 +4,7 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; -import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/ask_user.ts"; +import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/index.ts"; let tempDir: string; let tool: AskUserQuestion; diff --git a/tests/tools/shell.test.ts b/tests/tools/shell.test.ts index 3ac68bb37..684fa631a 100644 --- a/tests/tools/shell.test.ts +++ b/tests/tools/shell.test.ts @@ -6,7 +6,7 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { join } from "node:path"; import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; -import { Shell } from "../../src/kimi_cli_ts/tools/shell/shell.ts"; +import { Shell } from "../../src/kimi_cli_ts/tools/shell/index.ts"; let tempDir: string; let tool: Shell; diff --git a/tests/tools/think.test.ts b/tests/tools/think.test.ts index 0b53a3f7b..ce28d87e1 100644 --- a/tests/tools/think.test.ts +++ b/tests/tools/think.test.ts @@ -4,7 +4,7 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; -import { Think } from "../../src/kimi_cli_ts/tools/think/think.ts"; +import { Think } from "../../src/kimi_cli_ts/tools/think/index.ts"; let tempDir: string; let tool: Think; diff --git a/tests/tools/todo.test.ts b/tests/tools/todo.test.ts index 6f639c344..10c7d43e8 100644 --- a/tests/tools/todo.test.ts +++ b/tests/tools/todo.test.ts @@ -4,7 +4,7 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; import { createTempDir, removeTempDir, createTestToolContext } from "../conftest.ts"; -import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/todo.ts"; +import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/index.ts"; let tempDir: string; let tool: SetTodoList; diff --git a/tests/tools/tool_schemas.test.ts b/tests/tools/tool_schemas.test.ts index 6c9e3daa9..7fd986532 100644 --- a/tests/tools/tool_schemas.test.ts +++ b/tests/tools/tool_schemas.test.ts @@ -9,11 +9,11 @@ import { WriteFile } from "../../src/kimi_cli_ts/tools/file/write.ts"; import { StrReplaceFile } from "../../src/kimi_cli_ts/tools/file/replace.ts"; import { Glob } from "../../src/kimi_cli_ts/tools/file/glob.ts"; import { Grep } from "../../src/kimi_cli_ts/tools/file/grep.ts"; -import { Shell } from "../../src/kimi_cli_ts/tools/shell/shell.ts"; +import { Shell } from "../../src/kimi_cli_ts/tools/shell/index.ts"; import { FetchURL } from "../../src/kimi_cli_ts/tools/web/fetch.ts"; -import { Think } from "../../src/kimi_cli_ts/tools/think/think.ts"; -import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/ask_user.ts"; -import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/todo.ts"; +import { Think } from "../../src/kimi_cli_ts/tools/think/index.ts"; +import { AskUserQuestion } from "../../src/kimi_cli_ts/tools/ask_user/index.ts"; +import { SetTodoList } from "../../src/kimi_cli_ts/tools/todo/index.ts"; describe("Tool Schemas", () => { describe("ReadFile schema", () => { From 4e16534ed12fd56d20e526cab91a24d15c4c42dd Mon Sep 17 00:00:00 2001 From: zongheyuan Date: Mon, 6 Apr 2026 03:36:49 +0800 Subject: [PATCH 22/28] feat(wire): complete wire mode implementation and comprehensive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- .python-version | 2 +- CLAUDE.md | 155 +- scripts/dev.ts | 102 + src/kimi_cli/CLAUDE.md | 351 ++ src/kimi_cli/ui/CLAUDE.md | 548 +++ src/kimi_cli_ts/acp/convert.ts | 201 +- src/kimi_cli_ts/acp/kaos.ts | 546 +-- src/kimi_cli_ts/acp/mcp.ts | 86 +- src/kimi_cli_ts/acp/server.ts | 965 +++--- src/kimi_cli_ts/acp/session.ts | 1043 +++--- src/kimi_cli_ts/acp/tools.ts | 376 ++- src/kimi_cli_ts/acp/types.ts | 334 +- src/kimi_cli_ts/acp/version.ts | 47 +- src/kimi_cli_ts/agentspec.ts | 269 +- src/kimi_cli_ts/app.ts | 578 ++-- src/kimi_cli_ts/approval_runtime/index.ts | 28 +- src/kimi_cli_ts/approval_runtime/models.ts | 36 +- src/kimi_cli_ts/approval_runtime/runtime.ts | 492 +-- src/kimi_cli_ts/auth/oauth.ts | 954 +++--- src/kimi_cli_ts/auth/platforms.ts | 324 +- src/kimi_cli_ts/background/agent_runner.ts | 478 +-- src/kimi_cli_ts/background/ids.ts | 16 +- src/kimi_cli_ts/background/index.ts | 26 +- src/kimi_cli_ts/background/manager.ts | 756 ++--- src/kimi_cli_ts/background/models.ts | 330 +- src/kimi_cli_ts/background/store.ts | 503 +-- src/kimi_cli_ts/background/summary.ts | 114 +- src/kimi_cli_ts/background/worker.ts | 360 +- src/kimi_cli_ts/cli/errors.ts | 47 +- src/kimi_cli_ts/cli/export.ts | 347 +- src/kimi_cli_ts/cli/index.ts | 1671 ++++----- src/kimi_cli_ts/cli/info.ts | 62 +- src/kimi_cli_ts/cli/login.ts | 84 +- src/kimi_cli_ts/cli/logout.ts | 66 +- src/kimi_cli_ts/cli/mcp.ts | 394 ++- src/kimi_cli_ts/cli/plugin.ts | 686 ++-- src/kimi_cli_ts/cli/toad.ts | 126 +- src/kimi_cli_ts/cli/vis.ts | 49 +- src/kimi_cli_ts/cli/web.ts | 92 +- src/kimi_cli_ts/config.ts | 407 +-- src/kimi_cli_ts/constant.ts | 22 +- src/kimi_cli_ts/exception.ts | 56 +- src/kimi_cli_ts/hooks/config.ts | 26 +- src/kimi_cli_ts/hooks/engine.ts | 665 ++-- src/kimi_cli_ts/hooks/events.ts | 249 +- src/kimi_cli_ts/hooks/index.ts | 6 +- src/kimi_cli_ts/hooks/runner.ts | 172 +- src/kimi_cli_ts/llm.ts | 1300 +++---- src/kimi_cli_ts/metadata.ts | 142 +- src/kimi_cli_ts/notifications/index.ts | 30 +- src/kimi_cli_ts/notifications/llm.ts | 173 +- src/kimi_cli_ts/notifications/manager.ts | 264 +- src/kimi_cli_ts/notifications/models.ts | 201 +- src/kimi_cli_ts/notifications/notifier.ts | 70 +- src/kimi_cli_ts/notifications/store.ts | 285 +- src/kimi_cli_ts/notifications/wire.ts | 46 +- src/kimi_cli_ts/plugin/index.ts | 38 +- src/kimi_cli_ts/plugin/manager.ts | 430 +-- src/kimi_cli_ts/plugin/tool.ts | 337 +- src/kimi_cli_ts/session.ts | 565 ++-- src/kimi_cli_ts/session_fork.ts | 516 +-- src/kimi_cli_ts/session_state.ts | 225 +- src/kimi_cli_ts/share.ts | 6 +- src/kimi_cli_ts/skill/flow/d2.ts | 824 +++-- src/kimi_cli_ts/skill/flow/index.ts | 164 +- src/kimi_cli_ts/skill/flow/mermaid.ts | 467 +-- src/kimi_cli_ts/skill/index.ts | 544 +-- src/kimi_cli_ts/soul/agent.ts | 1008 +++--- src/kimi_cli_ts/soul/approval.ts | 246 +- src/kimi_cli_ts/soul/compaction.ts | 361 +- src/kimi_cli_ts/soul/context.ts | 530 +-- src/kimi_cli_ts/soul/denwarenji.ts | 66 +- src/kimi_cli_ts/soul/dynamic_injection.ts | 90 +- .../soul/dynamic_injections/index.ts | 6 +- .../soul/dynamic_injections/plan_mode.ts | 395 +-- .../soul/dynamic_injections/yolo_mode.ts | 45 +- src/kimi_cli_ts/soul/index.ts | 362 +- src/kimi_cli_ts/soul/kimisoul.ts | 2974 +++++++++-------- src/kimi_cli_ts/soul/message.ts | 147 +- src/kimi_cli_ts/soul/slash.ts | 359 +- src/kimi_cli_ts/soul/toolset.ts | 376 +-- src/kimi_cli_ts/subagents/builder.ts | 98 +- src/kimi_cli_ts/subagents/core.ts | 104 +- src/kimi_cli_ts/subagents/git_context.ts | 211 +- src/kimi_cli_ts/subagents/index.ts | 14 +- src/kimi_cli_ts/subagents/models.ts | 58 +- src/kimi_cli_ts/subagents/output.ts | 142 +- src/kimi_cli_ts/subagents/registry.ts | 34 +- src/kimi_cli_ts/subagents/runner.ts | 793 ++--- src/kimi_cli_ts/subagents/store.ts | 402 +-- src/kimi_cli_ts/tools/agent/index.ts | 365 +- src/kimi_cli_ts/tools/ask_user/index.ts | 284 +- src/kimi_cli_ts/tools/background/index.ts | 249 +- src/kimi_cli_ts/tools/base.ts | 36 +- src/kimi_cli_ts/tools/display.ts | 46 +- src/kimi_cli_ts/tools/dmail/index.ts | 32 +- src/kimi_cli_ts/tools/file/glob.ts | 127 +- src/kimi_cli_ts/tools/file/grep.ts | 974 +++--- src/kimi_cli_ts/tools/file/plan_mode.ts | 52 +- src/kimi_cli_ts/tools/file/read.ts | 717 ++-- src/kimi_cli_ts/tools/file/read_media.ts | 164 +- src/kimi_cli_ts/tools/file/utils.ts | 437 ++- src/kimi_cli_ts/tools/index.ts | 7 +- src/kimi_cli_ts/tools/plan/enter.ts | 166 +- src/kimi_cli_ts/tools/plan/heroes.ts | 516 +-- src/kimi_cli_ts/tools/plan/index.ts | 388 +-- src/kimi_cli_ts/tools/registry.ts | 142 +- src/kimi_cli_ts/tools/shell/index.ts | 368 +- src/kimi_cli_ts/tools/test.ts | 83 +- src/kimi_cli_ts/tools/think/index.ts | 16 +- src/kimi_cli_ts/tools/todo/index.ts | 50 +- src/kimi_cli_ts/tools/types.ts | 558 ++-- src/kimi_cli_ts/tools/utils.ts | 26 +- src/kimi_cli_ts/tools/web/fetch.ts | 459 +-- src/kimi_cli_ts/tools/web/search.ts | 196 +- src/kimi_cli_ts/types.ts | 159 +- .../ui/components/ApprovalPrompt.tsx | 164 +- .../ui/components/CommandPanel.tsx | 227 +- src/kimi_cli_ts/ui/components/MentionMenu.tsx | 53 +- .../ui/components/NotificationStack.tsx | 101 +- src/kimi_cli_ts/ui/components/PanelShell.tsx | 531 +-- src/kimi_cli_ts/ui/components/SlashMenu.tsx | 206 +- src/kimi_cli_ts/ui/components/Spinner.tsx | 76 +- src/kimi_cli_ts/ui/components/StatusBar.tsx | 638 ++-- src/kimi_cli_ts/ui/components/TitleBox.tsx | 336 +- src/kimi_cli_ts/ui/components/WelcomeBox.tsx | 116 +- src/kimi_cli_ts/ui/hooks/useApproval.ts | 72 +- src/kimi_cli_ts/ui/hooks/useFileMention.ts | 399 ++- src/kimi_cli_ts/ui/hooks/useGitStatus.ts | 157 +- src/kimi_cli_ts/ui/hooks/useInput.ts | 312 +- src/kimi_cli_ts/ui/hooks/usePanelKeyboard.ts | 204 +- src/kimi_cli_ts/ui/hooks/usePanelScroller.ts | 117 +- src/kimi_cli_ts/ui/hooks/useReplayHistory.ts | 558 ++-- src/kimi_cli_ts/ui/hooks/useUsagePanel.ts | 97 +- src/kimi_cli_ts/ui/hooks/useWire.ts | 765 ++--- src/kimi_cli_ts/ui/print/index.ts | 750 +++-- src/kimi_cli_ts/ui/renderer/ansi-parser.ts | 420 +-- src/kimi_cli_ts/ui/renderer/csi.ts | 32 +- src/kimi_cli_ts/ui/renderer/diff.ts | 244 +- src/kimi_cli_ts/ui/renderer/index.ts | 406 +-- src/kimi_cli_ts/ui/renderer/patch-writer.ts | 505 +-- src/kimi_cli_ts/ui/renderer/screen.ts | 286 +- .../ui/renderer/terminal-detect.ts | 94 +- src/kimi_cli_ts/ui/renderer/types.ts | 44 +- src/kimi_cli_ts/ui/shell/ApprovalPanel.tsx | 350 +- src/kimi_cli_ts/ui/shell/DebugPanel.tsx | 542 +-- src/kimi_cli_ts/ui/shell/Prompt.tsx | 906 ++--- src/kimi_cli_ts/ui/shell/PromptView.tsx | 124 +- src/kimi_cli_ts/ui/shell/QuestionPanel.tsx | 434 +-- src/kimi_cli_ts/ui/shell/ReplayPanel.tsx | 364 +- src/kimi_cli_ts/ui/shell/SelectionPanel.tsx | 177 +- src/kimi_cli_ts/ui/shell/SetupWizard.tsx | 438 +-- src/kimi_cli_ts/ui/shell/Shell.tsx | 502 +-- src/kimi_cli_ts/ui/shell/TaskBrowser.tsx | 532 +-- src/kimi_cli_ts/ui/shell/Visualize.tsx | 2098 +++++++----- src/kimi_cli_ts/ui/shell/commands/add_dir.ts | 106 +- src/kimi_cli_ts/ui/shell/commands/editor.ts | 140 +- src/kimi_cli_ts/ui/shell/commands/feedback.ts | 124 +- src/kimi_cli_ts/ui/shell/commands/info.ts | 195 +- src/kimi_cli_ts/ui/shell/commands/init.ts | 74 +- src/kimi_cli_ts/ui/shell/commands/login.ts | 422 +-- src/kimi_cli_ts/ui/shell/commands/misc.ts | 26 +- src/kimi_cli_ts/ui/shell/commands/model.ts | 439 +-- src/kimi_cli_ts/ui/shell/commands/session.ts | 346 +- src/kimi_cli_ts/ui/shell/commands/usage.ts | 333 +- src/kimi_cli_ts/ui/shell/console.ts | 16 +- src/kimi_cli_ts/ui/shell/context-types.ts | 142 +- src/kimi_cli_ts/ui/shell/events.ts | 140 +- src/kimi_cli_ts/ui/shell/index.ts | 76 +- src/kimi_cli_ts/ui/shell/input-stack.ts | 78 +- src/kimi_cli_ts/ui/shell/input-state.ts | 1222 +++---- src/kimi_cli_ts/ui/shell/keyboard.ts | 206 +- src/kimi_cli_ts/ui/shell/shell-commands.ts | 24 +- src/kimi_cli_ts/ui/shell/shell-executor.ts | 72 +- src/kimi_cli_ts/ui/shell/slash.ts | 783 +++-- src/kimi_cli_ts/ui/shell/usePromptSymbol.ts | 18 +- src/kimi_cli_ts/ui/shell/useSelectionInput.ts | 410 +-- src/kimi_cli_ts/ui/shell/useShellCallbacks.ts | 497 +-- src/kimi_cli_ts/ui/shell/useShellLayout.ts | 79 +- src/kimi_cli_ts/ui/theme.ts | 308 +- src/kimi_cli_ts/utils/async.ts | 107 +- src/kimi_cli_ts/utils/changelog.ts | 20 +- src/kimi_cli_ts/utils/clipboard.ts | 273 +- src/kimi_cli_ts/utils/datetime.ts | 64 +- src/kimi_cli_ts/utils/editor.ts | 127 +- src/kimi_cli_ts/utils/environment.ts | 148 +- src/kimi_cli_ts/utils/envvar.ts | 14 +- src/kimi_cli_ts/utils/export.ts | 490 +-- src/kimi_cli_ts/utils/frontmatter.ts | 62 +- src/kimi_cli_ts/utils/index.ts | 42 +- src/kimi_cli_ts/utils/io.ts | 36 +- src/kimi_cli_ts/utils/logging.ts | 170 +- src/kimi_cli_ts/utils/media_tags.ts | 42 +- src/kimi_cli_ts/utils/message.ts | 56 +- src/kimi_cli_ts/utils/path.ts | 61 +- src/kimi_cli_ts/utils/proxy.ts | 24 +- src/kimi_cli_ts/utils/queue.ts | 132 +- src/kimi_cli_ts/utils/sensitive.ts | 112 +- src/kimi_cli_ts/utils/server.ts | 185 +- src/kimi_cli_ts/utils/signals.ts | 32 +- src/kimi_cli_ts/utils/slashcmd.ts | 121 +- src/kimi_cli_ts/utils/string.ts | 53 +- src/kimi_cli_ts/utils/subprocess_env.ts | 34 +- src/kimi_cli_ts/utils/term.ts | 16 +- src/kimi_cli_ts/utils/yaml.ts | 323 +- src/kimi_cli_ts/vis/api/sessions.ts | 1372 ++++---- src/kimi_cli_ts/vis/api/statistics.ts | 470 +-- src/kimi_cli_ts/vis/api/system.ts | 23 +- src/kimi_cli_ts/vis/app.ts | 657 ++-- src/kimi_cli_ts/web/api/config.ts | 310 +- src/kimi_cli_ts/web/api/open_in.ts | 276 +- src/kimi_cli_ts/web/api/sessions.ts | 1246 +++---- src/kimi_cli_ts/web/app.ts | 681 ++-- src/kimi_cli_ts/web/auth.ts | 276 +- src/kimi_cli_ts/web/index.ts | 10 +- src/kimi_cli_ts/web/models.ts | 93 +- src/kimi_cli_ts/web/runner/index.ts | 6 +- src/kimi_cli_ts/web/runner/messages.ts | 48 +- src/kimi_cli_ts/web/runner/process.ts | 761 ++--- src/kimi_cli_ts/web/runner/worker.ts | 162 +- src/kimi_cli_ts/web/store/index.ts | 16 +- src/kimi_cli_ts/web/store/sessions.ts | 636 ++-- src/kimi_cli_ts/wire/file.ts | 455 ++- src/kimi_cli_ts/wire/jsonrpc.ts | 315 +- src/kimi_cli_ts/wire/root_hub.ts | 42 +- src/kimi_cli_ts/wire/serde.ts | 93 +- src/kimi_cli_ts/wire/server.ts | 1852 +++++----- src/kimi_cli_ts/wire/types.ts | 769 ++--- src/kimi_cli_ts/wire/wire_core.ts | 304 +- tests_e2e_ts/test_mcp_cli.test.ts | 225 ++ .../test_wire_approvals_tools.test.ts | 481 +++ tests_e2e_ts/test_wire_config.test.ts | 160 + tests_e2e_ts/test_wire_errors.test.ts | 289 ++ tests_e2e_ts/test_wire_prompt.test.ts | 375 +++ tests_e2e_ts/test_wire_protocol.test.ts | 186 ++ tests_e2e_ts/test_wire_question.test.ts | 210 ++ tests_e2e_ts/test_wire_real_llm.test.ts | 13 + tests_e2e_ts/test_wire_sessions.test.ts | 370 ++ tests_e2e_ts/test_wire_skills_mcp.test.ts | 328 ++ tests_e2e_ts/test_wire_steer.test.ts | 123 + tests_e2e_ts/wire_helpers.ts | 842 +++++ 241 files changed, 41723 insertions(+), 33461 deletions(-) create mode 100644 scripts/dev.ts create mode 100644 src/kimi_cli/CLAUDE.md create mode 100644 src/kimi_cli/ui/CLAUDE.md create mode 100644 tests_e2e_ts/test_mcp_cli.test.ts create mode 100644 tests_e2e_ts/test_wire_approvals_tools.test.ts create mode 100644 tests_e2e_ts/test_wire_config.test.ts create mode 100644 tests_e2e_ts/test_wire_errors.test.ts create mode 100644 tests_e2e_ts/test_wire_prompt.test.ts create mode 100644 tests_e2e_ts/test_wire_protocol.test.ts create mode 100644 tests_e2e_ts/test_wire_question.test.ts create mode 100644 tests_e2e_ts/test_wire_real_llm.test.ts create mode 100644 tests_e2e_ts/test_wire_sessions.test.ts create mode 100644 tests_e2e_ts/test_wire_skills_mcp.test.ts create mode 100644 tests_e2e_ts/test_wire_steer.test.ts create mode 100644 tests_e2e_ts/wire_helpers.ts diff --git a/.python-version b/.python-version index 6324d401a..24ee5b1be 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.13 diff --git a/CLAUDE.md b/CLAUDE.md index efc886276..7ac318313 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,11 @@ Default to using Bun instead of Node.js. - Use `bun run