From 0037c57415a83c603bbe86bcebb65648fe5508f9 Mon Sep 17 00:00:00 2001 From: jimgqwang Date: Sun, 7 Jun 2026 13:41:44 +0800 Subject: [PATCH 1/3] first commit --- .github/workflows/ci.yml | 287 ++ .github/workflows/release.yml | 95 + .gitignore | 50 + .prettierrc | 7 + CHANGELOG.md | 23 + LICENSE | 201 + README.md | 408 ++ config.example.toml | 14 + configs/TEST_STRATEGY.md | 315 ++ configs/default-settings.json | 117 + configs/test-setup.ts | 128 + configs/test-utils.ts | 245 ++ docs/configuration.md | 308 ++ docs/getting-started.md | 209 + eslint.config.ts | 27 + install.sh | 342 ++ package.json | 69 + packages/cli/package.json | 35 + packages/cli/src/app.tsx | 26 + .../cli/src/app/createGatewayEventHandler.ts | 825 ++++ packages/cli/src/app/createSlashHandler.ts | 130 + packages/cli/src/app/delegationStore.ts | 77 + packages/cli/src/app/gatewayContext.tsx | 20 + packages/cli/src/app/inputSelectionStore.ts | 15 + packages/cli/src/app/interfaces.ts | 408 ++ packages/cli/src/app/overlayStore.ts | 59 + packages/cli/src/app/scroll.ts | 71 + packages/cli/src/app/setupHandoff.ts | 54 + packages/cli/src/app/slash/commands/core.ts | 675 ++++ packages/cli/src/app/slash/commands/debug.ts | 48 + packages/cli/src/app/slash/commands/init.ts | 88 + packages/cli/src/app/slash/commands/ops.ts | 735 ++++ .../cli/src/app/slash/commands/session.ts | 598 +++ packages/cli/src/app/slash/commands/setup.ts | 20 + packages/cli/src/app/slash/registry.ts | 22 + packages/cli/src/app/slash/types.ts | 21 + packages/cli/src/app/spawnHistoryStore.ts | 159 + packages/cli/src/app/turnController.ts | 918 +++++ packages/cli/src/app/turnStore.ts | 85 + packages/cli/src/app/uiStore.ts | 47 + packages/cli/src/app/useComposerState.ts | 367 ++ packages/cli/src/app/useConfigSync.ts | 288 ++ packages/cli/src/app/useInputHandlers.ts | 592 +++ packages/cli/src/app/useLongRunToolCharms.ts | 69 + packages/cli/src/app/useMainApp.ts | 901 +++++ packages/cli/src/app/useSessionLifecycle.ts | 361 ++ packages/cli/src/app/useSubmission.ts | 429 +++ packages/cli/src/banner.ts | 94 + .../src/components/activeSessionSwitcher.tsx | 636 ++++ packages/cli/src/components/agentsOverlay.tsx | 1074 ++++++ packages/cli/src/components/appChrome.tsx | 573 +++ packages/cli/src/components/appLayout.tsx | 491 +++ packages/cli/src/components/appOverlays.tsx | 263 ++ packages/cli/src/components/branding.tsx | 449 +++ .../src/components/coordinatorDashboard.tsx | 244 ++ packages/cli/src/components/diffView.tsx | 429 +++ packages/cli/src/components/fileTree.tsx | 289 ++ packages/cli/src/components/fpsOverlay.tsx | 31 + packages/cli/src/components/helpHint.tsx | 74 + packages/cli/src/components/markdown.tsx | 1150 ++++++ packages/cli/src/components/maskedPrompt.tsx | 35 + packages/cli/src/components/messageLine.tsx | 246 ++ packages/cli/src/components/modelPicker.tsx | 1065 ++++++ .../cli/src/components/overlayControls.tsx | 51 + packages/cli/src/components/prompts.tsx | 277 ++ .../cli/src/components/queuedMessages.tsx | 65 + packages/cli/src/components/sessionPicker.tsx | 228 ++ packages/cli/src/components/skillsHub.tsx | 309 ++ .../cli/src/components/slashCommandPopup.tsx | 125 + .../cli/src/components/streamingAssistant.tsx | 115 + .../cli/src/components/streamingMarkdown.tsx | 175 + packages/cli/src/components/textInput.tsx | 1328 +++++++ packages/cli/src/components/themed.tsx | 31 + packages/cli/src/components/thinking.tsx | 1401 +++++++ packages/cli/src/components/todoPanel.tsx | 94 + packages/cli/src/config/env.ts | 60 + packages/cli/src/config/limits.ts | 13 + packages/cli/src/config/timing.ts | 6 + packages/cli/src/content/charms.ts | 1 + packages/cli/src/content/faces.ts | 17 + packages/cli/src/content/fortunes.ts | 30 + packages/cli/src/content/hotkeys.ts | 37 + packages/cli/src/content/placeholders.ts | 13 + packages/cli/src/content/setup.ts | 17 + packages/cli/src/content/verbs.ts | 106 + packages/cli/src/demo.tsx | 58 + packages/cli/src/domain/details.ts | 76 + packages/cli/src/domain/messages.ts | 91 + packages/cli/src/domain/paths.ts | 16 + packages/cli/src/domain/providers.ts | 11 + packages/cli/src/domain/roles.ts | 9 + packages/cli/src/domain/slash.ts | 10 + packages/cli/src/domain/usage.ts | 3 + packages/cli/src/domain/viewport.ts | 51 + packages/cli/src/entry.tsx | 830 ++++ .../__tests__/e2e-submit-message.test.ts | 250 ++ .../src/gateway/__tests__/integration.test.ts | 557 +++ packages/cli/src/gateway/client.ts | 745 ++++ packages/cli/src/gateway/coder-client.ts | 1100 ++++++ packages/cli/src/gateway/context.tsx | 24 + packages/cli/src/gateway/deferred.ts | 98 + packages/cli/src/gateway/engine-factory.ts | 480 +++ packages/cli/src/gateway/mock-client.ts | 233 ++ packages/cli/src/gateway/query-bridge.ts | 628 +++ packages/cli/src/gateway/types.ts | 564 +++ packages/cli/src/hooks/useCompletion.ts | 112 + packages/cli/src/hooks/useGitBranch.ts | 72 + packages/cli/src/hooks/useInputHistory.ts | 11 + packages/cli/src/hooks/useQueue.ts | 76 + packages/cli/src/hooks/useVirtualHistory.ts | 554 +++ packages/cli/src/lib/circularBuffer.ts | 48 + packages/cli/src/lib/clipboard.ts | 166 + packages/cli/src/lib/editor.test.ts | 74 + packages/cli/src/lib/editor.ts | 47 + packages/cli/src/lib/emoji.ts | 55 + packages/cli/src/lib/externalCli.ts | 16 + packages/cli/src/lib/externalLink.ts | 435 +++ packages/cli/src/lib/fileTreeBuilder.ts | 257 ++ packages/cli/src/lib/forceTruecolor.ts | 60 + packages/cli/src/lib/fpsStore.ts | 51 + packages/cli/src/lib/gracefulExit.ts | 47 + packages/cli/src/lib/history.ts | 82 + packages/cli/src/lib/inputMetrics.ts | 203 + packages/cli/src/lib/liveProgress.test.ts | 116 + packages/cli/src/lib/liveProgress.ts | 93 + packages/cli/src/lib/mathUnicode.ts | 770 ++++ packages/cli/src/lib/memory.ts | 187 + packages/cli/src/lib/memoryMonitor.ts | 109 + packages/cli/src/lib/messages.test.ts | 29 + packages/cli/src/lib/messages.ts | 8 + packages/cli/src/lib/openExternalUrl.test.ts | 217 ++ packages/cli/src/lib/openExternalUrl.ts | 158 + packages/cli/src/lib/osc52.ts | 73 + packages/cli/src/lib/perfPane.tsx | 108 + packages/cli/src/lib/platform.ts | 409 ++ packages/cli/src/lib/precisionWheel.ts | 48 + packages/cli/src/lib/prompt.ts | 35 + packages/cli/src/lib/reasoning.ts | 55 + packages/cli/src/lib/rpc.ts | 41 + packages/cli/src/lib/subagentTree.ts | 355 ++ packages/cli/src/lib/syntax.ts | 117 + packages/cli/src/lib/terminalModes.ts | 51 + packages/cli/src/lib/terminalParity.ts | 78 + packages/cli/src/lib/terminalSetup.ts | 444 +++ packages/cli/src/lib/termux.ts | 29 + packages/cli/src/lib/text.test.ts | 18 + packages/cli/src/lib/text.ts | 418 ++ packages/cli/src/lib/todo.test.ts | 21 + packages/cli/src/lib/todo.ts | 9 + packages/cli/src/lib/viewportStore.ts | 124 + packages/cli/src/lib/virtualHeights.ts | 145 + packages/cli/src/lib/wheelAccel.ts | 190 + packages/cli/src/protocol/interpolation.ts | 3 + packages/cli/src/protocol/paste.ts | 1 + packages/cli/src/services/session-service.ts | 208 + packages/cli/src/theme.ts | 626 +++ packages/cli/src/types.ts | 214 ++ packages/cli/src/types/coder-tui.d.ts | 176 + packages/cli/tsconfig.json | 14 + packages/core/package.json | 25 + .../core/src/__tests__/checkpoint.test.ts | 94 + packages/core/src/__tests__/compactor.test.ts | 218 ++ .../core/src/__tests__/error-recovery.test.ts | 262 ++ .../core/src/__tests__/hooks-phase5.test.ts | 522 +++ .../core/src/__tests__/placeholder.test.ts | 124 + packages/core/src/__tests__/query.test.ts | 426 +++ .../core/src/__tests__/tool-registry.test.ts | 247 ++ packages/core/src/budget-store.ts | 345 ++ packages/core/src/checkpoint.ts | 543 +++ packages/core/src/context/compactor.ts | 829 ++++ packages/core/src/context/token-budget.ts | 230 ++ packages/core/src/cron-scheduler.ts | 363 ++ packages/core/src/error-recovery.ts | 265 ++ packages/core/src/hooks/manager.ts | 1391 +++++++ packages/core/src/hooks/types.ts | 244 ++ packages/core/src/index.ts | 167 + packages/core/src/memory/consolidator.ts | 262 ++ packages/core/src/memory/extractor.ts | 346 ++ packages/core/src/memory/index.ts | 40 + packages/core/src/memory/store.ts | 451 +++ packages/core/src/memory/types.ts | 90 + packages/core/src/permission/engine.ts | 197 + packages/core/src/provider-adapter.ts | 262 ++ packages/core/src/query-engine.ts | 429 +++ packages/core/src/query.ts | 948 +++++ packages/core/src/rules-manager.ts | 426 +++ packages/core/src/scratchpad.ts | 449 +++ packages/core/src/session.ts | 410 ++ packages/core/src/subagent-bus.ts | 565 +++ packages/core/src/system-prompt/assembler.ts | 286 ++ .../core/src/system-prompt/coordinator.ts | 104 + packages/core/src/tool-registry.ts | 139 + packages/core/tsconfig.json | 10 + packages/mcp/package.json | 21 + packages/mcp/src/client.ts | 794 ++++ packages/mcp/src/index.ts | 18 + packages/mcp/src/server.ts | 477 +++ packages/mcp/tsconfig.json | 10 + packages/provider/package.json | 29 + .../src/__tests__/placeholder.test.ts | 28 + packages/provider/src/anthropic.ts | 438 +++ packages/provider/src/base.ts | 310 ++ packages/provider/src/deepseek.ts | 77 + packages/provider/src/index.ts | 64 + packages/provider/src/lazy-deps.ts | 202 + packages/provider/src/openai-compat.ts | 653 ++++ packages/provider/src/retry.ts | 199 + packages/provider/src/router.ts | 313 ++ packages/provider/tsconfig.json | 10 + packages/shared/package.json | 40 + .../shared/src/__tests__/integration.test.ts | 119 + .../shared/src/__tests__/utils/diff.test.ts | 206 + .../src/__tests__/utils/messages.test.ts | 418 ++ .../src/__tests__/utils/tokenizer.test.ts | 157 + packages/shared/src/agent-teams.ts | 111 + packages/shared/src/index.ts | 5 + packages/shared/src/protocol.ts | 110 + packages/shared/src/subagent-bus.ts | 809 ++++ packages/shared/src/types/config.ts | 261 ++ packages/shared/src/types/hook.ts | 651 ++++ packages/shared/src/types/index.ts | 6 + packages/shared/src/types/message.ts | 341 ++ packages/shared/src/types/permission.ts | 152 + packages/shared/src/types/session.ts | 155 + packages/shared/src/types/tool.ts | 132 + packages/shared/src/utils/diff.ts | 177 + packages/shared/src/utils/index.ts | 5 + packages/shared/src/utils/messages.ts | 196 + packages/shared/src/utils/tokenizer.ts | 96 + packages/shared/tsconfig.json | 10 + packages/skills/package.json | 24 + packages/skills/src/creator.ts | 442 +++ packages/skills/src/improver.ts | 415 ++ packages/skills/src/index.ts | 25 + packages/skills/src/loader.ts | 409 ++ packages/skills/src/registry.ts | 521 +++ packages/skills/src/types.ts | 91 + packages/skills/tsconfig.json | 10 + .../src/__tests__/sprint5-test-strategy.ts | 932 +++++ packages/tools/package.json | 24 + packages/tools/src/__tests__/bash.test.ts | 188 + packages/tools/src/__tests__/edit.test.ts | 76 + packages/tools/src/__tests__/glob.test.ts | 75 + packages/tools/src/__tests__/grep.test.ts | 93 + .../tools/src/__tests__/placeholder.test.ts | 48 + packages/tools/src/__tests__/read.test.ts | 88 + packages/tools/src/__tests__/write.test.ts | 64 + packages/tools/src/agent-message.ts | 149 + packages/tools/src/agent-read.ts | 156 + packages/tools/src/agent-spawn.ts | 315 ++ packages/tools/src/agent-stop.ts | 126 + packages/tools/src/ask-user-question.ts | 312 ++ packages/tools/src/bash.ts | 222 ++ packages/tools/src/cron-create.d.ts | 49 + packages/tools/src/cron-create.d.ts.map | 1 + packages/tools/src/cron-create.js | 251 ++ packages/tools/src/cron-create.js.map | 1 + packages/tools/src/cron-create.ts | 321 ++ packages/tools/src/cron-delete.ts | 103 + packages/tools/src/cron-list.ts | 84 + packages/tools/src/edit.ts | 159 + packages/tools/src/enter-worktree.ts | 311 ++ packages/tools/src/exit-plan-mode.ts | 174 + packages/tools/src/exit-worktree.ts | 366 ++ packages/tools/src/git.ts | 162 + packages/tools/src/glob.ts | 207 + packages/tools/src/grep.ts | 317 ++ packages/tools/src/index.ts | 109 + packages/tools/src/lsp.ts | 618 +++ packages/tools/src/notebook-edit.ts | 377 ++ packages/tools/src/read.ts | 210 + packages/tools/src/skill.ts | 166 + packages/tools/src/task-create.ts | 151 + packages/tools/src/task-describe.ts | 183 + packages/tools/src/task-list.ts | 321 ++ packages/tools/src/task-output.ts | 265 ++ packages/tools/src/task-update.ts | 155 + packages/tools/src/team-create.ts | 281 ++ packages/tools/src/team-delete.ts | 170 + packages/tools/src/todo-write.ts | 175 + packages/tools/src/web-fetch.ts | 408 ++ packages/tools/src/web-search.ts | 329 ++ packages/tools/src/write.ts | 115 + packages/tools/tsconfig.json | 10 + packages/tui/ambient.d.ts | 83 + packages/tui/package.json | 44 + packages/tui/src/ambient-fixes.d.ts | 32 + packages/tui/src/bootstrap/state.ts | 9 + packages/tui/src/compat/alternate-screen.tsx | 155 + packages/tui/src/compat/ansi.tsx | 22 + packages/tui/src/compat/box.tsx | 81 + packages/tui/src/compat/cache-eviction.ts | 133 + packages/tui/src/compat/cursor-hooks.ts | 318 ++ packages/tui/src/compat/error-boundary.tsx | 295 ++ packages/tui/src/compat/external-process.ts | 44 + packages/tui/src/compat/force-redraw.ts | 55 + packages/tui/src/compat/lru.ts | 59 + packages/tui/src/compat/main-screen.tsx | 131 + packages/tui/src/compat/mouse-tracker.tsx | 329 ++ packages/tui/src/compat/no-select.tsx | 75 + packages/tui/src/compat/raw-ansi.tsx | 22 + packages/tui/src/compat/render-utils.ts | 35 + packages/tui/src/compat/scroll-box.tsx | 653 ++++ packages/tui/src/compat/scroll-stats.ts | 78 + packages/tui/src/compat/selection.ts | 471 +++ packages/tui/src/compat/tab-status.ts | 17 + packages/tui/src/compat/terminal-detect.ts | 23 + packages/tui/src/compat/terminal-focus.ts | 19 + packages/tui/src/compat/terminal-title.ts | 16 + packages/tui/src/compat/terminal-viewport.ts | 27 + packages/tui/src/compat/text.tsx | 61 + packages/tui/src/compat/types.ts | 87 + packages/tui/src/compat/use-input.ts | 55 + packages/tui/src/compat/use-stdin.ts | 61 + packages/tui/src/entry-exports.ts | 193 + packages/tui/src/hooks/use-stderr.ts | 15 + packages/tui/src/hooks/use-stdout.ts | 15 + .../tui/src/native-ts/yoga-layout/enums.ts | 112 + .../tui/src/native-ts/yoga-layout/index.ts | 2326 ++++++++++++ packages/tui/src/text-input.ts | 2 + packages/tui/src/utils/debug.ts | 6 + packages/tui/src/utils/earlyInput.ts | 131 + packages/tui/src/utils/env.ts | 66 + packages/tui/src/utils/envUtils.ts | 13 + .../tui/src/utils/execFileNoThrow.test.ts | 146 + packages/tui/src/utils/execFileNoThrow.ts | 115 + packages/tui/src/utils/fullscreen.ts | 3 + packages/tui/src/utils/intl.ts | 87 + packages/tui/src/utils/log.ts | 7 + packages/tui/src/utils/semver.ts | 57 + packages/tui/src/utils/sliceAnsi.ts | 114 + packages/tui/tsconfig.json | 14 + patches/ink@7.0.5.patch | 21 + pnpm-lock.yaml | 3372 +++++++++++++++++ pnpm-workspace.yaml | 3 + prettier.config.js | 12 + scripts/changelog.sh | 120 + scripts/publish.sh | 90 + tsconfig.base.json | 26 + tsconfig.json | 13 + vitest.config.ts | 46 + vitest.integration.config.ts | 17 + 342 files changed, 78022 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 configs/TEST_STRATEGY.md create mode 100644 configs/default-settings.json create mode 100644 configs/test-setup.ts create mode 100644 configs/test-utils.ts create mode 100644 docs/configuration.md create mode 100644 docs/getting-started.md create mode 100644 eslint.config.ts create mode 100755 install.sh create mode 100644 package.json create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/app.tsx create mode 100644 packages/cli/src/app/createGatewayEventHandler.ts create mode 100644 packages/cli/src/app/createSlashHandler.ts create mode 100644 packages/cli/src/app/delegationStore.ts create mode 100644 packages/cli/src/app/gatewayContext.tsx create mode 100644 packages/cli/src/app/inputSelectionStore.ts create mode 100644 packages/cli/src/app/interfaces.ts create mode 100644 packages/cli/src/app/overlayStore.ts create mode 100644 packages/cli/src/app/scroll.ts create mode 100644 packages/cli/src/app/setupHandoff.ts create mode 100644 packages/cli/src/app/slash/commands/core.ts create mode 100644 packages/cli/src/app/slash/commands/debug.ts create mode 100644 packages/cli/src/app/slash/commands/init.ts create mode 100644 packages/cli/src/app/slash/commands/ops.ts create mode 100644 packages/cli/src/app/slash/commands/session.ts create mode 100644 packages/cli/src/app/slash/commands/setup.ts create mode 100644 packages/cli/src/app/slash/registry.ts create mode 100644 packages/cli/src/app/slash/types.ts create mode 100644 packages/cli/src/app/spawnHistoryStore.ts create mode 100644 packages/cli/src/app/turnController.ts create mode 100644 packages/cli/src/app/turnStore.ts create mode 100644 packages/cli/src/app/uiStore.ts create mode 100644 packages/cli/src/app/useComposerState.ts create mode 100644 packages/cli/src/app/useConfigSync.ts create mode 100644 packages/cli/src/app/useInputHandlers.ts create mode 100644 packages/cli/src/app/useLongRunToolCharms.ts create mode 100644 packages/cli/src/app/useMainApp.ts create mode 100644 packages/cli/src/app/useSessionLifecycle.ts create mode 100644 packages/cli/src/app/useSubmission.ts create mode 100644 packages/cli/src/banner.ts create mode 100644 packages/cli/src/components/activeSessionSwitcher.tsx create mode 100644 packages/cli/src/components/agentsOverlay.tsx create mode 100644 packages/cli/src/components/appChrome.tsx create mode 100644 packages/cli/src/components/appLayout.tsx create mode 100644 packages/cli/src/components/appOverlays.tsx create mode 100644 packages/cli/src/components/branding.tsx create mode 100644 packages/cli/src/components/coordinatorDashboard.tsx create mode 100644 packages/cli/src/components/diffView.tsx create mode 100644 packages/cli/src/components/fileTree.tsx create mode 100644 packages/cli/src/components/fpsOverlay.tsx create mode 100644 packages/cli/src/components/helpHint.tsx create mode 100644 packages/cli/src/components/markdown.tsx create mode 100644 packages/cli/src/components/maskedPrompt.tsx create mode 100644 packages/cli/src/components/messageLine.tsx create mode 100644 packages/cli/src/components/modelPicker.tsx create mode 100644 packages/cli/src/components/overlayControls.tsx create mode 100644 packages/cli/src/components/prompts.tsx create mode 100644 packages/cli/src/components/queuedMessages.tsx create mode 100644 packages/cli/src/components/sessionPicker.tsx create mode 100644 packages/cli/src/components/skillsHub.tsx create mode 100644 packages/cli/src/components/slashCommandPopup.tsx create mode 100644 packages/cli/src/components/streamingAssistant.tsx create mode 100644 packages/cli/src/components/streamingMarkdown.tsx create mode 100644 packages/cli/src/components/textInput.tsx create mode 100644 packages/cli/src/components/themed.tsx create mode 100644 packages/cli/src/components/thinking.tsx create mode 100644 packages/cli/src/components/todoPanel.tsx create mode 100644 packages/cli/src/config/env.ts create mode 100644 packages/cli/src/config/limits.ts create mode 100644 packages/cli/src/config/timing.ts create mode 100644 packages/cli/src/content/charms.ts create mode 100644 packages/cli/src/content/faces.ts create mode 100644 packages/cli/src/content/fortunes.ts create mode 100644 packages/cli/src/content/hotkeys.ts create mode 100644 packages/cli/src/content/placeholders.ts create mode 100644 packages/cli/src/content/setup.ts create mode 100644 packages/cli/src/content/verbs.ts create mode 100644 packages/cli/src/demo.tsx create mode 100644 packages/cli/src/domain/details.ts create mode 100644 packages/cli/src/domain/messages.ts create mode 100644 packages/cli/src/domain/paths.ts create mode 100644 packages/cli/src/domain/providers.ts create mode 100644 packages/cli/src/domain/roles.ts create mode 100644 packages/cli/src/domain/slash.ts create mode 100644 packages/cli/src/domain/usage.ts create mode 100644 packages/cli/src/domain/viewport.ts create mode 100644 packages/cli/src/entry.tsx create mode 100644 packages/cli/src/gateway/__tests__/e2e-submit-message.test.ts create mode 100644 packages/cli/src/gateway/__tests__/integration.test.ts create mode 100644 packages/cli/src/gateway/client.ts create mode 100644 packages/cli/src/gateway/coder-client.ts create mode 100644 packages/cli/src/gateway/context.tsx create mode 100644 packages/cli/src/gateway/deferred.ts create mode 100644 packages/cli/src/gateway/engine-factory.ts create mode 100644 packages/cli/src/gateway/mock-client.ts create mode 100644 packages/cli/src/gateway/query-bridge.ts create mode 100644 packages/cli/src/gateway/types.ts create mode 100644 packages/cli/src/hooks/useCompletion.ts create mode 100644 packages/cli/src/hooks/useGitBranch.ts create mode 100644 packages/cli/src/hooks/useInputHistory.ts create mode 100644 packages/cli/src/hooks/useQueue.ts create mode 100644 packages/cli/src/hooks/useVirtualHistory.ts create mode 100644 packages/cli/src/lib/circularBuffer.ts create mode 100644 packages/cli/src/lib/clipboard.ts create mode 100644 packages/cli/src/lib/editor.test.ts create mode 100644 packages/cli/src/lib/editor.ts create mode 100644 packages/cli/src/lib/emoji.ts create mode 100644 packages/cli/src/lib/externalCli.ts create mode 100644 packages/cli/src/lib/externalLink.ts create mode 100644 packages/cli/src/lib/fileTreeBuilder.ts create mode 100644 packages/cli/src/lib/forceTruecolor.ts create mode 100644 packages/cli/src/lib/fpsStore.ts create mode 100644 packages/cli/src/lib/gracefulExit.ts create mode 100644 packages/cli/src/lib/history.ts create mode 100644 packages/cli/src/lib/inputMetrics.ts create mode 100644 packages/cli/src/lib/liveProgress.test.ts create mode 100644 packages/cli/src/lib/liveProgress.ts create mode 100644 packages/cli/src/lib/mathUnicode.ts create mode 100644 packages/cli/src/lib/memory.ts create mode 100644 packages/cli/src/lib/memoryMonitor.ts create mode 100644 packages/cli/src/lib/messages.test.ts create mode 100644 packages/cli/src/lib/messages.ts create mode 100644 packages/cli/src/lib/openExternalUrl.test.ts create mode 100644 packages/cli/src/lib/openExternalUrl.ts create mode 100644 packages/cli/src/lib/osc52.ts create mode 100644 packages/cli/src/lib/perfPane.tsx create mode 100644 packages/cli/src/lib/platform.ts create mode 100644 packages/cli/src/lib/precisionWheel.ts create mode 100644 packages/cli/src/lib/prompt.ts create mode 100644 packages/cli/src/lib/reasoning.ts create mode 100644 packages/cli/src/lib/rpc.ts create mode 100644 packages/cli/src/lib/subagentTree.ts create mode 100644 packages/cli/src/lib/syntax.ts create mode 100644 packages/cli/src/lib/terminalModes.ts create mode 100644 packages/cli/src/lib/terminalParity.ts create mode 100644 packages/cli/src/lib/terminalSetup.ts create mode 100644 packages/cli/src/lib/termux.ts create mode 100644 packages/cli/src/lib/text.test.ts create mode 100644 packages/cli/src/lib/text.ts create mode 100644 packages/cli/src/lib/todo.test.ts create mode 100644 packages/cli/src/lib/todo.ts create mode 100644 packages/cli/src/lib/viewportStore.ts create mode 100644 packages/cli/src/lib/virtualHeights.ts create mode 100644 packages/cli/src/lib/wheelAccel.ts create mode 100644 packages/cli/src/protocol/interpolation.ts create mode 100644 packages/cli/src/protocol/paste.ts create mode 100644 packages/cli/src/services/session-service.ts create mode 100644 packages/cli/src/theme.ts create mode 100644 packages/cli/src/types.ts create mode 100644 packages/cli/src/types/coder-tui.d.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/__tests__/checkpoint.test.ts create mode 100644 packages/core/src/__tests__/compactor.test.ts create mode 100644 packages/core/src/__tests__/error-recovery.test.ts create mode 100644 packages/core/src/__tests__/hooks-phase5.test.ts create mode 100644 packages/core/src/__tests__/placeholder.test.ts create mode 100644 packages/core/src/__tests__/query.test.ts create mode 100644 packages/core/src/__tests__/tool-registry.test.ts create mode 100644 packages/core/src/budget-store.ts create mode 100644 packages/core/src/checkpoint.ts create mode 100644 packages/core/src/context/compactor.ts create mode 100644 packages/core/src/context/token-budget.ts create mode 100644 packages/core/src/cron-scheduler.ts create mode 100644 packages/core/src/error-recovery.ts create mode 100644 packages/core/src/hooks/manager.ts create mode 100644 packages/core/src/hooks/types.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/memory/consolidator.ts create mode 100644 packages/core/src/memory/extractor.ts create mode 100644 packages/core/src/memory/index.ts create mode 100644 packages/core/src/memory/store.ts create mode 100644 packages/core/src/memory/types.ts create mode 100644 packages/core/src/permission/engine.ts create mode 100644 packages/core/src/provider-adapter.ts create mode 100644 packages/core/src/query-engine.ts create mode 100644 packages/core/src/query.ts create mode 100644 packages/core/src/rules-manager.ts create mode 100644 packages/core/src/scratchpad.ts create mode 100644 packages/core/src/session.ts create mode 100644 packages/core/src/subagent-bus.ts create mode 100644 packages/core/src/system-prompt/assembler.ts create mode 100644 packages/core/src/system-prompt/coordinator.ts create mode 100644 packages/core/src/tool-registry.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/client.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/provider/package.json create mode 100644 packages/provider/src/__tests__/placeholder.test.ts create mode 100644 packages/provider/src/anthropic.ts create mode 100644 packages/provider/src/base.ts create mode 100644 packages/provider/src/deepseek.ts create mode 100644 packages/provider/src/index.ts create mode 100644 packages/provider/src/lazy-deps.ts create mode 100644 packages/provider/src/openai-compat.ts create mode 100644 packages/provider/src/retry.ts create mode 100644 packages/provider/src/router.ts create mode 100644 packages/provider/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/__tests__/integration.test.ts create mode 100644 packages/shared/src/__tests__/utils/diff.test.ts create mode 100644 packages/shared/src/__tests__/utils/messages.test.ts create mode 100644 packages/shared/src/__tests__/utils/tokenizer.test.ts create mode 100644 packages/shared/src/agent-teams.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/protocol.ts create mode 100644 packages/shared/src/subagent-bus.ts create mode 100644 packages/shared/src/types/config.ts create mode 100644 packages/shared/src/types/hook.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/src/types/message.ts create mode 100644 packages/shared/src/types/permission.ts create mode 100644 packages/shared/src/types/session.ts create mode 100644 packages/shared/src/types/tool.ts create mode 100644 packages/shared/src/utils/diff.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/src/utils/messages.ts create mode 100644 packages/shared/src/utils/tokenizer.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/skills/package.json create mode 100644 packages/skills/src/creator.ts create mode 100644 packages/skills/src/improver.ts create mode 100644 packages/skills/src/index.ts create mode 100644 packages/skills/src/loader.ts create mode 100644 packages/skills/src/registry.ts create mode 100644 packages/skills/src/types.ts create mode 100644 packages/skills/tsconfig.json create mode 100644 packages/teams/src/__tests__/sprint5-test-strategy.ts create mode 100644 packages/tools/package.json create mode 100644 packages/tools/src/__tests__/bash.test.ts create mode 100644 packages/tools/src/__tests__/edit.test.ts create mode 100644 packages/tools/src/__tests__/glob.test.ts create mode 100644 packages/tools/src/__tests__/grep.test.ts create mode 100644 packages/tools/src/__tests__/placeholder.test.ts create mode 100644 packages/tools/src/__tests__/read.test.ts create mode 100644 packages/tools/src/__tests__/write.test.ts create mode 100644 packages/tools/src/agent-message.ts create mode 100644 packages/tools/src/agent-read.ts create mode 100644 packages/tools/src/agent-spawn.ts create mode 100644 packages/tools/src/agent-stop.ts create mode 100644 packages/tools/src/ask-user-question.ts create mode 100644 packages/tools/src/bash.ts create mode 100644 packages/tools/src/cron-create.d.ts create mode 100644 packages/tools/src/cron-create.d.ts.map create mode 100644 packages/tools/src/cron-create.js create mode 100644 packages/tools/src/cron-create.js.map create mode 100644 packages/tools/src/cron-create.ts create mode 100644 packages/tools/src/cron-delete.ts create mode 100644 packages/tools/src/cron-list.ts create mode 100644 packages/tools/src/edit.ts create mode 100644 packages/tools/src/enter-worktree.ts create mode 100644 packages/tools/src/exit-plan-mode.ts create mode 100644 packages/tools/src/exit-worktree.ts create mode 100644 packages/tools/src/git.ts create mode 100644 packages/tools/src/glob.ts create mode 100644 packages/tools/src/grep.ts create mode 100644 packages/tools/src/index.ts create mode 100644 packages/tools/src/lsp.ts create mode 100644 packages/tools/src/notebook-edit.ts create mode 100644 packages/tools/src/read.ts create mode 100644 packages/tools/src/skill.ts create mode 100644 packages/tools/src/task-create.ts create mode 100644 packages/tools/src/task-describe.ts create mode 100644 packages/tools/src/task-list.ts create mode 100644 packages/tools/src/task-output.ts create mode 100644 packages/tools/src/task-update.ts create mode 100644 packages/tools/src/team-create.ts create mode 100644 packages/tools/src/team-delete.ts create mode 100644 packages/tools/src/todo-write.ts create mode 100644 packages/tools/src/web-fetch.ts create mode 100644 packages/tools/src/web-search.ts create mode 100644 packages/tools/src/write.ts create mode 100644 packages/tools/tsconfig.json create mode 100644 packages/tui/ambient.d.ts create mode 100644 packages/tui/package.json create mode 100644 packages/tui/src/ambient-fixes.d.ts create mode 100644 packages/tui/src/bootstrap/state.ts create mode 100644 packages/tui/src/compat/alternate-screen.tsx create mode 100644 packages/tui/src/compat/ansi.tsx create mode 100644 packages/tui/src/compat/box.tsx create mode 100644 packages/tui/src/compat/cache-eviction.ts create mode 100644 packages/tui/src/compat/cursor-hooks.ts create mode 100644 packages/tui/src/compat/error-boundary.tsx create mode 100644 packages/tui/src/compat/external-process.ts create mode 100644 packages/tui/src/compat/force-redraw.ts create mode 100644 packages/tui/src/compat/lru.ts create mode 100644 packages/tui/src/compat/main-screen.tsx create mode 100644 packages/tui/src/compat/mouse-tracker.tsx create mode 100644 packages/tui/src/compat/no-select.tsx create mode 100644 packages/tui/src/compat/raw-ansi.tsx create mode 100644 packages/tui/src/compat/render-utils.ts create mode 100644 packages/tui/src/compat/scroll-box.tsx create mode 100644 packages/tui/src/compat/scroll-stats.ts create mode 100644 packages/tui/src/compat/selection.ts create mode 100644 packages/tui/src/compat/tab-status.ts create mode 100644 packages/tui/src/compat/terminal-detect.ts create mode 100644 packages/tui/src/compat/terminal-focus.ts create mode 100644 packages/tui/src/compat/terminal-title.ts create mode 100644 packages/tui/src/compat/terminal-viewport.ts create mode 100644 packages/tui/src/compat/text.tsx create mode 100644 packages/tui/src/compat/types.ts create mode 100644 packages/tui/src/compat/use-input.ts create mode 100644 packages/tui/src/compat/use-stdin.ts create mode 100644 packages/tui/src/entry-exports.ts create mode 100644 packages/tui/src/hooks/use-stderr.ts create mode 100644 packages/tui/src/hooks/use-stdout.ts create mode 100644 packages/tui/src/native-ts/yoga-layout/enums.ts create mode 100644 packages/tui/src/native-ts/yoga-layout/index.ts create mode 100644 packages/tui/src/text-input.ts create mode 100644 packages/tui/src/utils/debug.ts create mode 100644 packages/tui/src/utils/earlyInput.ts create mode 100644 packages/tui/src/utils/env.ts create mode 100644 packages/tui/src/utils/envUtils.ts create mode 100644 packages/tui/src/utils/execFileNoThrow.test.ts create mode 100644 packages/tui/src/utils/execFileNoThrow.ts create mode 100644 packages/tui/src/utils/fullscreen.ts create mode 100644 packages/tui/src/utils/intl.ts create mode 100644 packages/tui/src/utils/log.ts create mode 100644 packages/tui/src/utils/semver.ts create mode 100644 packages/tui/src/utils/sliceAnsi.ts create mode 100644 packages/tui/tsconfig.json create mode 100644 patches/ink@7.0.5.patch create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prettier.config.js create mode 100755 scripts/changelog.sh create mode 100755 scripts/publish.sh create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts create mode 100644 vitest.integration.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96e9762 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,287 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +env: + PNPM_VERSION: '9.15.4' + NODE_VERSION: '22' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ═══════════════════════════════════════════════════════════════════════════ + # Lint & Format Check + # ═══════════════════════════════════════════════════════════════════════════ + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run ESLint + run: pnpm lint + + - name: Check formatting + run: pnpm format:check + + # ═══════════════════════════════════════════════════════════════════════════ + # Type Check + # ═══════════════════════════════════════════════════════════════════════════ + type-check: + name: Type Check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run TypeScript compiler + run: pnpm type-check + + # ═══════════════════════════════════════════════════════════════════════════ + # Unit Tests — Run per package × per Node.js version matrix + # ═══════════════════════════════════════════════════════════════════════════ + test: + name: Test (${{ matrix.package }} / Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node-version: [18, 20, 22] + package: + - shared + - core + - tools + - provider + - cli + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests for @kode/${{ matrix.package }} (Node ${{ matrix.node-version }}) + run: pnpm --filter @kode/${{ matrix.package }} test -- --coverage + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.package }} + path: packages/${{ matrix.package }}/coverage/ + retention-days: 7 + + # ═══════════════════════════════════════════════════════════════════════════ + # Coverage Report — aggregate and verify thresholds + # ═══════════════════════════════════════════════════════════════════════════ + coverage: + name: Coverage Report + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests with coverage + run: pnpm test:coverage + + - name: Upload combined coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 14 + + # ═══════════════════════════════════════════════════════════════════════════ + # Build — verify all packages build successfully + # ═══════════════════════════════════════════════════════════════════════════ + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + # ═══════════════════════════════════════════════════════════════════════════ + # Security Scan + # ═══════════════════════════════════════════════════════════════════════════ + security: + name: Security Scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run npm audit + run: pnpm audit --audit-level=high + continue-on-error: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + + # ═══════════════════════════════════════════════════════════════════════════ + # Integration Tests — Docker sandbox + full Agent Loop + # ═══════════════════════════════════════════════════════════════════════════ + integration: + name: Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: build + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + - name: Run integration tests (Docker sandbox) + run: pnpm test:integration + env: + KODE_SANDBOX_MODE: 'docker' + + # ═══════════════════════════════════════════════════════════════════════════ + # Sandbox Escape Detection — verify no breakout vectors + # ═══════════════════════════════════════════════════════════════════════════ + sandbox-check: + name: Sandbox Escape Detection + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: build + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + - name: Run sandbox escape test suite + run: pnpm --filter @kode/cli test -- --grep 'sandbox|escape|isolation' + + # ═══════════════════════════════════════════════════════════════════════════ + # CI Summary — gate on all checks + # ═══════════════════════════════════════════════════════════════════════════ + ci-pass: + name: CI Pass + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [lint, type-check, test, coverage, build, security, integration, sandbox-check] + if: always() + steps: + - name: Check results + run: | + echo "## CI Results" >> $GITHUB_STEP_SUMMARY + for job in lint type-check test coverage build security integration sandbox-check; do + result="${{ needs.$job.result }}" + echo "- **$job**: $result" >> $GITHUB_STEP_SUMMARY + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "Job $job failed with result: $result" + exit 1 + fi + done + echo "All CI checks passed!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f598830 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + tags: ["v*"] + +env: + PNPM_VERSION: '9.15.4' + NODE_VERSION: '22' + +permissions: + contents: write + packages: write + +jobs: + # ═══════════════════════════════════════════════════════════════════════════ + # Full CI gate before publishing + # ═══════════════════════════════════════════════════════════════════════════ + gate: + name: Pre-release CI Gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Type check + run: pnpm type-check + + - name: Build all packages + run: pnpm build + + - name: Run tests + run: pnpm test + + # ═══════════════════════════════════════════════════════════════════════════ + # Publish all packages to npm + # ═══════════════════════════════════════════════════════════════════════════ + publish: + name: Publish to npm + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: gate + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed for changelog generation + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + - name: Generate CHANGELOG + run: ./scripts/changelog.sh "${{ github.ref_name }}" + + - name: Publish packages + run: ./scripts/publish.sh + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body_path: CHANGELOG.md + generate_release_notes: false + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b199cd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Environment +.env +.env.* +!.env.example + +# Database +*.db +*.db-journal +*.db-wal +*.db-shm + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log + +# Coverage +coverage/ + +# Temp +.tmp/ +tmp/ + +# Claude Code local settings (should not be committed) +.claude/ + +# Kode Agent local config (should not be committed) +.kode/ + +# Kode Agent — user config and sessions (should not be committed) +~/.kode/ + +# Profiling artifacts +*.heapsnapshot +*.cpuprofile diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ce4d2b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## [0.1.0] - Unreleased + +### Added + +- Initial open-source release of Coder Agent +- Multi-provider LLM support (Anthropic, OpenAI, DeepSeek) with auto-routing +- Agent Teams with Coordinator/Worker pattern and SubagentBus +- 27-event lifecycle hook system (Shell + JS handlers) +- 3-tier permission model (Plan/Ask/Auto) with risk-level classification +- React Ink-based Terminal UI with slash commands +- Session fork/rewind with checkpoint recovery +- Context compaction (Snip + Summarize + Microcompact) +- BudgetStore disk offload for large tool outputs +- Self-evolving skill system with Progressive Disclosure +- MCP protocol support (Client + Server, JSON-RPC 2.0 over stdio) +- Durable Cron scheduler +- Git worktree isolation +- FTS5 memory system with semantic search +- 26+ built-in tools (Read, Write, Edit, Bash, Glob, Grep, Git, WebFetch, WebSearch, etc.) +- Docker sandbox support +- CI pipeline (lint, type-check, test matrix, CodeQL, integration tests) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68dfb15 --- /dev/null +++ b/README.md @@ -0,0 +1,408 @@ +# Coder Agent + +

+ An open-source, enterprise-grade CLI agent coding tool
+ A Claude Code alternative with multi-provider support, Agent Teams, and a powerful hook system +

+ +

+ License: MIT + TypeScript + Node.js >= 18.0.0 + Tests +

+ +--- + +**Coder Agent** is an open-source CLI coding agent that brings enterprise-grade Agent Team orchestration, multi-provider LLM support, and deep extensibility through its hook system. It runs in your terminal, reads and writes code, executes shell commands, and orchestrates multiple AI agents to solve complex engineering tasks — all with fine-grained permission control and comprehensive context management. + +--- + +## Quick Start + +```bash +# Prerequisites: Node.js >= 18.0.0 (>= 22.0.0 recommended) + +# Clone the repo +git clone https://github.com/AgenticMatrix/CoderAgent.git +cd CoderAgent + +# One-click install (builds, links `coder` command globally, sets up config) +./install.sh + +# Set your model and API key (DeepSeek by default) +coder --model + +# Start coding +coder +``` + +> **Note:** The installer defaults to **DeepSeek** as the LLM provider. To use Anthropic or OpenAI, set the corresponding API key and edit `~/.coder/settings.json`. + +--- + +## Key Features + +### 🧠 Intelligent Agent Loop +**ReAct pattern** powered by TypeScript `AsyncGenerator`. Supports real-time streaming, interrupt & resume, user confirmation pauses, and checkpoint recovery. Prevents infinite loops with max-turns, budget cap, token threshold, and duplicate-operation detection. + +### 👥 Agent Teams +**Coordinator/Worker pattern** with async `SubagentBus` communication. Spawn sub-agents for exploration, building, code review, and planning. Workers communicate via shared context and task notifications. Supports `TeamCreate`/`TeamDelete` for on-demand team topology. + +```bash +# Coordinator mode with 4 workers +coder --coordinator --workers 4 "Fix all TypeScript errors across the codebase" + +# Worker mode (joins an existing team) +coder --worker --team my-team-id +``` + +### 🔌 Multi-Provider +Unified `Provider` interface supporting **Anthropic** (native extended thinking), **OpenAI** (GPT-4o, o4-mini), and **DeepSeek** (R1 reasoning). **Auto Router** automatically selects the optimal model based on task complexity. Hot-swap providers without changing agent logic. + +### 🛠️ Built-in Tools +File system (Read, Write, Edit, Glob), search (Grep), shell (Bash), version control (Git), task management (TodoWrite), browser (WebFetch, WebSearch), agent orchestration (AgentSpawn, AgentMessage, AgentStop), system (Skill, Cron, Worktree), and LSP integration. MCP protocol support for community tool extensions. + +### 🔐 3-Tier Permission Model +**Plan** (read-only, auto-approved) → **Ask** (prompt user for mutations) → **Auto** (trusted workspace, full authorization). `SAFE` / `MUTATION` / `DESTRUCTIVE` risk levels. PermissionRequest hooks allow third-party approval plugins. Configurable sandbox modes (Docker, local) for shell execution isolation. + +### 🪝 Lifecycle Hooks +`SessionStart`, `UserPromptSubmit`, `PreMessage`, `PostMessage`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact` — and 19 more event types (27 total). Blockable hooks (PreMessage, PreToolUse) can veto actions. Non-blockable hooks (PostMessage, Notification) fire-and-forget for observability. Shell and JS function handlers. + +### 📦 Context Management +**Snip compaction** (drop oldest messages, keep last 30) and **Summarize** (LLM-based compression). **Microcompact** — zero-LLM cleanup on >60min idle sessions. **BudgetStore** — disk offload for large tool outputs (>50KB single, >200KB aggregate). Dynamic system prompt refresh per turn. + +### 🧩 Session Fork & Rewind +Full session lifecycle: `create`, `resume`, `fork` from turn N to explore alternatives, `rewind` to a previous turn, `continue` the last session. Auto file-change checkpoints after every Write/Edit. Git stash checkpoints before destructive operations. + +```bash +# Resume most recent session +coder --continue + +# Resume specific session +coder --resume sess_abc123 + +# Fork a session from turn 5 +coder --fork-session sess_abc123 --fork-turn 5 +``` + +### 🎨 Terminal UI (TUI) +React Ink-based terminal renderer with command palette, model picker, multi-panel views, and full mouse support. Includes a rich set of slash commands accessible via `/` prefix. + +#### TUI Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Enter` | Send message | +| `Ctrl+C` | Interrupt agent / exit | +| `Ctrl+L` | Clear screen | +| `Ctrl+R` | Toggle reasoning display | +| `↑` / `↓` | Navigate history | +| `/` | Open slash command palette | + +#### Slash Commands + +Type `/` during an interactive session to access built-in commands: + +| Command | Description | +|---------|-------------| +| `/help` | List all commands and hotkeys | +| `/clear` | Start a new session | +| `/resume` | Resume a prior session | +| `/model` | Change or show the current model | +| `/sessions` | Switch between live TUI sessions | +| `/compress` | Compress the conversation transcript | +| `/branch` | Fork/branch the current session | +| `/usage` | Show session usage stats (tokens, cost) | +| `/agents` | Open the spawn-tree dashboard | +| `/skills` | Browse, inspect, and install skills | +| `/tools` | Enable or disable tools | +| `/rollback` | List, diff, or restore checkpoints | +| `/status` | Show live session info | +| `/quit` | Exit Coder Agent | +| `/init` | Bootstrap a project context file | + +--- + +## Skill System + +Coder Agent features a **self-evolving skill system** powered by `SKILL.md` files. Skills are discovered, loaded, and improved automatically through a three-phase lifecycle: + +1. **Create** — The agent detects repeated task patterns across sessions and auto-proposes new skills. Users can also manually install skills from the community hub via `/skills install`. + +2. **Use** — Skills are loaded at session start via **Progressive Disclosure**: only skill names and descriptions appear in the system prompt. When a task matches a skill's intent, the full `SKILL.md` body is injected into context on-demand, keeping token usage low. + +3. **Improve** — After each skill execution, the agent evaluates the outcome. If improvements are detected (shorter execution, fewer turns, fewer errors), the skill is auto-updated. This creates a virtuous cycle where skills compound in quality over time. + +Skills are stored in `~/.coder/skills/` and can be managed via: + +```bash +/skills list # List installed skills +/skills browse # Browse community skills +/skills install # Install a skill by name or URL +/skills inspect # Inspect a skill's full definition +/skills search # Search community skills by keyword +``` + +Configure skill auto-creation and auto-improvement in `~/.coder/settings.json`: + +```yaml +skills: + autoCreate: true # Auto-propose new skills from repeated tasks + autoImprove: true # Auto-improve skills after execution + minRepeatForSkill: 2 # Times a task must repeat before skill creation +``` + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TUI Layer (@coder/tui) │ +│ React Ink Terminal Renderer + Gateway │ +│ query-bridge.ts ↔ GatewayEvent │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ AsyncGenerator + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Agent Loop (core/query.ts) │ +│ while(true): LLM Stream → Tool Execute → Observe → Repeat │ +│ Exit: maxTurns | budget | abort | stopReason ≠ tool_use │ +└──┬──────────┬──────────┬──────────┬──────────┬──────────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ +│Provider│ │Hooks │ │Tools │ │Context│ │Subagent │ +│Adapter│ │System│ │Reg. │ │Mgmt │ │Bus │ +└──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘ └────┬─────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + Anthropic Shell File/Sys Compactor Worker + OpenAI JS Func Shell/Git BudgetStore Agents + DeepSeek MCP/Ext. Snip/Summ. +``` + +### Packages + +| Package | Description | +|---------|-------------| +| `@coder/cli` | Terminal entry point, TUI gateway, engine wiring, slash commands | +| `@coder/core` | Agent Loop, QueryEngine, Hooks, Context, Checkpoint, Session, Cron | +| `@coder/shared` | Types, utilities, protocol definitions, config loader | +| `@coder/provider` | Anthropic, OpenAI, DeepSeek adapters + Auto Router | +| `@coder/tools` | Tool system: registry, orchestrator, permission engine | +| `@coder/skills` | SKILL.md discovery, Progressive Disclosure, self-evolution | +| `@coder/mcp` | MCP Client & Server (JSON-RPC 2.0 over stdio) | +| `@coder/tui` | React Ink terminal renderer with Yoga Layout, ANSI processing | +| `teams/` | Team topology definitions and role configurations | + +--- + +## Comparison + +| Feature | Coder Agent | Claude Code | Hermes-Agent | +|---------|-----------|-------------|--------------| +| **License** | MIT | Proprietary | MIT | +| **Multi-Provider** | ✅ Anthropic + OpenAI + DeepSeek + Auto | ❌ Anthropic only | ✅ Multi-provider | +| **Agent Teams** | ✅ Coordinator/Worker + SubagentBus | ❌ Single agent | ❌ Single agent | +| **Hook System** | ✅ 27 events, Shell + JS | ⚠️ Limited hooks | ❌ No hooks | +| **Permission Model** | ✅ 3-tier (Plan/Ask/Auto) + Risk | ⚠️ Ask/Auto only | ⚠️ Basic | +| **Context Compaction** | ✅ Snip + Summarize + Microcompact | ✅ Summarize + Archive | ❌ Limited | +| **BudgetStore Disk Offload** | ✅ Per-result + Aggregate | ❌ No | ❌ No | +| **Session Fork/Rewind** | ✅ Fork from any turn | ❌ No | ❌ No | +| **Thinking Support** | ✅ DeepSeek R1 + Claude Extended | ✅ Claude Extended | ❌ Limited | +| **MCP Protocol** | ✅ Full Client + Server | ✅ Client only | ⚠️ Partial | +| **Skill System** | ✅ Self-evolving Skills | ✅ Skills | ❌ No | +| **Sandbox** | ✅ Docker + Local modes | ⚠️ Basic | ❌ No | +| **Cron Scheduler** | ✅ Durable tasks | ✅ Scheduled tasks | ❌ No | +| **Worktree** | ✅ Git worktree isolation | ✅ Git worktree | ❌ No | +| **FTS5 Memory** | ✅ Semantic search | ✅ Memory system | ❌ No | + +> **Coder Agent** stands out in enterprise scenarios with its **Agent Teams**, **multi-provider flexibility**, **deep hook system**, and **fine-grained permission control**. If you need a single-agent coding tool, Claude Code is excellent. If you need AI agent orchestration with extensibility, Coder Agent is the right choice. + +--- + +## Configuration + +Coder Agent uses `~/.coder/settings.json` for persistent config. Settings are auto-populated at runtime — no manual environment variable exports needed. + +**Priority order:** +1. CLI flags (`--model provider/model-name`) +2. `~/.coder/settings.json` → `default_model` + `model_list[]` + `env.*` + +**Quick setup:** + +```bash +# Interactive model selection (provider → model → auth token) +coder --model + +# Direct model set +coder --model deepseek/deepseek-v4-pro +``` + +Environment variables are auto-populated from settings.json at runtime. No need to manually export `CODER_AUTH_TOKEN`, `CODER_MODEL`, or `CODER_BASE_URL`. + +### Model Switching + +Switch models interactively: +```bash +coder --model # Interactive selection from model_list +``` + +### ~/.coder/settings.json + +```json +{ + "theme": "dark", + "default_model": "deepseek/deepseek-v4-pro", + "model_list": [ + { + "model": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"], + "base_url": "https://api.deepseek.com/anthropic", + "auth_token_env": "YOUR_DEEPSEEK_API_KEY", + "provider": "deepseek", + "price": { + "input": 0.14, + "output": 0.28, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["claude-sonnet-2025", "opus-4.8"], + "base_url": "https://api.deepseek.com/anthropic", + "auth_token_env": "YOUR_ANTHROPIC_API_KEY", + "provider": "anthropic", + "price": { + "input": 3.0, + "output": 15.0, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["gpt-4-turbo", "gpt-4o", "gpt-3.5-turbo"], + "base_url": "https://api.openai.com/v1", + "auth_token_env": "YOUR_OPENAI_API_KEY", + "provider": "openai", + "price": { + "input": 10.0, + "output": 30.0, + "currency": "USD", + "unit": "1M tokens" + } + } + ], + "env": { + "CODER_MODEL": "deepseek-v4-pro", + "CODER_BASE_URL": "https://api.deepseek.com/anthropic", + "CODER_AUTH_TOKEN": "YOUR_DEEPSEEK_API_KEY" + } +} +``` + +### Hook Configuration + +Place hook definitions in `~/.coder/hooks/*.json`: + +```json +{ + "event": "PreToolUse", + "handler": { + "type": "shell", + "command": "/usr/local/bin/coder-guard", + "timeout": 30000 + } +} +``` + +Supported events: `SessionStart`, `UserPromptSubmit`, `PreMessage`, `PostMessage`, `PreToolUse`, `PostToolUse`, `PostToolBatch`, `Stop`, `StopFailure`, `PreCompact`, `PostCompact`, `PermissionRequest`, `PermissionDenied`, `Notification`, `SubagentStart`, `SubagentStop`, and more. + +### Project Configuration (CODER.md) + +Place a `CODER.md` file in your project root for project-specific instructions. Coder Agent automatically loads this as context at the start of every session: + +```markdown +# Project Overview + +This is a Node.js microservice with PostgreSQL and Redis. + +## Architecture +- `src/app/` — Application entry points +- `src/components/` — Shared components +- `src/lib/` — Utilities and business logic +- `db/` — Database schema and migrations + +## Conventions +- Use TypeScript strict mode +- Tests use Vitest +- API routes follow RESTful conventions +``` + +Use `/init` in an interactive session to have Coder Agent auto-generate a `CODER.md` for your project. + +--- + +## Development + +```bash +# Install dependencies +pnpm install + +# Build all packages +pnpm build + +# Run tests (~500 tests across 41 test files) +pnpm test + +# Type check +pnpm type-check + +# Lint +pnpm lint + +# CI pipeline (lint + type-check + tests + build) +pnpm ci +``` + +### Package Descriptions + +| Package | Description | +|---------|------------| +| `packages/shared` | Shared types, utilities, protocol definitions, config loader | +| `packages/tui` | Terminal rendering framework (React Ink + Yoga Layout) | +| `packages/tools` | Tool system: registry, orchestrator, permission engine | +| `packages/provider` | LLM provider adapters (Anthropic, OpenAI, DeepSeek) + Auto Router | +| `packages/skills` | Skill system: SKILL.md discovery, creation, improvement | +| `packages/mcp` | MCP Client & Server (JSON-RPC 2.0 over stdio) | +| `packages/core` | Core runtime: Agent Loop, QueryEngine, Hooks, Context, Session, Cron | +| `packages/cli` | CLI entry point, TUI gateway, slash commands, engine factory | +| `packages/teams` | Team topology definitions and coordinator/worker strategies | + +### Requirements + +- **Node.js** >= 18.0.0 (Node.js >= 22.0.0 recommended) +- **pnpm** >= 9.15.0 +- **TypeScript** 5.7+ +- **macOS** 12+ or **Linux** (for sandbox features) + +--- + +## Documentation + +- [Getting Started Guide](./docs/getting-started.md) — installation, basic usage, TUI shortcuts, session management +- [Configuration Reference](./docs/configuration.md) — full settings.json schema, env vars, permission modes, sandbox setup + +--- + +## License + +MIT © Coder Agent Contributors + +--- + +

+ Built with TypeScript, React Ink, and ❤️ by the Coder Agent community +

diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..10ac0a8 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,14 @@ +# Coder Agent Configuration (DEPRECATED FORMAT) +# This file is kept for reference only. Modern config uses ~/.coder/settings.json. +# Copy this file to ~/.coder/config.toml to use the legacy TOML format. + +[env] +# Coder API Configuration +# Priority: environment variables > this file > defaults +# For API key: CODER_API_KEY > CODER_AUTH_TOKEN +CODER_BASE_URL = "https://api.deepseek.com/v1" +CODER_AUTH_TOKEN = "sk-ant-xxxxx" +CODER_MODEL = "deepseek-v4-pro" + +# Appearance +theme = "dark" diff --git a/configs/TEST_STRATEGY.md b/configs/TEST_STRATEGY.md new file mode 100644 index 0000000..aed24d9 --- /dev/null +++ b/configs/TEST_STRATEGY.md @@ -0,0 +1,315 @@ +# Coder Agent 测试策略 + +> 基于 Hermes Agent 测试实践总结,适用于 Coder Agent monorepo 项目。 + +--- + +## 一、Hermes Agent 测试模式分析 + +### 1.1 测试层次 + +Hermes Agent(17,000+ 测试)采用四层测试架构: + +| 层次 | 范围 | 占比 | 说明 | +|------|------|------|------| +| **单元测试** | 单函数/类 | ~70% | 快速、独立、可并行 | +| **组件测试** | 单个子系统 | ~15% | mock 外部依赖 | +| **集成测试** | 跨模块交互 | ~10% | Docker/真实服务 | +| **E2E 测试** | 完整用户流程 | ~5% | 真实平台交互 | + +### 1.2 核心模式 + +#### 模式 1:Hermetic 环境(`conftest.py`) + +每个测试文件运行在彻底的隔离环境中: + +```python +# 1. 清除所有凭证环境变量(防止开发者密钥泄露) +# 2. 隔离 HERMES_HOME(每个测试独立 tempdir) +# 3. 确定性运行时(TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0) +# 4. 禁止 HERMES_SESSION_* 继承 +``` + +**Coder Agent 适配**:已实现 `configs/test-setup.ts`,功能完全对齐。 + +#### 模式 2:Per-File 进程隔离 + +每个测试文件在独立 Python 子进程中运行,防止跨文件状态泄漏(module-level dicts、ContextVars、caches)。 + +``` +scripts/run_tests_parallel.py → 每个测试文件独立 subprocess +``` + +**Coder Agent 适配**: +- Vitest 默认在独立 Worker 线程中运行测试文件(`pool: 'threads'`) +- 每个 Worker 有独立的模块缓存 +- 配合 `configs/test-setup.ts` 的 `beforeEach`/`afterEach` 确保 per-test 清理 + +#### 模式 3:工厂函数 + Fixture + +大量使用工厂函数(`make_source`, `make_session_entry`, `make_event`, `make_runner`)构建测试数据,而非全局 fixture: + +```python +def make_source(platform, chat_id="e2e-chat-1", user_id="e2e-user-1"): + return SessionSource(platform=platform, chat_id=chat_id, ...) + +def make_runner(platform, session_entry=None): + """跳过 __init__ 避免文件系统/网络副作用""" + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(...) + # 手动注入 mock 依赖 + runner.session_store = MagicMock() + return runner +``` + +**Coder Agent 适配**:已实现 `configs/test-utils.ts`: +- `createTestContext()` — 构建 Agent 测试上下文 +- `mockAgentResponse()` — 模拟 LLM 响应 +- `mockAgentToolUse()` — 模拟工具调用响应 +- `createUserMessage()` / `createAssistantMessage()` — 消息工厂 + +#### 模式 4:E2E 无 LLM 测试 + +E2E 测试不调用真实 LLM API,而是 mock 整个消息流: + +```python +# tests/e2e/conftest.py +runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default") +adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1")) +``` + +完整的消息流测试:adapter.handle_message(event) → background task → GatewayRunner → adapter.send() + +**Coder Agent 适配**: +- 单元测试 mock LLM 调用(`mockAgentResponse`) +- 集成测试使用 mock provider +- E2E 测试通过 CLI `--print` 模式 + 脚本化输入验证完整流程 + +#### 模式 5:参数化跨平台测试 + +```python +@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK]) +def platform(request): + return request.param +``` + +**Coder Agent 适配**: +- Provider 测试参数化:`describe.each([['anthropic'], ['openai'], ['deepseek']])` +- 工具测试参数化:不同工具的风险等级、参数组合 + +### 1.3 关键数据 + +| 指标 | Hermes Agent | Coder Agent 一期目标 | +|------|-------------|---------------------| +| 总测试数 | ~17,000+ | 200+ | +| 测试文件数 | ~90 | 20+ | +| 行覆盖率 | 未公开 | ≥80% | +| 分支覆盖率 | 未公开 | ≥75% | +| CI 并行度 | 按文件拆分 | 按 package 拆分 | + +--- + +## 二、Coder Agent 测试架构 + +### 2.1 测试目录结构 + +``` +coder-agent/ +├── configs/ +│ ├── test-setup.ts # 全局测试环境(hermetic invariants) +│ ├── test-utils.ts # 测试辅助函数(工厂、mock、断言) +│ └── TEST_STRATEGY.md # 本文档 +├── vitest.config.ts # Vitest 配置(含覆盖率阈值) +├── packages/ +│ ├── shared/ +│ │ └── src/ +│ │ ├── utils/ +│ │ │ ├── tokenizer.ts +│ │ │ ├── diff.ts +│ │ │ └── messages.ts +│ │ └── __tests__/ +│ │ ├── utils/ +│ │ │ ├── tokenizer.test.ts +│ │ │ ├── diff.test.ts +│ │ │ └── messages.test.ts +│ │ └── integration.test.ts +│ ├── core/ +│ │ └── src/ +│ │ ├── query.ts # Agent Loop +│ │ ├── query-engine.ts +│ │ ├── ... +│ │ └── __tests__/ +│ │ ├── query.test.ts +│ │ ├── query-engine.test.ts +│ │ └── ... +│ ├── tools/ +│ │ └── src/ +│ │ ├── bash.ts +│ │ ├── file-read.ts +│ │ └── __tests__/ +│ ├── provider/ +│ │ └── src/ +│ │ ├── anthropic.ts +│ │ └── __tests__/ +│ └── cli/ +│ └── src/ +│ ├── components/ +│ └── __tests__/ +``` + +### 2.2 测试分类策略 + +| 分类 | 标记方式 | 运行方式 | 超时 | +|------|---------|---------|------| +| **单元测试** | 默认 | `pnpm test` | 5s | +| **集成测试** | 文件名 `*.integration.test.ts` | `pnpm test:integration` | 30s | +| **E2E 测试** | 文件名 `*.e2e.test.ts` | 单独 workflow | 60s | +| **快照测试** | `expect().toMatchSnapshot()` | `pnpm test -- -u` | 5s | +| **性能测试** | `*.bench.ts` | `vitest bench` | N/A | + +### 2.3 覆盖率阈值 + +```typescript +// vitest.config.ts +coverage: { + thresholds: { + lines: 80, // 行覆盖率 ≥ 80% + branches: 75, // 分支覆盖率 ≥ 75% + functions: 80, // 函数覆盖率 ≥ 80% + statements: 80, // 语句覆盖率 ≥ 80% + }, +} +``` + +豁免规则: +- `index.ts`(barrel 导出文件) +- `types/`(纯类型定义) +- `*.test.ts` / `*.spec.ts`(测试文件自身) + +--- + +## 三、测试编写规范 + +### 3.1 命名约定 + +``` +describe('ModuleName', () => { + describe('functionName', () => { + it('should [expected behavior] when [condition]', () => { ... }); + }); +}); +``` + +示例: +```typescript +describe('tokenizer', () => { + describe('countTokens', () => { + it('should return 0 for empty string', () => { ... }); + it('should handle unicode characters', () => { ... }); + it('should fallback gracefully for unknown models', () => { ... }); + }); +}); +``` + +### 3.2 测试模式清单 + +每个模块必须覆盖: + +- [ ] **正常路径** — 预期输入产生预期输出 +- [ ] **边界条件** — 空输入、零值、最大值 +- [ ] **错误路径** — 无效输入、异常处理 +- [ ] **幂等性** — 重复调用产生一致结果(适用时) +- [ ] **并发安全** — 多线程/异步安全(适用时) + +### 3.3 Mock 策略 + +``` +优先级: +1. 优先使用工厂函数(test-utils.ts)构建真实对象 +2. 使用 vi.fn() mock 外部服务 +3. 使用 vi.mock() mock 整个模块(谨慎使用) +4. 禁止 mock 被测试的模块自身 +``` + +### 3.4 Hermetic 原则 + +每个测试必须: + +1. **不依赖外部状态** — 不读取真实文件系统、环境变量、网络 +2. **不影响其他测试** — `afterEach` 清理所有副作用 +3. **可重复执行** — 相同输入始终产生相同输出 +4. **可在 CI 运行** — 不依赖本地开发环境特定配置 + +--- + +## 四、CI 流水线 + +### 4.1 触发条件 + +| 事件 | 触发 | +|------|------| +| Push to `main` | 全部 jobs | +| Push to `develop` | 全部 jobs | +| PR to `main` | 全部 jobs | + +### 4.2 并行策略 + +``` +lint ────────────────┐ +type-check ──────────┤ +test (shared) ───────┤ +test (core) ─────────┤ +test (tools) ────────┼──→ coverage ──→ build ──→ ci-pass +test (provider) ─────┤ +test (cli) ──────────┤ +security (CodeQL) ───┘ +``` + +### 4.3 缓存策略 + +- **pnpm store**: 使用 `actions/setup-node@v4` 的 `cache: 'pnpm'` +- **TypeScript**: 使用 `composite: true` + `incremental: true` 增量编译 +- **Coverage artifacts**: 保留 7-14 天 + +--- + +## 五、渐进式实施计划 + +### Phase 1(当前)— 测试基础设施 + +- [x] Vitest 配置 + 覆盖率阈值 +- [x] Global test setup(hermetic invariants) +- [x] Test utilities(工厂函数 + mock + 断言) +- [x] Shared 包单元测试(tokenizer, diff, messages) +- [x] CI 流水线配置 +- [x] 每个 Package 的示例测试 + +### Phase 2 — 核心运行时测试 + +- [ ] `core/query.ts` Agent Loop 测试(mock LLM 响应) +- [ ] `core/system-prompt/` 动态组装测试 +- [ ] `core/context/` 上下文压缩测试 +- [ ] `core/permission/` 权限引擎测试 +- [ ] `core/hooks/` Hooks 生命周期测试 + +### Phase 3 — 工具系统测试 + +- [ ] 每个工具的单元测试(bash, read, write, edit, glob, grep, etc.) +- [ ] `tool-registry.ts` 自动发现测试 +- [ ] `orchestrator.ts` 工具编排测试 +- [ ] 沙箱执行测试(macOS Seatbelt / Linux Landlock) + +### Phase 4 — 集成 + E2E + +- [ ] Provider 集成测试(mock HTTP 响应) +- [ ] Agent Teams 集成测试(Coordinator + Worker + Mailbox) +- [ ] CLI E2E 测试(`--print` 模式) +- [ ] 技能系统自创建/自改进测试 + +--- + +## 六、参考 + +- **Hermes Agent 测试实践**:`hermes-agent/tests/conftest.py`(hermetic invariants)、`tests/e2e/conftest.py`(E2E 工厂模式)、`tests/test_model_tools.py`(工具单元测试模式) +- **Vitest 文档**:https://vitest.dev/ +- **Effect-TS 测试**:https://effect.website/docs/guides/testing diff --git a/configs/default-settings.json b/configs/default-settings.json new file mode 100644 index 0000000..4c26a28 --- /dev/null +++ b/configs/default-settings.json @@ -0,0 +1,117 @@ +{ + "theme": "dark", + "max_tokens": 32768, + "default_model": "deepseek/deepseek-v4-pro", + "model_list": [ + { + "model": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"], + "base_url": "https://api.deepseek.com/anthropic", + "proxy": null, + "auth_token_env": "YOUR_DEEPSEEK_API_KEY", + "provider": "deepseek", + "price": { + "input": 0.14, + "output": 0.28, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["claude-sonnet-2025", "opus-4.8"], + "base_url": "https://api.deepseek.com/anthropic", + "auth_token_env": "YOUR_ANTHROPIC_API_KEY", + "provider": "anthropic", + "price": { + "input": 3.0, + "output": 15.0, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["glm-4-plus", "glm-4-flash", "glm-4-air"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "auth_token_env": "YOUR_ZHIPU_API_KEY", + "provider": "glm", + "price": { + "input": 0.1, + "output": 0.1, + "currency": "CNY", + "unit": "1K tokens" + } + }, + { + "model": ["MiniMax-Text-01", "abab7-chat", "abab6.5s-chat"], + "base_url": "https://api.minimax.chat/v1", + "auth_token_env": "YOUR_MINIMAX_API_KEY", + "provider": "minimax", + "price": { + "input": 10.0, + "output": 10.0, + "currency": "CNY", + "unit": "1M tokens" + } + }, + { + "model": ["local-llama3-70b", "local-qwen-32b", "local-mistral"], + "base_url": "http://localhost:8000/v1", + "auth_token_env": "LOCAL_NO_KEY", + "provider": "local", + "price": { + "input": 0, + "output": 0, + "currency": "USD", + "unit": "free" + } + }, + { + "model": ["qwen-max", "qwen-plus", "qwen-turbo"], + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "auth_token_env": "YOUR_QWEN_API_KEY", + "provider": "qwen", + "price": { + "input": 0.02, + "output": 0.06, + "currency": "CNY", + "unit": "1K tokens" + } + }, + { + "model": ["gpt-4-turbo", "gpt-4o", "gpt-3.5-turbo"], + "base_url": "https://api.openai.com/v1", + "auth_token_env": "YOUR_OPENAI_API_KEY", + "provider": "openai", + "price": { + "input": 10.0, + "output": 30.0, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["grok-1", "grok-beta", "grok-vision-beta"], + "base_url": "https://api.x.ai/v1", + "auth_token_env": "YOUR_GROK_API_KEY", + "provider": "grok", + "price": { + "input": 5.0, + "output": 15.0, + "currency": "USD", + "unit": "1M tokens" + } + }, + { + "model": ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.0-pro"], + "base_url": "https://generativelanguage.googleapis.com/v1", + "auth_token_env": "YOUR_GOOGLE_API_KEY", + "provider": "google", + "price": { + "input": 3.5, + "output": 10.5, + "currency": "USD", + "unit": "1M tokens" + } + } + ], + "env": {} +} diff --git a/configs/test-setup.ts b/configs/test-setup.ts new file mode 100644 index 0000000..227f282 --- /dev/null +++ b/configs/test-setup.ts @@ -0,0 +1,128 @@ +/** + * Global test setup for Coder Agent. + * + * Invariants enforced here (inspired by Hermes Agent's conftest.py): + * + * 1. **No credential env vars.** All provider/credential-shaped env vars + * are unset before every test. Local developer keys cannot leak in. + * + * 2. **Deterministic runtime.** TZ=UTC, LANG=C.UTF-8, NODE_ENV=test. + * + * 3. **Isolated CODER_HOME.** CODER_HOME points to a per-test tempdir so + * code reading `~/.coder/*` cannot see the real one. + * + * These invariants make the local test run match CI closely. + */ + +import { afterEach, beforeEach, vi } from 'vitest'; + +// ── Credential env-var filter ───────────────────────────────────────────── + +const CREDENTIAL_SUFFIXES = [ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', + '_ACCESS_KEY', + '_SECRET_ACCESS_KEY', + '_PRIVATE_KEY', + '_OAUTH_TOKEN', + '_ENCRYPT_KEY', + '_APP_SECRET', + '_CLIENT_SECRET', + '_AES_KEY', +]; + +const CREDENTIAL_NAMES = new Set([ + 'CODER_API_KEY', + 'CODER_TOKEN', + 'OPENAI_API_KEY', + 'OPENROUTER_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'GROQ_API_KEY', + 'XAI_API_KEY', + 'MISTRAL_API_KEY', + 'DEEPSEEK_API_KEY', + 'MOONSHOT_API_KEY', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'GITHUB_TOKEN', + 'GH_TOKEN', +]); + +function looksLikeCredential(name: string): boolean { + if (CREDENTIAL_NAMES.has(name)) return true; + return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix)); +} + +// ── Behavioral vars that change test semantics ──────────────────────────── + +const BEHAVIORAL_VARS = new Set([ + 'CODER_HOME', + 'CODER_CONFIG', + 'CODER_MODEL', + 'CODER_PROVIDER', + 'CODER_PERMISSION_MODE', + 'CODER_YOLO_MODE', + 'NODE_ENV', +]); + +// ── Per-test environment reset ──────────────────────────────────────────── + +/** + * Store original env values so we can restore them after each test. + * Individual tests that need specific env vars set them explicitly. + */ +const originalEnv: Record = {}; + +beforeEach(() => { + // 1. Store and clear credential-shaped env vars + for (const [name, value] of Object.entries(process.env)) { + if (looksLikeCredential(name) || BEHAVIORAL_VARS.has(name)) { + if (!(name in originalEnv)) { + originalEnv[name] = value; + } + delete process.env[name]; + } + } + + // 2. Set deterministic runtime env + process.env.TZ = 'UTC'; + process.env.LANG = 'C.UTF-8'; + process.env.LC_ALL = 'C.UTF-8'; + if (!('NODE_ENV' in originalEnv)) { + originalEnv['NODE_ENV'] = process.env.NODE_ENV; + } + process.env.NODE_ENV = 'test'; + + // 3. Set CODER_HOME to a temp path (test files override as needed) + if (!('CODER_HOME' in originalEnv)) { + originalEnv['CODER_HOME'] = process.env.CODER_HOME; + } + process.env.CODER_HOME = '/tmp/coder-test-home'; + + // 4. Reset timer mocks + vi.useRealTimers(); +}); + +afterEach(() => { + // Restore original env values + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + // Clear the stored originals so the next test starts fresh + for (const key of Object.keys(originalEnv)) { + delete originalEnv[key]; + } + + // Clear all mocks + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); diff --git a/configs/test-utils.ts b/configs/test-utils.ts new file mode 100644 index 0000000..ec3af76 --- /dev/null +++ b/configs/test-utils.ts @@ -0,0 +1,245 @@ +/** + * Test utility helpers for Coder Agent. + * + * Provides factory functions and assertion helpers commonly used across + * the test suite. Modeled after Hermes Agent's e2e/conftest.py fixture + * factories (make_source, make_session_entry, make_event, etc.). + */ + +import { vi } from 'vitest'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types (mirror shared types to keep test utils self-contained) +// ═══════════════════════════════════════════════════════════════════════════ + +export interface TestMessage { + role: 'user' | 'assistant' | 'system'; + content: string | TestContentBlock[]; +} + +export interface TestContentBlock { + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: string; + is_error?: boolean; +} + +export interface TestAgentContext { + sessionId: string; + workingDir: string; + maxTurns: number; + tools: string[]; + model: string; + provider: string; + messages: TestMessage[]; +} + +export interface MockAgentResponse { + content: string; + stopReason: 'end_turn' | 'tool_use' | 'max_tokens'; + toolCalls?: Array<{ + id: string; + name: string; + input: Record; + }>; + usage?: { inputTokens: number; outputTokens: number }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Factory functions +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Create a minimal test context for agent loop tests. + * Override fields as needed in individual tests. + */ +export function createTestContext( + overrides: Partial = {}, +): TestAgentContext { + return { + sessionId: `test-session-${Date.now()}`, + workingDir: '/tmp/coder-test-workspace', + maxTurns: 10, + tools: ['read', 'write', 'bash', 'grep', 'glob'], + model: 'deepseek-v4-pro', + provider: 'deepseek', + messages: [], + ...overrides, + }; +} + +/** + * Create a mock agent response (text completion, no tool calls). + */ +export function mockAgentResponse( + content: string, + overrides: Partial = {}, +): MockAgentResponse { + return { + content, + stopReason: 'end_turn', + usage: { inputTokens: 100, outputTokens: 50 }, + ...overrides, + }; +} + +/** + * Create a mock agent response with tool calls. + */ +export function mockAgentToolUse( + toolCalls: MockAgentResponse['toolCalls'], + overrides: Partial = {}, +): MockAgentResponse { + return { + content: '', + stopReason: 'tool_use', + toolCalls, + usage: { inputTokens: 100, outputTokens: 50 }, + ...overrides, + }; +} + +/** + * Create a standard assistant message. + */ +export function createAssistantMessage( + content: string, + toolCalls?: MockAgentResponse['toolCalls'], +): TestMessage { + const blocks: TestContentBlock[] = []; + if (content) { + blocks.push({ type: 'text', text: content }); + } + if (toolCalls) { + for (const tc of toolCalls) { + blocks.push({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: tc.input, + }); + } + } + return { role: 'assistant', content: blocks }; +} + +/** + * Create a user message. + */ +export function createUserMessage(content: string): TestMessage { + return { role: 'user', content }; +} + +/** + * Create a tool result message. + */ +export function createToolResult( + toolUseId: string, + content: string, + isError = false, +): TestMessage { + return { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content, + is_error: isError, + }, + ], + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Mock helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Create a mock AbortController that is not aborted. + */ +export function createMockAbortController(): AbortController { + const controller = new AbortController(); + return controller; +} + +/** + * Create a mock AbortController that is already aborted. + */ +export function createAbortedController(): AbortController { + const controller = new AbortController(); + controller.abort(); + return controller; +} + +/** + * Create a simple mock function that records its calls. + * Wraps vi.fn() for convenience. + */ +export function createMockFn unknown>() { + return vi.fn(); +} + +/** + * Create a mock timer context (fake timers). + * Returns a cleanup function. + */ +export function useFakeTimers(): () => void { + vi.useFakeTimers(); + return () => vi.useRealTimers(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Assertion helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Assert that an async function rejects with a specific error message. + */ +export async function assertRejects( + fn: () => Promise, + messageOrPattern: string | RegExp, +): Promise { + try { + await fn(); + throw new Error('Expected function to reject, but it resolved'); + } catch (error) { + if (error instanceof Error && error.message === 'Expected function to reject, but it resolved') { + throw error; + } + const errorMessage = error instanceof Error ? error.message : String(error); + if (typeof messageOrPattern === 'string') { + if (!errorMessage.includes(messageOrPattern)) { + throw new Error( + `Expected error message to include "${messageOrPattern}", got "${errorMessage}"`, + ); + } + } else { + if (!messageOrPattern.test(errorMessage)) { + throw new Error( + `Expected error message to match ${messageOrPattern}, got "${errorMessage}"`, + ); + } + } + } +} + +/** + * Assert that a value is within a tolerance of an expected value. + */ +export function assertApproximately( + actual: number, + expected: number, + tolerance: number, + label = 'value', +): void { + if (Math.abs(actual - expected) > tolerance) { + throw new Error( + `Expected ${label} to be ${expected} ± ${tolerance}, got ${actual}`, + ); + } +} diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0ed4c97 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,308 @@ +# Configuration Reference + +Coder Agent is configured through a combination of `~/.coder/settings.json`, +environment variables, and CLI flags. + +## Config File + +The primary configuration file lives at `~/.coder/settings.json`. It is created +automatically on first run if it doesn't exist. + +### Full Schema + +```yaml +# ── Provider ────────────────────────────────────────────────────────────── +provider: anthropic # anthropic | deepseek | openai +model: claude-sonnet-4-20250514 + +# ── Provider-specific settings ──────────────────────────────────────────── +providers: + anthropic: + baseUrl: https://api.anthropic.com + maxRetries: 3 + timeoutMs: 120000 + deepseek: + baseUrl: https://api.deepseek.com/anthropic + maxRetries: 3 + timeoutMs: 120000 + openai: + baseUrl: https://api.openai.com/v1 + maxRetries: 3 + timeoutMs: 120000 + +# ── Agent Behavior ──────────────────────────────────────────────────────── +maxTurns: 100 # Maximum turns per query +contextBudget: 180000 # Token budget before auto-compaction +compactThreshold: 0.7 # Fraction of budget that triggers compaction + +# ── Permissions ─────────────────────────────────────────────────────────── +permissions: + mode: default # default | accept-edits | bypass + allowedPaths: [] # Paths the agent can always write to + deniedPaths: [] # Paths the agent can never touch + allowShellCommands: true # Whether shell execution is permitted + +# ── Sessions ────────────────────────────────────────────────────────────── +sessions: + maxSessions: 50 # Maximum stored sessions before rotation + autoResume: true # Auto-resume last session on coder (no args) + +# ── Skills ──────────────────────────────────────────────────────────────── +skills: + autoCreate: true # Auto-propose new skills from repeated tasks + autoImprove: true # Auto-improve skills after execution + minRepeatForSkill: 2 # Times a task must repeat before skill creation + +# ── Sandbox ─────────────────────────────────────────────────────────────── +sandbox: + mode: docker # docker | local | disabled + image: coder-agent/sandbox # Sandbox Docker image + networkDisabled: true # Disable network in sandbox + readOnlyRootfs: true # Read-only filesystem (except workspace) + +# ── Telemetry ───────────────────────────────────────────────────────────── +telemetry: + enabled: false + endpoint: https://telemetry.coder.dev +``` + +## Environment Variables + +All configuration options can be overridden with environment variables. +Environment variables take precedence over the config file. + +| Variable | Description | Default | +|----------|-------------|---------| +| `CODER_API_KEY` | Coder API key | — | +| `DEEPSEEK_API_KEY` | DeepSeek API key | — | +| `OPENAI_API_KEY` | OpenAI API key | — | +| `CODER_PROVIDER` | LLM provider name | `anthropic` | +| `CODER_MODEL` | Model identifier | provider default | +| `CODER_MAX_TURNS` | Maximum turns per query | `100` | +| `CODER_CONTEXT_BUDGET` | Token budget before compaction | `180000` | +| `CODER_COMPACT_THRESHOLD` | Compaction trigger threshold | `0.7` | +| `CODER_PERMISSION_MODE` | Permission mode | `default` | +| `CODER_SANDBOX_MODE` | Sandbox mode | `docker` | +| `CODER_SANDBOX_IMAGE` | Sandbox Docker image | `coder-agent/sandbox` | +| `CODER_SESSION_DIR` | Session storage directory | `~/.coder/sessions` | +| `CODER_SKILLS_DIR` | Skills storage directory | `~/.coder/skills` | +| `CODER_SCRATCHPAD_DIR` | Scratchpad directory | `~/.coder/scratchpad` | +| `CODER_TELEMETRY_ENABLED` | Enable telemetry | `false` | +| `CODER_COORDINATOR_MODE` | Force coordinator mode | `false` | +| `CODER_WORKER_MODE` | Force worker mode | `false` | +| `CODER_TEAM_ID` | Team identifier for multi-agent | — | +| `CODER_DEBUG` | Enable debug logging | `false` | +| `CODER_HEAPDUMP_ON_START` | Write heap dump at startup | `false` | + +## Provider Configuration + +### Anthropic (Default) + +```bash +export CODER_API_KEY=sk-ant-api03-... +``` + +Supported models: +- `claude-opus-4-20250514` — most capable +- `claude-sonnet-4-20250514` — balanced +- `claude-haiku-3-5-20241022` — fastest + +### DeepSeek + +```bash +export DEEPSEEK_API_KEY=sk-... +``` + +```yaml +provider: deepseek +model: deepseek-chat +``` + +### OpenAI + +```bash +export OPENAI_API_KEY=sk-... +``` + +```yaml +provider: openai +model: gpt-4o +``` + +### Custom Provider (OpenAI-compatible) + +```yaml +providers: + custom: + baseUrl: https://your-llm-proxy.com/v1 + apiKeyEnv: CUSTOM_API_KEY + maxRetries: 3 + timeoutMs: 120000 +``` + +Set `provider: custom` and `model: your-model-name`. + +## Permission Modes + +### `default` (Recommended) + +The agent asks for permission before: +- Writing or deleting files +- Executing shell commands +- Making network requests (in sandbox-disabled mode) + +### `accept-edits` + +Auto-approves file edits and safe operations. Still prompts for: +- Shell commands +- File deletions outside the project + +### `bypass` + +No permission prompts. Use with caution — the agent can execute arbitrary +commands and modify any file within its allowed paths. + +### Path-Based Rules + +Fine-grained control with `allowedPaths` and `deniedPaths`: + +```yaml +permissions: + mode: default + allowedPaths: + - src/**/*.ts + - tests/**/*.test.ts + deniedPaths: + - .env + - .env.* + - secrets/** + - **/credentials.* +``` + +## Session Storage + +Sessions are stored as JSON files in `~/.coder/sessions/` (configurable via +`CODER_SESSION_DIR`). Each session file contains: + +```json +{ + "id": "sess_abc123", + "cwd": "/path/to/project", + "createdAt": "2026-05-30T10:00:00Z", + "updatedAt": "2026-05-30T10:05:00Z", + "messages": [ + { "role": "user", "content": "Fix the login bug" }, + { "role": "assistant", "content": [...] } + ], + "checkpoints": [ + { "turn": 0, "messageIndex": 2 } + ], + "totalCost": 0.042, + "totalTokens": 15000 +} +``` + +### Session Commands + +```bash +# List recent sessions +coder --sessions + +# Resume a specific session +coder --resume sess_abc123 + +# Continue the most recent session +coder --continue +``` + +## Sandbox Configuration + +Coder Agent can execute code and shell commands in an isolated Docker container. + +### Docker Sandbox (Default) + +```yaml +sandbox: + mode: docker + image: coder-agent/sandbox:latest + networkDisabled: true + readOnlyRootfs: true + workspaceMount: /workspace +``` + +The sandbox image is a minimal Linux container with: +- Node.js 22 runtime +- Python 3.12 +- Git, curl, jq +- Common build tools (make, gcc) + +### Local Execution + +```yaml +sandbox: + mode: local +``` + +Commands run directly on the host. Faster but no isolation. + +### Disabled + +```yaml +sandbox: + mode: disabled +``` + +No code execution. The agent can only read and edit files. + +## Thinking (Extended Reasoning) + +Enable extended thinking for complex tasks: + +```bash +coder --thinking "Design the database schema for a multi-tenant SaaS" +``` + +Control the thinking budget (in tokens): + +```bash +coder --thinking --thinking-budget 4096 "Architect the microservices" +``` + +## Advanced + +### Custom System Prompt + +Append instructions to the system prompt: + +```yaml +appendSystemPrompt: | + Always use TypeScript strict mode. + Prefer functional components over class components. + Write tests for all new code. +``` + +Or override entirely: + +```yaml +customSystemPrompt: | + You are a security auditor. Focus on finding vulnerabilities. + Do not suggest features or refactoring. +``` + +### Context Compaction + +When the conversation exceeds the `contextBudget`, the engine automatically +summarizes earlier messages to stay within limits. The `compactThreshold` +controls how aggressively this happens (0.0 = never compact, 1.0 = compact +at exactly the budget). + +```yaml +contextBudget: 180000 # 180K tokens +compactThreshold: 0.7 # Start compacting at 126K tokens +``` + +### Multiple Projects + +Configuration is global (`~/.coder/settings.json`), but project-specific +instructions go in `CODER.md` at the project root (also supports `CLAUDE.md` and `CODEBUDDY.md` for compatibility). The agent reads this +file at the start of every session. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..fb4e3a3 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,209 @@ +# Getting Started with Coder Agent + +Coder Agent is a terminal-native AI coding agent that understands your codebase and +executes multi-step engineering tasks. It supports both single-agent and +coordinator/worker modes. + +## Prerequisites + +- **Node.js >= 18** (Node 22+ recommended) +- **pnpm** (for development installs) +- An API key from at least one provider: + - [Anthropic Console](https://console.anthropic.com/) (default) + - [DeepSeek Platform](https://platform.deepseek.com/) + - [OpenAI Platform](https://platform.openai.com/) + +## Installation + +### One-Click Install (recommended) + +```bash +curl -fsSL https://raw.githubusercontent.com/AgenticMatrix/Coder-Agent/main/install.sh | bash +``` + +The installer will: +1. Verify your Node.js version +2. Install `coder-agent` globally via npm +3. Create `~/.coder/` with session storage and skill directories +4. Prompt you to configure your Anthropic API key + +### npm + +```bash +npm install -g coder-agent +``` + +### From Source + +```bash +git clone https://github.com/AgenticMatrix/Coder-Agent.git +cd coder-agent +pnpm install +pnpm build +pnpm link --global +``` + +## Configuration + +### API Keys + +Coder Agent supports multiple LLM providers. Set at least one: + +```bash +# Anthropic (default) +export CODER_API_KEY=sk-ant-... + +# DeepSeek +export DEEPSEEK_API_KEY=sk-... + +# OpenAI +export OPENAI_API_KEY=sk-... +``` + +Add the export to your shell config (`~/.zshrc` or `~/.bashrc`) to persist +across terminal sessions. + +### Provider Selection + +Set the provider and model in `~/.coder/settings.json`: + +```yaml +provider: deepseek +model: deepseek-v4-pro +``` + +Or via environment variables: + +```bash +export CODER_PROVIDER=deepseek +export CODER_MODEL=deepseek-chat +``` + +See [Configuration](./configuration.md) for all options. + +## Basic Usage + +### Interactive Session + +Start an interactive TUI session in your project directory: + +```bash +cd my-project +coder +``` + +The Terminal UI shows: +- The conversation with the agent +- Tool calls and their results +- Permission prompts for file writes and shell commands +- Session cost and token usage + +### One-Shot Query + +Ask a single question without entering interactive mode: + +```bash +coder "Explain the authentication flow in this project" +``` + +### Coordinator Mode + +Run a coordinator that delegates work to parallel worker agents: + +```bash +coder --coordinator "Fix all TypeScript errors across the codebase" +``` + +Control the number of workers: + +```bash +coder --coordinator --workers 4 "Write tests for all untested modules" +``` + +### Resume a Session + +```bash +# Resume the most recent session +coder --continue + +# Resume a specific session by ID +coder --resume sess_abc123 +``` + +### Fork a Session + +Create a new session from a previous conversation at a specific turn: + +```bash +coder --fork-session sess_abc123 --fork-turn 5 +``` + +## Project Instructions — CODER.md + +Create a `CODER.md` file at the root of your project to give the agent +project-specific context. This file is read automatically at the start of every +session. (Also supports `CLAUDE.md` and `CODEBUDDY.md` for compatibility.) + +```markdown +# Project Overview + +This is a Next.js e-commerce application with Prisma ORM and PostgreSQL. + +## Architecture + +- `src/app/` — Next.js App Router pages +- `src/components/` — Shared React components +- `src/lib/` — Utilities and business logic +- `prisma/` — Database schema and migrations + +## Conventions + +- Use TypeScript strict mode +- Tests use Vitest with React Testing Library +- API routes follow RESTful conventions +- Commit messages follow Conventional Commits + +## Environment + +- Node 20+ +- PostgreSQL 16 +- Redis for session caching +``` + +## Session Management + +Sessions are stored in `~/.coder/sessions/` as JSON files. Each session contains: +- The full message transcript +- Checkpoints at each turn boundary +- Tool usage history +- Cost and token tracking + +## Keyboard Shortcuts (TUI) + +| Key | Action | +|-----|--------| +| `Enter` | Send message | +| `Ctrl+C` | Interrupt agent / exit | +| `Ctrl+L` | Clear screen | +| `Ctrl+R` | Toggle reasoning display | +| `↑` / `↓` | Navigate history | + +## Permissions + +Coder Agent asks for permission before executing file writes or shell commands. +You can configure the permission mode: + +```bash +# Always ask (default) +coder --permission default + +# Auto-accept safe operations +coder --permission accept-edits + +# Bypass all prompts (use with caution) +coder --permission bypass +``` + +## Next Steps + +- [Configuration Reference](./configuration.md) — all config options diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..a55edfd --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,27 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs'], + }, + { + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }], + }, + }, +); diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..27d03aa --- /dev/null +++ b/install.sh @@ -0,0 +1,342 @@ +#!/bin/bash +# install.sh — One-click installer for Coder Agent +# +# Usage: +# # Remote install (from GitHub) +# curl -fsSL https://raw.githubusercontent.com/AgenticMatrix/Coder-Agent/main/install.sh | bash +# +# # Local development install (run from repo root) +# ./install.sh --local +# ./install.sh --dev +# +# This script: +# 1. Checks Node.js >= 22 +# 2. Installs coder-agent (npm registry or local link) +# 3. Creates ~/.coder configuration directory +# 4. Optionally sets up API keys +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +LOCAL_INSTALL=false +for arg in "$@"; do + case "$arg" in + --local|--dev) LOCAL_INSTALL=true ;; + esac +done + +echo -e "${CYAN}" +echo "╔══════════════════════════════════════════════════╗" +echo "║ Coder Agent — One-Click Installer ║" +echo "╚══════════════════════════════════════════════════╝" +echo -e "${NC}" +echo "" + +# --------------------------------------------------------------------------- +# 1. Check Node.js +# --------------------------------------------------------------------------- +NODE_MIN_VERSION=22 + +if ! command -v node &> /dev/null; then + echo -e "${RED}ERROR: Node.js is not installed.${NC}" + echo "" + echo "Coder Agent requires Node.js >= ${NODE_MIN_VERSION}." + echo "Install it from: https://nodejs.org/" + echo "" + echo "Or use a version manager:" + echo " - nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash" + echo " - fnm: curl -fsSL https://fnm.vercel.app/install | bash" + echo " - brew: brew install node" + exit 1 +fi + +NODE_VERSION=$(node -v | sed 's/v//') +NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) + +echo -e "Node.js version: ${GREEN}v${NODE_VERSION}${NC}" + +if [ "$NODE_MAJOR" -lt "$NODE_MIN_VERSION" ]; then + echo -e "${YELLOW}Node.js v${NODE_MAJOR} detected. Coder Agent requires >= ${NODE_MIN_VERSION}.${NC}" + echo "" + + # Try to auto-install Node.js 22 + AUTO_INSTALLED=false + + # Option 1: fnm (fast, cross-platform) + if ! $AUTO_INSTALLED && command -v fnm &> /dev/null; then + echo -e "${CYAN}fnm detected. Installing Node.js 22...${NC}" + fnm install 22 && fnm use 22 && AUTO_INSTALLED=true + fi + + # Option 2: nvm + if ! $AUTO_INSTALLED && [ -s "$HOME/.nvm/nvm.sh" ]; then + echo -e "${CYAN}nvm detected. Installing Node.js 22...${NC}" + . "$HOME/.nvm/nvm.sh" && nvm install 22 && nvm use 22 && AUTO_INSTALLED=true + fi + + # Option 3: Try to install fnm if it is not available + if ! $AUTO_INSTALLED; then + echo -e "${CYAN}No Node.js version manager found. Attempting to install fnm...${NC}" + if command -v curl &> /dev/null; then + curl -fsSL https://fnm.vercel.app/install | bash + # Source fnm for current session + FNM_PATH="$HOME/.local/share/fnm" + [ -d "$HOME/.fnm" ] && FNM_PATH="$HOME/.fnm" + if [ -f "$FNM_PATH/fnm" ]; then + export PATH="$FNM_PATH:$PATH" + eval "$(fnm env)" + fnm install 22 && fnm use 22 && AUTO_INSTALLED=true + fi + fi + fi + + if $AUTO_INSTALLED; then + NODE_VERSION=$(node -v | sed "s/v//") + NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) + echo -e "${GREEN}✅ Node.js upgraded to v${NODE_VERSION}${NC}" + echo "" + else + echo -e "${RED}ERROR: Could not automatically install Node.js >= ${NODE_MIN_VERSION}.${NC}" + echo "" + echo "Please install Node.js 22+ manually:" + echo " - fnm: curl -fsSL https://fnm.vercel.app/install | bash" + echo " - nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash" + echo " - brew: brew install node@22" + echo " - Official: https://nodejs.org/" + exit 1 + fi +fi + +# Check npm version +NPM_MIN_VERSION=10 + +if command -v npm &> /dev/null; then + NPM_VERSION=$(npm -v) + NPM_MAJOR=$(echo "$NPM_VERSION" | cut -d. -f1) + echo -e "npm version: ${GREEN}v${NPM_VERSION}${NC}" + + if [ "$NPM_MAJOR" -lt "$NPM_MIN_VERSION" ]; then + echo -e "${YELLOW}WARNING: npm v${NPM_MAJOR} detected. npm >= ${NPM_MIN_VERSION} recommended.${NC}" + echo "You can upgrade npm with: npm install -g npm@latest" + echo "" + fi +fi + +# --------------------------------------------------------------------------- +# 2. Auto-detect local dev install +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Auto-detect if we're inside the coder-agent repo +if [ -f "${SCRIPT_DIR}/packages/cli/package.json" ] && [ -f "${SCRIPT_DIR}/pnpm-workspace.yaml" ]; then + LOCAL_INSTALL=true + REPO_DIR="${SCRIPT_DIR}" +fi + +# --------------------------------------------------------------------------- +# 3. Install coder-agent +# --------------------------------------------------------------------------- +echo "" + +if $LOCAL_INSTALL; then + # --- Local / Development install --- + echo -e "${CYAN}Local development install detected.${NC}" + + if [ -z "${REPO_DIR:-}" ]; then + # User passed --local but we're not in the repo + echo -e "${RED}ERROR: --local flag used but not inside coder-agent repo.${NC}" + echo "Run this script from the repo root:" + echo " git clone https://github.com/AgenticMatrix/Coder-Agent.git" + echo " cd Coder-Agent && ./install.sh --local" + exit 1 + fi + + echo -e "Repo directory: ${GREEN}${REPO_DIR}${NC}" + echo "" + + # Check for pnpm (required for this monorepo) + if ! command -v pnpm &> /dev/null; then + echo -e "${YELLOW}pnpm is required but not found.${NC}" + echo "" + echo "This project uses pnpm workspaces — npm is not supported." + + # Try to auto-install pnpm via npm or corepack + if command -v npm &> /dev/null; then + echo -e "${CYAN}Installing pnpm via npm...${NC}" + npm install -g pnpm 2>/dev/null || true + elif command -v corepack &> /dev/null; then + echo -e "${CYAN}Installing pnpm via corepack...${NC}" + corepack enable pnpm 2>/dev/null || true + corepack prepare pnpm@latest --activate 2>/dev/null || true + fi + + if ! command -v pnpm &> /dev/null; then + echo -e "${RED}ERROR: Could not install pnpm automatically.${NC}" + echo "Install it manually: npm install -g pnpm" + exit 1 + fi + echo -e "${GREEN}✅ pnpm installed${NC}" + fi + + echo -e "${CYAN}Installing dependencies with pnpm...${NC}" + (cd "${REPO_DIR}" && pnpm install) + + # Build + echo "" + echo -e "${CYAN}Building coder-agent...${NC}" + (cd "${REPO_DIR}" && pnpm build) + + # Link CLI globally so 'coder' command is available + echo "" + echo -e "${CYAN}Linking coder command globally...${NC}" + (cd "${REPO_DIR}/packages/cli" && npm link --force 2>/dev/null || npm link 2>/dev/null || true) + + echo -e "${GREEN}✅ coder-agent built and linked locally${NC}" + +else + # --- Remote / npm registry install --- + echo -e "${CYAN}Installing coder-agent from npm registry...${NC}" + if npm install -g coder-agent 2>&1; then + echo -e "${GREEN}✅ coder-agent installed from npm${NC}" + else + echo -e "${YELLOW}⚠️ npm registry install failed (package may not be published yet).${NC}" + echo "" + echo "To install from source:" + echo " git clone https://github.com/AgenticMatrix/Coder-Agent.git" + echo " cd Coder-Agent && ./install.sh --local" + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# 4. Verify installation +# --------------------------------------------------------------------------- +echo "" +echo -e "${CYAN}Verifying installation...${NC}" + +if command -v coder &> /dev/null; then + CODER_VERSION=$(coder --version 2>/dev/null || echo "unknown") + echo -e "${GREEN}✅ coder command available (${CODER_VERSION})${NC}" +else + echo -e "${YELLOW}⚠️ coder command not on PATH yet. Configuring PATH automatically...${NC}" + + # Detect shell + SHELL_NAME=$(basename "$SHELL" 2>/dev/null || echo "bash") + + # Find the global npm bin directory + NPM_BIN_DIR=$(npm bin -g 2>/dev/null || echo "") + if [ -z "$NPM_BIN_DIR" ]; then + NPM_PREFIX=$(npm config get prefix 2>/dev/null || echo "") + if [ -n "$NPM_PREFIX" ]; then + NPM_BIN_DIR="${NPM_PREFIX}/bin" + fi + fi + + # Add npm global bin to PATH if not already there + if [ -n "$NPM_BIN_DIR" ] && ! echo "$PATH" | tr ':' '\n' | grep -qxF "$NPM_BIN_DIR"; then + case "$SHELL_NAME" in + zsh) + RC_FILE="$HOME/.zshrc" + ;; + bash) + if [ -f "$HOME/.bash_profile" ]; then + RC_FILE="$HOME/.bash_profile" + else + RC_FILE="$HOME/.bashrc" + fi + ;; + fish) + RC_FILE="$HOME/.config/fish/config.fish" + mkdir -p "$(dirname "$RC_FILE")" + ;; + *) + RC_FILE="$HOME/.profile" + ;; + esac + + echo "" >> "$RC_FILE" + echo "# Added by Coder Agent installer" >> "$RC_FILE" + echo "export PATH=\"${NPM_BIN_DIR}:\$PATH\"" >> "$RC_FILE" + + # Also export for current session + export PATH="${NPM_BIN_DIR}:$PATH" + + echo -e "${GREEN}✅ Added ${NPM_BIN_DIR} to PATH in ${RC_FILE}${NC}" + echo "" + echo "Run this to apply immediately:" + echo " source ${RC_FILE}" + + # Re-verify after PATH update + if command -v coder &> /dev/null; then + CODER_VERSION=$(coder --version 2>/dev/null || echo "unknown") + echo -e "${GREEN}✅ coder command available (${CODER_VERSION})${NC}" + fi + fi +fi + +# --------------------------------------------------------------------------- +# 5. Create configuration directory +# --------------------------------------------------------------------------- +CODER_DIR="${HOME}/.coder" + +echo "" +echo -e "${CYAN}Setting up configuration...${NC}" + +mkdir -p "${CODER_DIR}" +mkdir -p "${CODER_DIR}/sessions" +mkdir -p "${CODER_DIR}/skills" +mkdir -p "${CODER_DIR}/scratchpad" + +echo -e "${GREEN}✅ Configuration directory created at ${CODER_DIR}${NC}" + +# --------------------------------------------------------------------------- +# 6. Copy default settings.json template +# --------------------------------------------------------------------------- +SETTINGS_FILE="${CODER_DIR}/settings.json" +DEFAULT_SETTINGS="${SCRIPT_DIR}/configs/default-settings.json" + +echo "" +if [ ! -f "$SETTINGS_FILE" ]; then + if [ -f "$DEFAULT_SETTINGS" ]; then + cp "$DEFAULT_SETTINGS" "$SETTINGS_FILE" + echo -e "${GREEN}✅ Created ${SETTINGS_FILE} from default template${NC}" + else + echo -e "${YELLOW}Default settings template not found at ${DEFAULT_SETTINGS}${NC}" + fi +else + echo -e "${GREEN}Existing ${SETTINGS_FILE} found, skipping${NC}" +fi + +# --------------------------------------------------------------------------- +# 7. Done +# --------------------------------------------------------------------------- +echo "" +echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ Coder Agent installation complete! ║${NC}" +echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${CYAN}Quick Start:${NC}" +echo "" +echo " # Start an interactive session" +echo " coder" +echo "" +echo " # Select or configure a model interactively" +echo " coder --model" +echo "" +echo " # Ask a one-shot question" +echo " coder 'Explain this codebase'" +echo "" +echo " # Run in coordinator mode (multi-worker)" +echo " coder --coordinator 'Fix the auth bug'" +echo "" +echo -e "${CYAN}Configuration:${NC}" +echo " ~/.coder/ — Configuration directory" +echo " ~/.coder/settings.json — Provider & model settings" +echo " coder --model — Interactive model selection" +echo " CODER.md — Project-specific instructions" +echo "" +echo -e "${YELLOW}Documentation: https://github.com/AgenticMatrix/Coder-Agent${NC}" diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f22f43 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "coder-agent", + "version": "0.1.0", + "private": true, + "description": "Coder Agent — An open-source, enterprise-grade CLI coding agent with multi-provider support, Agent Teams, and a powerful hook system", + "type": "module", + "scripts": { + "dev": "pnpm --recursive run dev", + "build": "pnpm --recursive run build", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:unit": "vitest run --dir packages", + "test:integration": "vitest run --dir packages --config vitest.integration.config.ts", + "lint": "eslint . --ext .ts,.tsx", + "lint:fix": "eslint . --ext .ts,.tsx --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "type-check": "tsc -b", + "clean": "pnpm --recursive run clean", + "ci": "pnpm lint && pnpm type-check && pnpm test:coverage && pnpm build" + }, + "engines": { + "node": ">=22.0.0" + }, + "packageManager": "pnpm@9.15.4", + "devDependencies": { + "@eslint/js": "^9.16.0", + "@types/node": "^22.10.0", + "@vitest/coverage-v8": "^2.1.0", + "eslint": "^9.16.0", + "prettier": "^3.4.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.18.0", + "vitest": "^2.1.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgenticMatrix/Coder-Agent" + }, + "license": "Apache-2.0", + "keywords": [ + "cli", + "coding-agent", + "ai-agent", + "llm", + "anthropic", + "openai", + "deepseek", + "multi-provider", + "agent-teams", + "terminal", + "tui", + "developer-tools", + "code-generation", + "mcp", + "hooks" + ], + "author": "Coder Agent Contributors", + "bugs": { + "url": "https://github.com/AgenticMatrix/Coder-Agent/issues" + }, + "homepage": "https://github.com/AgenticMatrix/Coder-Agent#readme", + "pnpm": { + "patchedDependencies": { + "ink@7.0.5": "patches/ink@7.0.5.patch" + } + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..9d83b6f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@coder/cli", + "version": "0.1.0", + "description": "Coder Agent terminal CLI — React Ink TUI, streaming, diff view", + "type": "module", + "main": "./dist/entry.js", + "types": "./dist/entry.d.ts", + "bin": { + "coder": "./dist/entry.js" + }, + "scripts": { + "build": "tsc -b", + "postbuild": "chmod +x dist/entry.js dist/demo.js", + "dev": "tsc -b --watch", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "vitest run" + }, + "dependencies": { + "@coder/core": "workspace:*", + "@coder/provider": "workspace:*", + "@coder/shared": "workspace:*", + "@coder/tools": "workspace:*", + "@coder/tui": "workspace:*", + "@nanostores/react": "^1.1.0", + "ink-text-input": "^6.0.0", + "nanostores": "^1.2.0", + "react": "^19.2.4", + "unicode-animations": "^1.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx new file mode 100644 index 0000000..ff44877 --- /dev/null +++ b/packages/cli/src/app.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { useStore } from '@nanostores/react' + +import { GatewayProvider } from './app/gatewayContext.js' +import { $uiState } from './app/uiStore.js' +import { useMainApp } from './app/useMainApp.js' +import { AppLayout } from './components/appLayout.js' +import type { IGatewayClient } from './gateway/client.js' + +export function App({ gw }: { gw: IGatewayClient }) { + const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw) + const { mouseTracking } = useStore($uiState) + + return ( + + + + ) +} diff --git a/packages/cli/src/app/createGatewayEventHandler.ts b/packages/cli/src/app/createGatewayEventHandler.ts new file mode 100644 index 0000000..db11512 --- /dev/null +++ b/packages/cli/src/app/createGatewayEventHandler.ts @@ -0,0 +1,825 @@ +import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' +import { STREAM_BATCH_MS } from '../config/timing.js' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' +import type { + CommandsCatalogResponse, + ConfigFullResponse, + DelegationStatusResponse, + GatewayEvent, + GatewaySkin, + SessionMostRecentResponse +} from '../gateway/types.js' +import { rpcErrorMessage } from '../lib/rpc.js' +import { topLevelSubagents } from '../lib/subagentTree.js' +import { formatToolCall, stripAnsi } from '../lib/text.js' +import { fromSkin } from '../theme.js' +import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' + +import { applyDelegationStatus, getDelegationState } from './delegationStore.js' +import type { GatewayEventHandlerContext } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { getUiState, patchUiState } from './uiStore.js' + +const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i + +const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') + +const applySkin = (s: GatewaySkin) => + patchUiState({ + theme: fromSkin( + s.colors ?? {}, + s.branding ?? {}, + s.banner_logo ?? '', + s.banner_hero ?? '', + s.tool_prefix ?? '', + s.help_header ?? '' + ) + }) + +const dropBgTask = (taskId: string) => + patchUiState(state => { + const next = new Set(state.bgTasks) + next.delete(taskId) + + return { ...state, bgTasks: next } + }) + +const pushUnique = + (max: number) => + (xs: T[], x: T): T[] => + xs.at(-1) === x ? xs : [...xs, x].slice(-max) + +const pushThinking = pushUnique(6) +const pushNote = pushUnique(6) +const pushTool = pushUnique(8) + +const KNOWN_SUBAGENT_STATUSES = new Set([ + 'completed', + 'error', + 'failed', + 'interrupted', + 'queued', + 'running', + 'timeout' +]) + +const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => { + if (typeof status !== 'string') { + return fallback + } + + const normalized = status.toLowerCase() as SubagentStatus + + return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback +} + +export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { + const { rpc } = ctx.gateway + const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session + const { bellOnComplete, stdout, sys } = ctx.system + const { appendMessage, panel, setHistoryItems } = ctx.transcript + const { setInput } = ctx.composer + const { submitRef } = ctx.submission + const { setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, setVoiceEnabled } = ctx.voice + + let pendingThinkingStatus = '' + let thinkingStatusTimer: null | ReturnType = null + let startupPromptSubmitted = false + /** When true, thinking.delta must NOT overwrite the status bar */ + let statusExplicitlySet = false + + // Inject the disk-save callback into turnController so recordMessageComplete + // can fire-and-forget a persist without having to plumb a gateway ref around. + turnController.persistSpawnTree = async (subagents, sessionId) => { + try { + const startedAt = subagents.reduce((min, s) => { + if (!s.startedAt) { + return min + } + + return min === 0 ? s.startedAt : Math.min(min, s.startedAt) + }, 0) + + const top = topLevelSubagents(subagents) + .map(s => s.goal) + .filter(Boolean) + .slice(0, 2) + + const label = top.length ? top.join(' · ') : `${subagents.length} subagents` + + await rpc('spawn_tree.save', { + finished_at: Date.now() / 1000, + label: label.slice(0, 120), + session_id: sessionId ?? 'default', + started_at: startedAt ? startedAt / 1000 : null, + subagents + }) + } catch { + // Persistence is best-effort; in-memory history is the authoritative + // same-session source. A write failure doesn't block the turn. + } + } + + // Refresh delegation caps at most every 5s so the status bar HUD can + // render a /warning close to the configured cap without spamming the RPC. + let lastDelegationFetchAt = 0 + + const refreshDelegationStatus = (force = false) => { + const now = Date.now() + + if (!force && now - lastDelegationFetchAt < 5000) { + return + } + + lastDelegationFetchAt = now + rpc('delegation.status', {}) + .then(r => applyDelegationStatus(r)) + .catch(() => {}) + } + + const setStatus = (status: string) => { + pendingThinkingStatus = '' + statusExplicitlySet = true + + if (thinkingStatusTimer) { + clearTimeout(thinkingStatusTimer) + thinkingStatusTimer = null + } + + patchUiState({ status }) + } + + /** + * Fallback status timer for thinking phases. + * + * IMPORTANT: This must NEVER write raw thinking/chain-of-thought text to + * the status bar. The bridge's explicit status.update events ('Thinking…', + * 'Generating…', etc.) are the authoritative source of status labels. + * This fallback only fires when the bridge has NOT emitted a status.update, + * and even then it uses a clean generic label — never raw model output. + */ + const scheduleThinkingStatus = () => { + // If the status was explicitly set by a status.update event from + // the bridge ('Thinking…', 'Generating…', 'Running Bash…'), don't + // overwrite it with a fallback. + if (statusExplicitlySet) return + + pendingThinkingStatus = statusFromBusy() + + if (thinkingStatusTimer) { + return + } + + thinkingStatusTimer = setTimeout(() => { + thinkingStatusTimer = null + patchUiState({ status: pendingThinkingStatus || 'Thinking…' }) + }, STREAM_BATCH_MS) + } + + const restoreStatusAfter = (ms: number) => { + turnController.clearStatusTimer() + turnController.statusTimer = setTimeout(() => { + turnController.statusTimer = null + patchUiState({ status: statusFromBusy() }) + }, ms) + } + + const scheduleStartupPrompt = () => { + if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) { + return + } + + startupPromptSubmitted = true + setTimeout(async () => { + let sid = getUiState().sid + + for (let i = 0; !sid && i < 40; i += 1) { + await new Promise(resolve => setTimeout(resolve, 100)) + sid = getUiState().sid + } + + if (!sid) { + return sys('startup query skipped: no active session') + } + + if (STARTUP_IMAGE) { + try { + await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid }) + } catch (e) { + sys(`startup image attach failed: ${rpcErrorMessage(e)}`) + } + } + + submitRef.current(STARTUP_QUERY || 'What do you see in this image?') + }, 0) + } + + // Terminal statuses are never overwritten by late-arriving live events — + // otherwise a stale `subagent.start` / `spawn_requested` can clobber a + // terminal state from complete (failed/interrupted/timeout/error). + const isTerminalStatus = (s: SubagentProgress['status']) => + s === 'completed' || s === 'error' || s === 'failed' || s === 'interrupted' || s === 'timeout' + + const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running') + + const handleReady = (skin?: GatewaySkin) => { + if (skin) { + applySkin(skin) + } + + rpc('commands.catalog', {}) + .then(r => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + categories: r.categories ?? [], + pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, + sub: (r.sub ?? {}) as Record + }) + + if (r.warning) { + turnController.pushActivity(String(r.warning), 'warn') + } + }) + .catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info')) + + if (STARTUP_RESUME_ID) { + patchUiState({ status: 'resuming…' }) + resumeById(STARTUP_RESUME_ID) + scheduleStartupPrompt() + + return + } + + // Opt-in: when `display.tui_auto_resume_recent` is true, look up + // the most recent human-facing session and resume it instead of + // forging a brand-new one. Mirrors classic CLI's `coder -c` / + // `coder --tui` muscle memory and addresses the audit's "session + // unrecoverable after disconnection" gap. Default off so existing + // users aren't surprised. + rpc('config.get', { key: 'full' }) + .then(cfg => { + if (!cfg?.config?.display?.tui_auto_resume_recent) { + patchUiState({ status: 'forging session…' }) + newSession() + scheduleStartupPrompt() + + return + } + + return rpc('session.most_recent', {}).then(r => { + const target = r?.session_id + + if (target) { + patchUiState({ status: 'resuming most recent…' }) + resumeById(target) + scheduleStartupPrompt() + + return + } + + patchUiState({ status: 'forging session…' }) + newSession() + scheduleStartupPrompt() + }) + }) + .catch(() => { + patchUiState({ status: 'forging session…' }) + newSession() + scheduleStartupPrompt() + }) + } + + return (ev: GatewayEvent) => { + const sid = getUiState().sid + + if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) { + return + } + + switch (ev.type) { + case 'gateway.ready': + handleReady(ev.payload?.skin) + + return + + case 'skin.changed': + if (ev.payload) { + applySkin(ev.payload) + } + + return + case 'session.info': { + const info = ev.payload + + patchUiState(state => ({ + ...state, + info, + status: state.status === 'starting agent…' ? 'ready' : state.status, + usage: info.usage ? { ...state.usage, ...info.usage } : state.usage + })) + + setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m))) + + return + } + + case 'thinking.delta': { + if (!getUiState().busy) { + return + } + + const text = ev.payload?.text + + if (text !== undefined) { + const value = String(text) + // Fallback: if the bridge has not yet emitted a status.update + // (e.g. thinking/delta arrived before thinking/start from the + // provider), schedule a clean generic label. Never feed raw + // thinking/chain-of-thought text to the status bar — it is + // only for the reasoning panel ( drawer). + scheduleThinkingStatus() + + if (value) { + turnController.recordReasoningDelta(value) + } + } + + return + } + + case 'message.start': + turnController.startMessage() + + return + case 'status.update': { + const p = ev.payload + + if (!p?.text) { + return + } + + if (p.kind === 'goal') { + sys(p.text) + + const brief = p.text.startsWith('✓') + ? '✓ goal complete' + : p.text.startsWith('↻') + ? '↻ goal continuing' + : p.text.startsWith('⏸') + ? '⏸ goal paused' + : 'ready' + + setStatus(brief) + restoreStatusAfter(6000) + + return + } + + setStatus(p.text) + + if (p.kind === 'compressing') { + sys(p.text) + + return + } + + if (!p.kind || p.kind === 'status') { + return + } + + if (turnController.lastStatusNote !== p.text) { + turnController.lastStatusNote = p.text + turnController.pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) + } + + restoreStatusAfter(4000) + + return + } + + case 'gateway.stderr': { + const line = String(ev.payload.line).slice(0, 120) + + turnController.pushActivity(line, 'info') + + return + } + + case 'browser.progress': { + const message = String(ev.payload?.message ?? '').trim() + + if (message) { + sys(message) + } + + return + } + + case 'voice.status': { + // Continuous VAD loop reports its internal state so the status bar + // can show listening / transcribing / idle without polling. + const state = String(ev.payload?.state ?? '') + + if (state === 'listening') { + setVoiceRecording(true) + setVoiceProcessing(false) + } else if (state === 'transcribing') { + setVoiceRecording(false) + setVoiceProcessing(true) + } else { + setVoiceRecording(false) + setVoiceProcessing(false) + } + + return + } + + case 'voice.transcript': { + // CLI parity: the 3-strikes silence detector flipped off automatically. + // Mirror that on the UI side and tell the user why the mode is off. + if (ev.payload?.no_speech_limit) { + setVoiceEnabled(false) + setVoiceRecording(false) + setVoiceProcessing(false) + sys('voice: no speech detected 3 times, continuous mode stopped') + + return + } + + const text = String(ev.payload?.text ?? '').trim() + + if (!text) { + return + } + + // CLI parity: _pending_input.put(transcript) unconditionally feeds + // the transcript to the agent as its next turn — draft handling + // doesn't apply because voice-mode users are speaking, not typing. + // + // We can't branch on composer input from inside a setInput updater + // (React strict mode double-invokes it, duplicating the submit). + // Just clear + defer submit so the cleared input is committed before + // submit reads it. + setInput('') + setTimeout(() => submitRef.current(text), 0) + + return + } + + case 'gateway.start_timeout': { + const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {} + const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' + + setStatus('gateway startup timeout') + turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error') + + // Surface the most useful stderr lines inline so users can tell + // "wrong python", "missing dep", and "config parse failure" + // apart without leaving the TUI. Filter blank rows BEFORE + // taking the last N so trailing empty lines in the buffer + // don't crowd out actual content; truncate to match the + // 120-char clip used for `gateway.stderr` activity entries. + const STDERR_LINE_CAP = 120 + const STDERR_LINES_MAX = 8 + + const tailLines = (stderrTail ?? '') + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .slice(-STDERR_LINES_MAX) + + for (const line of tailLines) { + turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error') + } + + return + } + + case 'gateway.protocol_error': + setStatus('protocol warning') + restoreStatusAfter(4000) + + if (!turnController.protocolWarned) { + turnController.protocolWarned = true + turnController.pushActivity('protocol noise detected · /logs to inspect', 'info') + } + + if (ev.payload?.preview) { + turnController.pushActivity(`protocol noise: ${String(ev.payload.preview).slice(0, 120)}`, 'info') + } + + return + + case 'reasoning.delta': + if (ev.payload?.text) { + turnController.recordReasoningDelta(ev.payload.text, Boolean(ev.payload.verbose)) + } + + return + + case 'reasoning.available': + turnController.recordReasoningAvailable(String(ev.payload?.text ?? ''), Boolean(ev.payload?.verbose)) + + return + + case 'tool.progress': + if (ev.payload?.preview && ev.payload.name) { + turnController.recordToolProgress(ev.payload.name, ev.payload.preview) + } + + return + + case 'tool.generating': + if (ev.payload?.name) { + turnController.pushTrail(`drafting ${ev.payload.name}…`) + } + + return + + case 'tool.start': + turnController.recordTodos(ev.payload.todos) + turnController.recordToolStart( + ev.payload.tool_id, + ev.payload.name ?? 'tool', + ev.payload.context ?? '', + ev.payload.args_text ? stripAnsi(String(ev.payload.args_text)) : undefined + ) + + return + case 'tool.input_delta': + turnController.recordToolInputDelta( + ev.payload.tool_id, + ev.payload.partial_json ? String(ev.payload.partial_json) : '' + ) + + return + case 'tool.complete': { + const inlineDiffText = + ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' + + const resultText = ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined + + if (inlineDiffText) { + turnController.recordInlineDiffToolComplete( + inlineDiffText, + ev.payload.tool_id, + ev.payload.name, + ev.payload.error, + ev.payload.duration_s, + resultText + ) + } else { + turnController.recordToolComplete( + ev.payload.tool_id, + ev.payload.name, + ev.payload.error, + ev.payload.summary, + ev.payload.duration_s, + ev.payload.todos, + resultText + ) + } + + // When a tool fails, surface the error in the status bar so the user + // can see what went wrong without scrolling through the transcript. + // After a brief display, restore the normal status. + if (ev.payload.error) { + const toolName = ev.payload.name ?? 'tool' + const brief = ev.payload.error.length > 60 + ? `${ev.payload.error.slice(0, 57)}…` + : ev.payload.error + setStatus(`Tool failed - ${toolName}: ${brief}`) + restoreStatusAfter(5000) + } + + return + } + + case 'clarify.request': + patchOverlayState({ + clarify: { choices: ev.payload.choices, question: ev.payload.question, requestId: ev.payload.request_id } + }) + setStatus('waiting for input…') + + return + case 'approval.request': { + const description = String(ev.payload.description ?? 'dangerous command') + const requestId = ev.payload.request_id ? String(ev.payload.request_id) : undefined + const toolUseId = ev.payload.tool_use_id ? String(ev.payload.tool_use_id) : undefined + const command = String(ev.payload.command ?? '') + + // Update tool context with the actual command so the TUI header + // shows e.g. "Bash(python script.py)" instead of just "Bash". + if (command && toolUseId) { + turnController.updateToolContext(toolUseId, command) + } + + patchOverlayState({ + approval: { + command, + description, + request_id: requestId, + tool_use_id: toolUseId, + }, + }) + setStatus('approval needed') + + return + } + + case 'sudo.request': + patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) + setStatus('sudo password needed') + + return + + case 'secret.request': + patchOverlayState({ + secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } + }) + setStatus('secret input needed') + + return + + case 'background.complete': + dropBgTask(ev.payload.task_id) + sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) + + return + case 'review.summary': { + // Self-improvement background review emitted a persistent summary + // of what it saved to memory/skills. Surface it as a system line + // in the transcript so it never gets lost to a transient status + // flash. Python-side already formats it as "💾 Self-improvement + // review: …". + const text = String(ev.payload?.text ?? '').trim() + + if (text) { + sys(text) + } + + return + } + + case 'subagent.spawn_requested': + // Child built but not yet running (waiting on ThreadPoolExecutor slot). + // Preserve completed state if a later event races in before this one. + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' })) + + // Prime the status-bar HUD: fetch caps (once every 5s) so we can + // warn as depth/concurrency approaches the configured ceiling. + if (getDelegationState().maxSpawnDepth === null) { + refreshDelegationStatus(true) + } else { + refreshDelegationStatus() + } + + return + + case 'subagent.start': + turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' })) + + return + case 'subagent.thinking': { + const text = String(ev.payload.text ?? '').trim() + + if (!text) { + return + } + + // Update-only: never resurrect subagents whose spawn_requested/start + // we missed or that already flushed via message.complete. + turnController.upsertSubagent( + ev.payload, + c => ({ + status: keepTerminalElseRunning(c.status), + thinking: pushThinking(c.thinking, text) + }), + { createIfMissing: false } + ) + + return + } + + case 'subagent.tool': { + const line = formatToolCall( + ev.payload.tool_name ?? 'delegate_task', + ev.payload.tool_preview ?? ev.payload.text ?? '' + ) + + turnController.upsertSubagent( + ev.payload, + c => ({ + status: keepTerminalElseRunning(c.status), + tools: pushTool(c.tools, line) + }), + { createIfMissing: false } + ) + + return + } + + case 'subagent.progress': { + const text = String(ev.payload.text ?? '').trim() + + if (!text) { + return + } + + turnController.upsertSubagent( + ev.payload, + c => ({ + notes: pushNote(c.notes, text), + status: keepTerminalElseRunning(c.status) + }), + { createIfMissing: false } + ) + + return + } + + case 'subagent.complete': + turnController.upsertSubagent( + ev.payload, + c => ({ + durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, + status: normalizeSubagentStatus(ev.payload.status, 'completed'), + summary: ev.payload.summary || ev.payload.text || c.summary + }), + { createIfMissing: false } + ) + + return + + case 'message.delta': + turnController.recordMessageDelta(ev.payload ?? {}) + + return + case 'message.complete': { + const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) + + if (!wasInterrupted) { + const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] + msgs.forEach(appendMessage) + + if (bellOnComplete && stdout?.isTTY) { + stdout.write('\x07') + } + } + + setStatus('ready') + + if (ev.payload?.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) + } + + return + } + + case 'error': + // Do NOT call turnController.recordError() here — it immediately + // calls idle() which sets busy=false and clears all turn state + // (pendingSegmentTools, segmentMessages, turnTools) BEFORE the + // error message is visible to the user. Instead we defer the + // state cleanup until after the error has been displayed. + { + const message = String(ev.payload?.message || 'unknown error') + + turnController.pushActivity(message, 'error') + + if (NO_PROVIDER_RE.test(message)) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + setStatus('setup required') + + return + } + + sys(`error: ${message}`) + + // Show the error status prominently, then clean up turn state + // and return to ready after the user has had time to read it. + setStatus('error') + turnController.idle() + restoreStatusAfter(4000) + } + + return + + default: + // Exhaustiveness check: if a new GatewayEvent type is added + // to the union, TypeScript will error here — forcing us to + // handle it explicitly rather than silently dropping events. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ; ((_exhaustive: never) => {})(ev) + } + } +} diff --git a/packages/cli/src/app/createSlashHandler.ts b/packages/cli/src/app/createSlashHandler.ts new file mode 100644 index 0000000..4e7b002 --- /dev/null +++ b/packages/cli/src/app/createSlashHandler.ts @@ -0,0 +1,130 @@ +import { parseSlashCommand } from '../domain/slash.js' +import type { SlashExecResponse } from '../gateway/types.js' +import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' + +import type { SlashHandlerContext } from './interfaces.js' +import { findSlashCommand } from './slash/registry.js' +import type { SlashRunCtx } from './slash/types.js' +import { getUiState } from './uiStore.js' + +export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { + const { gw } = ctx.gateway + const { catalog } = ctx.local + const { page, send, sys } = ctx.transcript + + const handler = (cmd: string): boolean => { + const flight = ++ctx.slashFlightRef.current + const ui = getUiState() + const sid = ui.sid + const parsed = parseSlashCommand(cmd) + const argTail = parsed.arg ? ` ${parsed.arg}` : '' + + const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid + + const guarded = + (fn: (r: T) => void) => + (r: null | T): void => { + if (!stale() && r) { + fn(r) + } + } + + const guardedErr = (e: unknown) => { + if (!stale()) { + sys(`error: ${rpcErrorMessage(e)}`) + } + } + + const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui } + + const found = findSlashCommand(parsed.name) + + if (found) { + found.run(parsed.arg, runCtx, cmd) + + return true + } + + if (catalog?.canon) { + const needle = `/${parsed.name}`.toLowerCase() + const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1] + + if (exact) { + if (exact.toLowerCase() !== needle) { + return handler(`${exact}${argTail}`) + } + } else { + const matches = [ + ...new Set( + Object.entries(catalog.canon) + .filter(([alias]) => alias.startsWith(needle)) + .map(([, canon]) => canon) + ) + ] + + if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { + return handler(`${matches[0]}${argTail}`) + } + + if (matches.length > 1) { + sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) + + return true + } + } + } + + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then(r => { + if (stale()) { + return + } + + const body = r?.output || `/${parsed.name}: no output` + const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 + + long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text) + }) + .catch(() => { + gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) + .then((raw: unknown) => { + if (stale()) { + return + } + + const d = asCommandDispatch(raw) + + if (!d) { + return sys('error: invalid response: command.dispatch') + } + + if (d.type === 'exec' || d.type === 'plugin') { + return sys(d.output || '(no output)') + } + + if (d.type === 'alias') { + return handler(`/${d.target}${argTail}`) + } + + if (d.type === 'skill') { + sys(`⚡ loading skill: ${d.name}`) + + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) + } + + if (d.type === 'send') { + if (d.notice?.trim()) { + sys(d.notice) + } + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + } + }) + .catch(guardedErr) + }) + + return true + } + + return handler +} diff --git a/packages/cli/src/app/delegationStore.ts b/packages/cli/src/app/delegationStore.ts new file mode 100644 index 0000000..9331495 --- /dev/null +++ b/packages/cli/src/app/delegationStore.ts @@ -0,0 +1,77 @@ +import { atom } from 'nanostores' + +import type { DelegationStatusResponse } from '../gateway/types.js' + +export interface DelegationState { + // Last known caps from `delegation.status` RPC. null until fetched. + maxConcurrentChildren: null | number + maxSpawnDepth: null | number + // True when spawning is globally paused (see tools/delegate_tool.py). + paused: boolean + // Monotonic clock of the last successful status fetch. + updatedAt: null | number +} + +const buildState = (): DelegationState => ({ + maxConcurrentChildren: null, + maxSpawnDepth: null, + paused: false, + updatedAt: null +}) + +export const $delegationState = atom(buildState()) + +export const getDelegationState = () => $delegationState.get() + +export const patchDelegationState = (next: Partial) => + $delegationState.set({ ...$delegationState.get(), ...next }) + +export const resetDelegationState = () => $delegationState.set(buildState()) + +// ── Overlay accordion open-state ────────────────────────────────────── +// +// Lifted out of OverlaySection's local useState so collapse choices +// survive: +// - navigating to a different subagent (Detail remounts) +// - switching list ↔ detail mode (Detail unmounts in list mode) +// - walking history (←/→) +// Keyed by section title; missing entries fall back to the section's +// `defaultOpen` prop. + +export const $overlaySectionsOpen = atom>({}) + +export const toggleOverlaySection = (title: string, defaultOpen: boolean) => { + const state = $overlaySectionsOpen.get() + const current = title in state ? state[title]! : defaultOpen + + $overlaySectionsOpen.set({ ...state, [title]: !current }) +} + +export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => { + const state = $overlaySectionsOpen.get() + + return title in state ? state[title]! : defaultOpen +} + +/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */ +export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => { + if (!r) { + return + } + + const patch: Partial = { updatedAt: Date.now() } + + if (typeof r.max_spawn_depth === 'number') { + patch.maxSpawnDepth = r.max_spawn_depth + } + + if (typeof r.max_concurrent_children === 'number') { + patch.maxConcurrentChildren = r.max_concurrent_children + } + + if (typeof r.paused === 'boolean') { + patch.paused = r.paused + } + + patchDelegationState(patch) +} diff --git a/packages/cli/src/app/gatewayContext.tsx b/packages/cli/src/app/gatewayContext.tsx new file mode 100644 index 0000000..ffcfa90 --- /dev/null +++ b/packages/cli/src/app/gatewayContext.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { createContext, useContext } from 'react' + +import type { GatewayProviderProps, GatewayServices } from './interfaces.js' + +const GatewayContext = createContext(null) + +export function GatewayProvider({ children, value }: GatewayProviderProps) { + return {children} +} + +export function useGateway() { + const value = useContext(GatewayContext) + + if (!value) { + throw new Error('GatewayContext missing') + } + + return value +} diff --git a/packages/cli/src/app/inputSelectionStore.ts b/packages/cli/src/app/inputSelectionStore.ts new file mode 100644 index 0000000..c01e118 --- /dev/null +++ b/packages/cli/src/app/inputSelectionStore.ts @@ -0,0 +1,15 @@ +import { atom } from 'nanostores' + +export interface InputSelection { + clear: () => void + collapseToEnd: () => void + end: number + start: number + value: string +} + +export const $inputSelection = atom(null) + +export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next) + +export const getInputSelection = () => $inputSelection.get() diff --git a/packages/cli/src/app/interfaces.ts b/packages/cli/src/app/interfaces.ts new file mode 100644 index 0000000..aa83aa7 --- /dev/null +++ b/packages/cli/src/app/interfaces.ts @@ -0,0 +1,408 @@ +import type { MouseTrackingMode } from '@coder/tui' +import type { MutableRefObject, ReactNode, SetStateAction } from 'react' + +import type { PasteEvent } from '../components/textInput.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { ImageAttachResponse, SessionCloseResponse } from '../gateway/types.js' +import type { ParsedVoiceRecordKey } from '../lib/platform.js' +import type { RpcResult } from '../lib/rpc.js' +import type { Theme } from '../theme.js' +import type { + ApprovalReq, + ClarifyReq, + ConfirmReq, + DetailsMode, + Msg, + PanelSection, + SecretReq, + SectionVisibility, + SessionInfo, + SlashCatalog, + SudoReq, + Usage +} from '../types.js' + +export interface StateSetter { + (value: SetStateAction): void +} + +export type StatusBarMode = 'bottom' | 'off' | 'top' + +export type BusyInputMode = 'interrupt' | 'queue' | 'steer' + +// Single source of truth for indicator style names. Union type is +// derived from this tuple so adding/removing a style only touches one +// line — `useConfigSync` (validation) and `session.ts` (slash arg +// validation + usage hint) both import it. +export const INDICATOR_STYLES = ['ascii', 'emoji', 'kaomoji', 'unicode'] as const +export type IndicatorStyle = (typeof INDICATOR_STYLES)[number] +export const DEFAULT_INDICATOR_STYLE: IndicatorStyle = 'kaomoji' + +export interface SelectionApi { + captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void + clearSelection: () => void + copySelection: () => Promise + copySelectionNoClear: () => Promise + getState: () => unknown + version: () => number + shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void + shiftSelection: (dRow: number, minRow: number, maxRow: number) => void +} + +export interface CompletionItem { + display: string + meta?: string + text: string +} + +export interface GatewayRpc { + (method: string, params?: Record): Promise +} + +export interface GatewayServices { + gw: IGatewayClient + rpc: GatewayRpc +} + +export interface GatewayProviderProps { + children: ReactNode + value: GatewayServices +} + +export interface DiffViewState { + /** Left / old content */ + oldContent: string + /** Right / new content */ + newContent: string + /** File path to display in header */ + filePath?: string + /** Display mode */ + mode: 'unified' | 'split' +} + +export interface OverlayState { + agents: boolean + agentsInitialHistoryIndex: number + approval: ApprovalReq | null + clarify: ClarifyReq | null + confirm: ConfirmReq | null + coordinatorDashboard: boolean + diffView: DiffViewState | null + fileTreeVisible: boolean + modelPicker: boolean + pager: null | PagerState + picker: boolean + secret: null | SecretReq + sessions: boolean + skillsHub: boolean + sudo: null | SudoReq +} + +export interface PagerState { + lines: string[] + offset: number + title?: string +} + +export interface TranscriptRow { + index: number + key: string + msg: Msg +} + +export interface UiState { + bgTasks: Set + busy: boolean + busyInputMode: BusyInputMode + compact: boolean + detailsMode: DetailsMode + detailsModeCommandOverride: boolean + info: null | SessionInfo + liveSessionCount: number + inlineDiffs: boolean + mouseTracking: MouseTrackingMode + pasteCollapseLines: number + pasteCollapseChars: number + + sections: SectionVisibility + showCost: boolean + showReasoning: boolean + indicatorStyle: IndicatorStyle + sid: null | string + /** Whether the slash command popup is open */ + slashCommandOpen: boolean + /** The text after "/" used to filter commands */ + slashCommandFilter: string + /** The currently highlighted command index in the popup */ + slashCommandSelectedIndex: number + status: string + statusBar: StatusBarMode + streaming: boolean + theme: Theme + usage: Usage +} + +export interface VirtualHistoryState { + bottomSpacer: number + end: number + measureRef: (key: string) => (el: unknown) => void + offsets: ArrayLike + start: number + topSpacer: number +} + +export interface ComposerPasteResult { + cursor: number + value: string +} + +export type MaybePromise = Promise | T + +export interface ComposerActions { + clearIn: () => void + dequeue: () => string | undefined + enqueue: (text: string) => void + handleTextPaste: (event: PasteEvent) => MaybePromise + openEditor: () => Promise + pushHistory: (text: string) => void + removeQueue: (index: number) => void + replaceQueue: (index: number, text: string) => void + setCompIdx: StateSetter + setHistoryIdx: StateSetter + setInput: StateSetter + setInputBuf: StateSetter + setPasteSnips: StateSetter + setQueueEdit: (index: null | number) => void + syncQueue: () => void +} + +export interface ComposerRefs { + historyDraftRef: MutableRefObject + historyRef: MutableRefObject + queueEditRef: MutableRefObject + queueRef: MutableRefObject + submitRef: MutableRefObject<(value: string) => void> +} + +export interface ComposerState { + compIdx: number + compReplace: number + completions: CompletionItem[] + historyIdx: null | number + input: string + inputBuf: string[] + pasteSnips: PasteSnippet[] + queueEditIdx: null | number + queuedDisplay: string[] +} + +export interface UseComposerStateOptions { + gw: IGatewayClient + onClipboardPaste: (quiet?: boolean) => Promise | void + onImageAttached?: (info: ImageAttachResponse) => void + submitRef: MutableRefObject<(value: string) => void> +} + +export interface UseComposerStateResult { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState +} + +export interface InputHandlerActions { + answerClarify: (answer: string) => void + appendMessage: (msg: Msg) => void + die: () => void + dispatchSubmission: (full: string) => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string, title?: string) => void + sys: (text: string) => void +} + +export interface InputHandlerContext { + actions: InputHandlerActions + composer: { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState + } + gateway: GatewayServices + terminal: { + hasSelection: boolean + selection: SelectionApi + stdout?: NodeJS.WriteStream + } + voice: { + enabled: boolean + recordKey: ParsedVoiceRecordKey + recording: boolean + setProcessing: StateSetter + setRecording: StateSetter + setVoiceEnabled: StateSetter + setVoiceTts: StateSetter + } +} + +export interface InputHandlerResult { + pagerPageSize: number +} + +export interface GatewayEventHandlerContext { + composer: { + setInput: StateSetter + } + gateway: GatewayServices + session: { + STARTUP_RESUME_ID: string + colsRef: MutableRefObject + newSession: (msg?: string, title?: string) => void + resetSession: () => void + resumeById: (id: string) => void + setCatalog: StateSetter + } + submission: { + submitRef: MutableRefObject<(value: string) => void> + } + system: { + bellOnComplete: boolean + stdout?: NodeJS.WriteStream + sys: (text: string) => void + } + transcript: { + appendMessage: (msg: Msg) => void + panel: (title: string, sections: PanelSection[]) => void + setHistoryItems: StateSetter + } + voice: { + setProcessing: StateSetter + setRecording: StateSetter + setVoiceEnabled: StateSetter + setVoiceTts: StateSetter + } +} + +export interface SlashHandlerContext { + composer: { + enqueue: (text: string) => void + hasSelection: boolean + paste: (quiet?: boolean) => void + queueRef: MutableRefObject + selection: SelectionApi + setInput: StateSetter + } + gateway: GatewayServices + local: { + catalog: null | SlashCatalog + getHistoryItems: () => Msg[] + getLastUserMsg: () => string + maybeWarn: (value: unknown) => void + setCatalog: StateSetter + } + session: { + closeSession: (targetSid?: null | string) => Promise + die: () => void + dieWithCode: (code: number) => void + guardBusySessionSwitch: (what?: string) => boolean + newLiveSession: (msg?: string, title?: string) => void + newSession: (msg?: string, title?: string) => void + resetVisibleHistory: (info?: null | SessionInfo) => void + resumeById: (id: string) => void + setSessionStartedAt: StateSetter + } + slashFlightRef: MutableRefObject + transcript: { + page: (text: string, title?: string) => void + panel: (title: string, sections: PanelSection[]) => void + send: (text: string) => void + setHistoryItems: StateSetter + sys: (text: string) => void + trimLastExchange: (items: Msg[]) => Msg[] + } + voice: { + setVoiceEnabled: StateSetter + setVoiceRecordKey: (v: ParsedVoiceRecordKey) => void + setVoiceTts: StateSetter + } +} + +export interface AppLayoutActions { + answerApproval: (choice: string) => void + answerClarify: (answer: string) => void + answerSecret: (value: string) => void + answerSudo: (pw: string) => void + clearSelection: () => void + activateLiveSession: (id: string) => void + closeLiveSession: (id: string) => Promise + newLiveSession: () => void + newPromptSession: (prompt: string, modelArg?: string) => void + onModelSelect: (value: string) => void + resumeById: (id: string) => void + setStickyPrompt: (value: string) => void +} + +export interface AppLayoutComposerProps { + cols: number + compIdx: number + completions: CompletionItem[] + empty: boolean + handleTextPaste: (event: PasteEvent) => MaybePromise + input: string + inputBuf: string[] + pagerPageSize: number + queueEditIdx: null | number + queuedDisplay: string[] + submit: (value: string) => void + updateInput: StateSetter + voiceRecordKey: ParsedVoiceRecordKey +} + +export interface AppLayoutProgressProps { + showProgressArea: boolean +} + +export interface AppLayoutStatusProps { + cwdLabel: string + goodVibesTick: number + sessionStartedAt: null | number + showStickyPrompt: boolean + statusColor: string + stickyPrompt: string + turnStartedAt: null | number + voiceLabel: string +} + +export interface AppLayoutTranscriptProps { + historyItems: Msg[] +} + +export interface AppLayoutProps { + actions: AppLayoutActions + composer: AppLayoutComposerProps + mouseTracking: MouseTrackingMode + progress: AppLayoutProgressProps + status: AppLayoutStatusProps + transcript: AppLayoutTranscriptProps +} + +export interface AppOverlaysProps { + cols: number + compIdx: number + completions: CompletionItem[] + onApprovalChoice: (choice: string) => void + onClarifyAnswer: (value: string) => void + onActiveSessionSelect: (sessionId: string) => void + onActiveSessionClose: (sessionId: string) => Promise + onModelSelect: (value: string) => void + onNewLiveSession: () => void + onNewPromptSession: (prompt: string, modelArg?: string) => void + onPickerSelect: (sessionId: string) => void + onSecretSubmit: (value: string) => void + onSudoSubmit: (pw: string) => void + pagerPageSize: number +} + +export interface PasteSnippet { + label: string + path?: string + text: string +} diff --git a/packages/cli/src/app/overlayStore.ts b/packages/cli/src/app/overlayStore.ts new file mode 100644 index 0000000..69d1094 --- /dev/null +++ b/packages/cli/src/app/overlayStore.ts @@ -0,0 +1,59 @@ +import { atom, computed } from 'nanostores' + +import type { OverlayState } from './interfaces.js' + +const buildOverlayState = (): OverlayState => ({ + agents: false, + agentsInitialHistoryIndex: 0, + approval: null, + clarify: null, + confirm: null, + coordinatorDashboard: false, + diffView: null, + fileTreeVisible: false, + modelPicker: false, + pager: null, + picker: false, + secret: null, + sessions: false, + skillsHub: false, + sudo: null +}) + +export const $overlayState = atom(buildOverlayState()) + +export const $isBlocked = computed( + $overlayState, + ({ agents, approval, clarify, confirm, coordinatorDashboard, diffView, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) => + Boolean(agents || approval || clarify || confirm || coordinatorDashboard || diffView || modelPicker || pager || picker || secret || sessions || skillsHub || sudo) +) + +export const getOverlayState = () => $overlayState.get() + +export const patchOverlayState = (next: Partial | ((state: OverlayState) => OverlayState)) => + $overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next }) + +/** Full reset — used by session/turn teardown and tests. */ +export const resetOverlayState = () => $overlayState.set(buildOverlayState()) + +/** + * Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo + * / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model + * picker, skills hub, session picker. Those are opened deliberately and + * shouldn't vanish when a turn ends. Called from turnController.idle() on + * every turn completion / interrupt; the old "reset everything" behaviour + * silently closed /agents the moment delegation finished. + */ +export const resetFlowOverlays = () => + $overlayState.set({ + ...buildOverlayState(), + agents: $overlayState.get().agents, + agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, + coordinatorDashboard: $overlayState.get().coordinatorDashboard, + diffView: $overlayState.get().diffView, + fileTreeVisible: $overlayState.get().fileTreeVisible, + modelPicker: $overlayState.get().modelPicker, + picker: $overlayState.get().picker, + sessions: $overlayState.get().sessions, + skillsHub: $overlayState.get().skillsHub + }) diff --git a/packages/cli/src/app/scroll.ts b/packages/cli/src/app/scroll.ts new file mode 100644 index 0000000..94aaae4 --- /dev/null +++ b/packages/cli/src/app/scroll.ts @@ -0,0 +1,71 @@ +import type { ScrollBoxHandle } from '@coder/tui' + +import type { SelectionApi } from './interfaces.js' + +export interface SelectionSnap { + anchor?: { row: number } | null + focus?: { row: number } | null + isDragging?: boolean +} + +export interface ScrollWithSelectionOptions { + readonly scrollRef: { readonly current: ScrollBoxHandle | null } + readonly selection: SelectionApi +} + +function scrollBoundsForDelta(s: ScrollBoxHandle, cur: number, delta: number) { + const viewport = Math.max(0, s.getViewportHeight()) + const cachedHeight = Math.max(viewport, s.getScrollHeight()) + let max = Math.max(0, cachedHeight - viewport) + + // getScrollHeight() is render-time cached. After the streaming tail is + // committed into virtual history, the Yoga height can be fresher than the + // cached value; if we clamp only against the cached fake bottom, wheel-down + // becomes a no-op and no render is scheduled to reveal the real tail. + if (delta > 0 && cur + delta >= max - 1) { + const freshHeight = Math.max(viewport, s.getFreshScrollHeight()) + max = Math.max(0, freshHeight - viewport) + } + + return { max, viewport } +} + +export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void { + const s = scrollRef.current + + if (!s) { + return + } + + const cur = s.getScrollTop() + s.getPendingDelta() + const { max, viewport } = scrollBoundsForDelta(s, cur, delta) + const actual = Math.max(0, Math.min(max, cur + delta)) - cur + + if (actual === 0) { + return + } + + const sel = selection.getState() as null | SelectionSnap + const top = s.getViewportTop() + const bottom = top + viewport - 1 + + if ( + sel?.anchor && + sel.focus && + sel.anchor.row >= top && + sel.anchor.row <= bottom && + (sel.isDragging || (sel.focus.row >= top && sel.focus.row <= bottom)) + ) { + const shift = sel.isDragging ? selection.shiftAnchor : selection.shiftSelection + + if (actual > 0) { + selection.captureScrolledRows(top, top + actual - 1, 'above') + } else { + selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') + } + + shift(-actual, top, bottom) + } + + s.scrollBy(actual) +} diff --git a/packages/cli/src/app/setupHandoff.ts b/packages/cli/src/app/setupHandoff.ts new file mode 100644 index 0000000..c77c21b --- /dev/null +++ b/packages/cli/src/app/setupHandoff.ts @@ -0,0 +1,54 @@ +import type { RunExternalProcess } from '@coder/tui' + +import type { SetupStatusResponse } from '../gateway/types.js' +import type { LaunchResult } from '../lib/externalCli.js' + +import type { SlashHandlerContext } from './interfaces.js' +import { patchUiState } from './uiStore.js' + +export interface RunExternalSetupOptions { + args: string[] + ctx: Pick + done: string + launcher: (args: string[]) => Promise + suspend: (run: RunExternalProcess) => Promise +} + +export async function runExternalSetup({ args, ctx, done, launcher, suspend }: RunExternalSetupOptions) { + const { gateway, session, transcript } = ctx + + transcript.sys(`launching \`coder ${args.join(' ')}\`…`) + patchUiState({ status: 'setup running…' }) + + let result: LaunchResult = { code: null } + + await suspend(async () => { + result = await launcher(args) + }) + + if (result.error) { + transcript.sys(`error launching coder: ${result.error}`) + patchUiState({ status: 'setup required' }) + + return + } + + if (result.code !== 0) { + transcript.sys(`coder ${args[0]} exited with code ${result.code}`) + patchUiState({ status: 'setup required' }) + + return + } + + const setup = await gateway.rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + transcript.sys('still no provider configured') + patchUiState({ status: 'setup required' }) + + return + } + + transcript.sys(done) + session.newSession() +} diff --git a/packages/cli/src/app/slash/commands/core.ts b/packages/cli/src/app/slash/commands/core.ts new file mode 100644 index 0000000..fc0c136 --- /dev/null +++ b/packages/cli/src/app/slash/commands/core.ts @@ -0,0 +1,675 @@ +import { forceRedraw, type MouseTrackingMode } from '@coder/tui' + +import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' +import { dailyFortune, randomFortune } from '../../../content/fortunes.js' +import { HOTKEYS } from '../../../content/hotkeys.js' +import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' +import type { + ConfigGetValueResponse, + ConfigSetResponse, + SessionCompressResponse, + SessionSaveResponse, + SessionStatusResponse, + SessionSteerResponse, + SessionTitleResponse, + SessionUndoResponse +} from '../../../gateway/types.js' +import { writeClipboardText } from '../../../lib/clipboard.js' +import { writeOsc52Clipboard } from '../../../lib/osc52.js' +import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js' +import type { Msg, PanelSection } from '../../../types.js' +import type { StatusBarMode } from '../../interfaces.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const flagFromArg = (arg: string, current: boolean): boolean | null => { + if (!arg) { + return !current + } + + const mode = arg.trim().toLowerCase() + + if (mode === 'on') { + return true + } + + if (mode === 'off') { + return false + } + + if (mode === 'toggle') { + return !current + } + + return null +} + +// `/mouse` toggles between full tracking and off when called bare so the +// old binary muscle-memory still works. Explicit presets (wheel / buttons / +// all) target the tmux-friendly hover-free subsets. +const MOUSE_MODE_ALIASES: Record = { + all: 'all', + any: 'all', + button: 'buttons', + buttons: 'buttons', + click: 'buttons', + full: 'all', + off: 'off', + on: 'all', + scroll: 'wheel', + wheel: 'wheel' +} + +const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => { + if (!arg || arg.trim().toLowerCase() === 'toggle') { + return current === 'off' ? 'all' : 'off' + } + + return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null +} + +const RESET_WORDS = new Set(['reset', 'clear', 'default']) +const CYCLE_WORDS = new Set(['cycle', 'toggle']) + +const DETAILS_USAGE = + 'usage: /details [hidden|collapsed|expanded|cycle] or /details
[hidden|collapsed|expanded|reset]' + +const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' + +export const coreCommands: SlashCommand[] = [ + { + help: 'list commands + hotkeys', + name: 'help', + run: (_arg, ctx) => { + const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({ + rows: cat.pairs, + title: cat.name + })) + + if (ctx.local.catalog?.skillCount) { + sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` }) + } + + sections.push( + { + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'], + [ + '/details
[hidden|collapsed|expanded|reset]', + 'override one section (thinking/tools/subagents/activity)' + ], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ], + title: 'TUI' + }, + { rows: HOTKEYS, title: 'Hotkeys' } + ) + + ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections) + } + }, + + { + aliases: ['exit'], + help: 'exit Coder', + name: 'quit', + run: (_arg, ctx) => ctx.session.die() + }, + + { + help: 'update Coder Agent to the latest version (exits TUI)', + name: 'update', + run: (_arg, ctx) => { + ctx.transcript.sys('exiting TUI to run update...') + // Exit code 42 signals the Python wrapper to exec `coder update`. + // Use dieWithCode for proper cleanup (gateway kill + Ink unmount). + setTimeout(() => ctx.session.dieWithCode(42), 100) + } + }, + + { + aliases: ['scroll'], + help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]', + name: 'mouse', + run: (arg, ctx) => { + const current = ctx.ui.mouseTracking + const next = mouseModeFromArg(arg, current) + + if (next === null) { + return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]') + } + + patchUiState({ mouseTracking: next }) + ctx.gateway.rpc('config.set', { key: 'mouse', value: next }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`)) + } + }, + + { + aliases: ['new'], + help: 'start a new session', + name: 'clear', + run: (arg, ctx, cmd) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + const isNew = cmd.startsWith('/new') + const requestedTitle = isNew ? arg.trim() : '' + + const commit = () => { + patchUiState({ status: 'forging session…' }) + ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined) + } + + if (NO_CONFIRM_DESTRUCTIVE) { + return commit() + } + + patchOverlayState({ + confirm: { + cancelLabel: 'No, keep going', + confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session', + danger: true, + detail: 'This ends the current conversation and clears the transcript.', + onConfirm: commit, + title: isNew ? 'Start a new session?' : 'Clear the current session?' + } + }) + } + }, + + { + help: 'force a full UI repaint', + name: 'redraw', + run: (_arg, ctx) => { + forceRedraw(process.stdout) + ctx.transcript.sys('ui redrawn') + } + }, + + { + help: 'show live session info', + name: 'status', + run: (_arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session') + } + + ctx.gateway + .rpc('session.status', { session_id: ctx.sid }) + .then(ctx.guarded(r => ctx.transcript.page(r.output || '(no status)', 'Status'))) + .catch(ctx.guardedErr) + } + }, + + { + help: 'resume a prior session', + name: 'resume', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true }) + } + }, + + { + help: 'set or show current session title', + name: 'title', + run: (arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session') + } + + const title = arg.trim() + + if (!arg) { + ctx.gateway + .rpc('session.title', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const current = (r?.title ?? '').trim() + ctx.transcript.sys(current ? `title: ${current}` : 'no title set') + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (!title) { + return ctx.transcript.sys('usage: /title ') + } + + ctx.gateway + .rpc('session.title', { session_id: ctx.sid, title }) + .then( + ctx.guarded(r => { + const next = (r?.title ?? title).trim() + const suffix = r?.pending ? ' (queued while session initializes)' : '' + ctx.transcript.sys(`session title set: ${next}${suffix}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'compact conversation context (no args) or toggle compact display (on|off|toggle)', + name: 'compact', + run: (arg, ctx) => { + const trimmed = arg.trim() + + // ── No args: trigger actual conversation compression (Claude Code behavior) ── + if (!trimmed) { + if (!ctx.sid) { + return ctx.transcript.sys('no active session to compact') + } + ctx.transcript.sys('Compacting conversation…') + ctx.gateway + .rpc>('session.compress', { session_id: ctx.sid }) + .then(r => { + if (r && (r as { removed?: number }).removed != null && (r as { removed: number }).removed > 0) { + const res = r as { removed: number; before_tokens?: number; after_tokens?: number } + ctx.transcript.sys( + `Compacted: ${res.removed} messages removed` + + (res.before_tokens != null ? ` · ${res.before_tokens.toLocaleString()} → ${(res.after_tokens ?? 0).toLocaleString()} tokens` : '') + ) + } else if (r && (r as { removed?: number }).removed === 0) { + ctx.transcript.sys('Context is within budget — no compaction needed') + } + }) + .catch(() => ctx.transcript.sys('compaction failed')) + return + } + + // ── Explicit on/off/toggle: toggle compact display mode ── + const next = flagFromArg(arg, ctx.ui.compact) + + if (next === null) { + return ctx.transcript.sys('usage: /compact [on|off|toggle]') + } + + patchUiState({ compact: next }) + ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`compact display ${next ? 'on' : 'off'}`)) + } + }, + + { + aliases: ['detail'], + help: 'control agent detail visibility (global or per-section)', + name: 'details', + run: (arg, ctx) => { + const { gateway, transcript, ui } = ctx + + if (!arg) { + gateway + .rpc('config.get', { key: 'details_mode' }) + .then(r => { + if (ctx.stale()) { + return + } + + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode + patchUiState({ detailsMode: mode, detailsModeCommandOverride: false }) + + const overrides = SECTION_NAMES.filter(s => ui.sections[s]) + .map(s => `${s}=${ui.sections[s]}`) + .join(' ') + + transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`) + }) + .catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`)) + + return + } + + const [first, second] = arg.trim().toLowerCase().split(/\s+/) + + if (second && isSectionName(first)) { + const reset = RESET_WORDS.has(second) + const mode = reset ? null : parseDetailsMode(second) + + if (!reset && !mode) { + return transcript.sys(DETAILS_SECTION_USAGE) + } + + const { [first]: _drop, ...rest } = ui.sections + + patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest }) + gateway + .rpc('config.set', { key: `details_mode.${first}`, value: mode ?? '' }) + .catch(() => {}) + transcript.sys(`details ${first}: ${mode ?? 'reset'}`) + + return + } + + const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first) + + if (!next) { + return transcript.sys(DETAILS_USAGE) + } + + const sections = Object.fromEntries(SECTION_NAMES.map(section => [section, next])) + + patchUiState({ detailsMode: next, detailsModeCommandOverride: true, sections }) + gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + transcript.sys(`details: ${next}`) + } + }, + + { + help: 'local fortune', + name: 'fortune', + run: (arg, ctx) => { + const key = arg.trim().toLowerCase() + + if (!arg || key === 'random') { + return ctx.transcript.sys(randomFortune()) + } + + if (['daily', 'stable', 'today'].includes(key)) { + return ctx.transcript.sys(dailyFortune(ctx.sid)) + } + + ctx.transcript.sys('usage: /fortune [random|daily]') + } + }, + + { + help: 'copy selection or assistant message', + name: 'copy', + run: async (arg, ctx) => { + const { sys } = ctx.transcript + + if (!arg && ctx.composer.hasSelection) { + const text = await ctx.composer.selection.copySelection() + + if (text) { + return sys(`copied ${text.length} characters`) + } else { + return sys( + 'clipboard copy failed — try CODER_TUI_FORCE_OSC52=1 to force the escape sequence' + ) + } + } + + if (arg && Number.isNaN(parseInt(arg, 10))) { + return sys('usage: /copy [number]') + } + + const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] + + if (!target) { + return sys('nothing to copy — start a conversation first') + } + + void writeClipboardText(target.text) + .then(nativeOk => { + if (ctx.stale()) { + return + } + + if (nativeOk) { + sys('copied to clipboard') + } else { + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + } + }) + .catch(error => { + if (!ctx.stale()) { + sys(`copy failed: ${String(error)}`) + } + }) + } + }, + + { + help: 'attach clipboard image', + name: 'paste', + run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste()) + }, + + { + help: 'configure IDE terminal keybindings for multiline + undo/redo', + name: 'terminal-setup', + run: (arg, ctx) => { + const target = arg.trim().toLowerCase() + + if (target && !['auto', 'cursor', 'vscode', 'windsurf'].includes(target)) { + return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]') + } + + const runner = + !target || target === 'auto' + ? configureDetectedTerminalKeybindings() + : configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf') + + void runner + .then(result => { + if (ctx.stale()) { + return + } + + ctx.transcript.sys(result.message) + + if (result.success && result.requiresRestart) { + ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect') + } + }) + .catch(error => { + if (!ctx.stale()) { + ctx.transcript.sys(`terminal setup failed: ${String(error)}`) + } + }) + } + }, + + { + help: 'view gateway logs', + name: 'logs', + run: (arg, ctx) => { + const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + + text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs') + } + }, + + { + help: 'view current transcript (user + assistant messages)', + name: 'history', + run: (arg, ctx) => { + // The CLI-side `/history` runs in a detached slash-worker subprocess + // that never sees the TUI's turns — it only surfaces whatever was + // persisted before this process started. Render the TUI's own + // transcript so `/history` actually reflects what the user just did. + const items = ctx.local.getHistoryItems().filter(m => m.role === 'user' || m.role === 'assistant') + + if (!items.length) { + return ctx.transcript.sys('no conversation yet') + } + + const preview = Math.max(80, parseInt(arg, 10) || 400) + + const lines = items.map((m, i) => { + const tag = m.role === 'user' ? `You #${i + 1}` : `Coder #${i + 1}` + const body = m.text.trim() || (m.tools?.length ? `(${m.tools.length} tool calls)` : '(empty)') + const clipped = body.length > preview ? `${body.slice(0, preview).trimEnd()}…` : body + + return `[${tag}]\n${clipped}` + }) + + ctx.transcript.page(lines.join('\n\n'), 'History') + } + }, + + { + help: 'save the current transcript to JSON', + name: 'save', + run: (_arg, ctx) => { + const hasConversation = ctx.local + .getHistoryItems() + .some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') + + if (!hasConversation) { + return ctx.transcript.sys('no conversation yet') + } + + if (!ctx.sid) { + return ctx.transcript.sys('no active session — nothing to save') + } + + ctx.gateway + .rpc('session.save', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const file = r?.file + + if (file) { + ctx.transcript.sys(`conversation saved to: ${file}`) + } else { + ctx.transcript.sys('failed to save') + } + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + aliases: ['sb'], + help: 'status bar position (on|off|top|bottom)', + name: 'statusbar', + run: (arg, ctx) => { + const mode = arg.trim().toLowerCase() + const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off' + + const next: null | StatusBarMode = + !mode || mode === 'toggle' + ? toggle + : mode === 'on' || mode === 'top' + ? 'top' + : mode === 'off' || mode === 'bottom' + ? mode + : null + + if (!next) { + return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]') + } + + patchUiState({ statusBar: next }) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`)) + } + }, + + { + aliases: ['q'], + help: 'inspect or enqueue a message', + name: 'queue', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`) + } + + ctx.composer.enqueue(arg) + ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + } + }, + + { + help: 'inject a message after the next tool call (no interrupt)', + name: 'steer', + run: (arg, ctx) => { + const payload = arg?.trim() ?? '' + + if (!payload) { + return ctx.transcript.sys('usage: /steer ') + } + + // If the agent isn't running, fall back to the queue so the user's + // message isn't lost — identical semantics to the gateway handler. + if (!ctx.ui.busy || !ctx.sid) { + ctx.composer.enqueue(payload) + ctx.transcript.sys( + `no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` + ) + + return + } + + ctx.gateway + .rpc('session.steer', { session_id: ctx.sid, text: payload }) + .then( + ctx.guarded(r => { + if (r?.status === 'queued') { + ctx.transcript.sys( + `steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` + ) + } else { + ctx.transcript.sys('steer rejected') + } + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'undo last exchange', + name: 'undo', + run: (_arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('nothing to undo') + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) > 0) { + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.sys(`undid ${r.removed} messages`) + } else { + ctx.transcript.sys('nothing to undo') + } + }) + ) + } + }, + + { + help: 'retry last user message', + name: 'retry', + run: (_arg, ctx) => { + const last = ctx.local.getLastUserMsg() + + if (!last) { + return ctx.transcript.sys('nothing to retry') + } + + if (!ctx.sid) { + return ctx.transcript.send(last) + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to retry') + } + + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.send(last) + }) + ) + } + } +] diff --git a/packages/cli/src/app/slash/commands/debug.ts b/packages/cli/src/app/slash/commands/debug.ts new file mode 100644 index 0000000..83149ec --- /dev/null +++ b/packages/cli/src/app/slash/commands/debug.ts @@ -0,0 +1,48 @@ +import { formatBytes, performHeapDump } from '../../../lib/memory.js' +import type { SlashCommand } from '../types.js' + +export const debugCommands: SlashCommand[] = [ + { + help: 'write a V8 heap snapshot + memory diagnostics (see CODER_HEAPDUMP_DIR)', + name: 'heapdump', + run: (_arg, ctx) => { + const { heapUsed, rss } = process.memoryUsage() + + ctx.transcript.sys(`writing heap dump (heap ${formatBytes(heapUsed)} · rss ${formatBytes(rss)})…`) + + void performHeapDump('manual').then(r => { + if (ctx.stale()) { + return + } + + if (!r.success) { + return ctx.transcript.sys(`heapdump failed: ${r.error ?? 'unknown error'}`) + } + + ctx.transcript.sys(`heapdump: ${r.heapPath}`) + ctx.transcript.sys(`diagnostics: ${r.diagPath}`) + }) + } + }, + + { + help: 'print live V8 heap + rss numbers', + name: 'mem', + run: (_arg, ctx) => { + const { arrayBuffers, external, heapTotal, heapUsed, rss } = process.memoryUsage() + + ctx.transcript.panel('Memory', [ + { + rows: [ + ['heap used', formatBytes(heapUsed)], + ['heap total', formatBytes(heapTotal)], + ['external', formatBytes(external)], + ['array buffers', formatBytes(arrayBuffers)], + ['rss', formatBytes(rss)], + ['uptime', `${process.uptime().toFixed(0)}s`] + ] + } + ]) + } + } +] diff --git a/packages/cli/src/app/slash/commands/init.ts b/packages/cli/src/app/slash/commands/init.ts new file mode 100644 index 0000000..bc01e21 --- /dev/null +++ b/packages/cli/src/app/slash/commands/init.ts @@ -0,0 +1,88 @@ +import type { SlashCommand } from '../types.js' + +/** + * /init — Bootstrap a project context file (.coder/CODER.md) + * + * Sends a structured prompt to the Agent asking it to: + * 1. Analyse the project structure (package.json, tsconfig, directory layout) + * 2. Generate a tailored .coder/CODER.md with project description, tech stack, + * build commands, coding conventions, and architecture notes + * 3. Write the file to .coder/CODER.md and report what was created + */ +export const initCommands: SlashCommand[] = [ + { + help: 'bootstrap a project context file (.coder/CODER.md)', + name: 'init', + run: (_arg, ctx) => { + // If the agent is busy mid-turn, queue the prompt so it fires + // after the current turn completes rather than interrupting. + if (ctx.ui.busy) { + ctx.composer.enqueue(INIT_PROMPT) + ctx.transcript.sys( + 'agent is busy — /init prompt queued (run /queue to inspect, Ctrl+K to dispatch)' + ) + return + } + + // Submit directly to the agent (automatically creates a session if none exists) + ctx.transcript.send(INIT_PROMPT) + } + } +] + +/** The prompt sent to the Agent by /init. */ +const INIT_PROMPT = [ + 'Please analyse the current project and create a `.coder/CODER.md` file.', + '', + 'Steps:', + '1. Read `package.json` (if it exists) — note the project name, scripts, dependencies.', + '2. Read `tsconfig.json` or `jsconfig.json` — note compiler options and path aliases.', + '3. List the top-level directory structure (Glob "**/*" with depth 1-2 or use Bash `ls -la`).', + '4. Check for existing config files: `.eslintrc.*`, `.prettierrc*`, `vite.config.*`, etc.', + '5. Check `.gitignore` for build-output patterns.', + '', + 'Then generate `.coder/CODER.md` with these sections:', + '', + '```markdown', + '# Project: ', + '', + '## Tech Stack', + '- Runtime: ', + '- Framework: ', + '- Key dependencies: ', + '', + '## Project Layout', + '```', + '', + '```', + '', + '## Build & Run', + '```bash', + '', + '', + '', + '', + '```', + '', + '## Coding Conventions', + '- ', + '- ', + '- ', + '', + '## Architecture Notes', + '- ', + '- ', + '', + '## Common Tasks', + '- ', + '- ', + '```', + '', + 'Write the file using the Write tool. After writing, summarise what you created.', + '', + 'Important:', + '- Keep descriptions concise — this is a reference for future Agent sessions.', + '- Use the actual project structure you observe, not templates.', + '- If a section does not apply (e.g. no database), omit it.', + '- The file path must be `.coder/CODER.md` in the project root.' +].join('\n') diff --git a/packages/cli/src/app/slash/commands/ops.ts b/packages/cli/src/app/slash/commands/ops.ts new file mode 100644 index 0000000..a27fece --- /dev/null +++ b/packages/cli/src/app/slash/commands/ops.ts @@ -0,0 +1,735 @@ +import type { + BrowserManageResponse, + CommandsCatalogResponse, + DelegationPauseResponse, + ProcessStopResponse, + ReloadEnvResponse, + ReloadMcpResponse, + RollbackDiffResponse, + RollbackListResponse, + RollbackRestoreResponse, + SlashExecResponse, + SpawnTreeListResponse, + SpawnTreeLoadResponse, + ToolsConfigureResponse +} from '../../../gateway/types.js' +import type { PanelSection } from '../../../types.js' +import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js' +import { patchOverlayState } from '../../overlayStore.js' +import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js' +import type { SlashCommand } from '../types.js' + +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsListResponse { + skills?: Record +} + +interface SkillsInspectResponse { + info?: SkillInfo +} + +interface SkillsSearchResponse { + results?: { description?: string; name: string }[] +} + +interface SkillsInstallResponse { + installed?: boolean + name?: string +} + +interface SkillsBrowseItem { + description?: string + name: string + source?: string + trust?: string +} + +interface SkillsBrowseResponse { + items?: SkillsBrowseItem[] + page?: number + total?: number + total_pages?: number +} + +interface SkillsReloadResponse { + output?: string +} + +export const opsCommands: SlashCommand[] = [ + { + help: 'stop background processes', + name: 'stop', + run: (_arg, ctx) => { + ctx.gateway + .rpc('process.stop', {}) + .then( + ctx.guarded(r => { + const killed = Number(r.killed ?? 0) + const noun = killed === 1 ? 'process' : 'processes' + ctx.transcript.sys(`stopped ${killed} background ${noun}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + aliases: ['reload_mcp'], + help: 'reload MCP servers in the live session (warns about prompt cache invalidation)', + name: 'reload-mcp', + run: (arg, ctx) => { + // Parse arg: `now` / `always` skip the confirmation gate. + // `always` additionally persists approvals.mcp_reload_confirm=false. + const a = (arg || '').trim().toLowerCase() + const params: { session_id: string | null; confirm?: boolean; always?: boolean } = { + session_id: ctx.sid + } + if (a === 'now' || a === 'approve' || a === 'once' || a === 'yes') { + params.confirm = true + } else if (a === 'always') { + params.confirm = true + params.always = true + } + + ctx.gateway + .rpc('reload.mcp', params) + .then( + ctx.guarded(r => { + if (r.status === 'confirm_required') { + ctx.transcript.sys(r.message || '/reload-mcp requires confirmation') + return + } + if (r.status === 'reloaded') { + ctx.transcript.sys( + params.always + ? 'MCP servers reloaded · future /reload-mcp will run without confirmation' + : 'MCP servers reloaded' + ) + return + } + ctx.transcript.sys('reload complete') + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 're-read ~/.coder/.env into the running gateway (CLI parity)', + name: 'reload', + run: (_arg, ctx) => { + ctx.gateway + .rpc('reload.env', {}) + .then( + ctx.guarded(r => { + const n = Number(r.updated ?? 0) + const noun = n === 1 ? 'var' : 'vars' + + ctx.transcript.sys(`reloaded .env (${n} ${noun} updated)`) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'manage browser CDP connection [connect|disconnect|status]', + name: 'browser', + run: (arg, ctx) => { + const [rawAction = 'status', ...rest] = arg.trim().split(/\s+/).filter(Boolean) + const action = rawAction.toLowerCase() + + if (!['connect', 'disconnect', 'status'].includes(action)) { + return ctx.transcript.sys( + 'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in settings.json' + ) + } + + const sid = ctx.sid ?? null + const url = action === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined + + if (url) { + ctx.transcript.sys(`checking Chromium-family browser remote debugging at ${url}...`) + } + + ctx.gateway + .rpc('browser.manage', { action, session_id: sid, ...(url && { url }) }) + .then( + ctx.guarded(r => { + // Without a session we can't subscribe to streamed + // browser.progress events, so flush the bundled list. + if (!sid) { + r.messages?.forEach(message => ctx.transcript.sys(message)) + } + + if (action === 'status') { + return ctx.transcript.sys( + r.connected + ? `browser connected: ${r.url || '(url unavailable)'}` + : 'browser not connected (try /browser connect or set browser.cdp_url in settings.json)' + ) + } + + if (action === 'disconnect') { + return ctx.transcript.sys('browser disconnected') + } + + if (r.connected) { + ctx.transcript.sys('Browser connected to live Chromium-family browser via CDP') + ctx.transcript.sys(`Endpoint: ${r.url || '(url unavailable)'}`) + ctx.transcript.sys('next browser tool call will use this CDP endpoint') + } + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list, diff, or restore checkpoints', + name: 'rollback', + run: (arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('no active session — nothing to rollback') + } + + const trimmed = arg.trim() + const [first = '', ...rest] = trimmed.split(/\s+/).filter(Boolean) + const lower = first.toLowerCase() + + if (!trimmed || lower === 'list' || lower === 'ls') { + return ctx.gateway + .rpc('rollback.list', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (!r.enabled) { + return ctx.transcript.sys('checkpoints are not enabled') + } + + const checkpoints = r.checkpoints ?? [] + + if (!checkpoints.length) { + return ctx.transcript.sys('no checkpoints found') + } + + ctx.transcript.panel('Rollback checkpoints', [ + { + rows: checkpoints.map((c, idx) => [ + `${idx + 1}. ${c.hash.slice(0, 10)}`, + [c.timestamp, c.message].filter(Boolean).join(' · ') || '(no metadata)' + ]) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + + if (lower === 'diff') { + const hash = rest[0] + + if (!hash) { + return ctx.transcript.sys('usage: /rollback diff ') + } + + return ctx.gateway + .rpc('rollback.diff', { hash, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const body = (r.rendered || r.diff || '').trim() + + if (!body && !r.stat) { + return ctx.transcript.sys('no changes since this checkpoint') + } + + const text = [r.stat || '', body].filter(Boolean).join('\n\n') + ctx.transcript.page(text, 'Rollback diff') + }) + ) + .catch(ctx.guardedErr) + } + + const hash = first + const filePath = rest.join(' ').trim() + + return ctx.gateway + .rpc('rollback.restore', { + ...(filePath ? { file_path: filePath } : {}), + hash, + session_id: ctx.sid + }) + .then( + ctx.guarded(r => { + if (!r.success) { + return ctx.transcript.sys(`rollback failed: ${r.error || r.message || 'unknown error'}`) + } + + const target = filePath || 'workspace' + const detail = r.reason || r.message || r.restored_to || 'restored' + ctx.transcript.sys(`rollback restored ${target}: ${detail}`) + + if ((r.history_removed ?? 0) > 0) { + ctx.transcript.setHistoryItems(prev => ctx.transcript.trimLastExchange(prev)) + } + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + aliases: ['tasks'], + help: 'open the spawn-tree dashboard (live audit + kill/pause controls)', + name: 'agents', + run: (arg, ctx) => { + const sub = arg.trim().toLowerCase() + + // Stay compatible with the gateway `/agents [pause|resume|status]` CLI — + // explicit subcommands skip the overlay and act directly so scripts and + // multi-step flows can drive it without entering interactive mode. + if (sub === 'pause' || sub === 'resume' || sub === 'unpause') { + const paused = sub === 'pause' + ctx.gateway.gw + .request('delegation.pause', { paused }) + .then(r => { + applyDelegationStatus({ paused: r?.paused }) + ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`) + }) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'status') { + const d = getDelegationState() + ctx.transcript.sys( + `delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}` + ) + + return + } + + patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 }) + } + }, + + { + help: 'replay a completed spawn tree · `/replay [N|last|list|load ]`', + name: 'replay', + run: (arg, ctx) => { + const history = getSpawnHistory() + const raw = arg.trim() + const lower = raw.toLowerCase() + + // ── Disk-backed listing ───────────────────────────────────── + if (lower === 'list' || lower === 'ls') { + ctx.gateway + .rpc('spawn_tree.list', { + limit: 30, + session_id: ctx.sid ?? 'default' + }) + .then( + ctx.guarded(r => { + const entries = r.entries ?? [] + + if (!entries.length) { + return ctx.transcript.sys('no archived spawn trees on disk for this session') + } + + const rows: [string, string][] = entries.map(e => { + const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?' + const label = e.label || `${e.count} subagents` + + return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`] + }) + + ctx.transcript.panel('Archived spawn trees', [{ rows }]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + // ── Disk-backed load by path ───────────────────────────────── + if (lower.startsWith('load ')) { + const path = raw.slice(5).trim() + + if (!path) { + return ctx.transcript.sys('usage: /replay load ') + } + + ctx.gateway + .rpc('spawn_tree.load', { path }) + .then( + ctx.guarded(r => { + if (!r.subagents?.length) { + return ctx.transcript.sys('snapshot empty or unreadable') + } + + // Push onto the in-memory history so the overlay picks it up + // by index 1 just like any other snapshot. + pushDiskSnapshot(r, path) + patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 }) + }) + ) + .catch(ctx.guardedErr) + + return + } + + // ── In-memory nav (same-session) ───────────────────────────── + if (!history.length) { + return ctx.transcript.sys('no completed spawn trees this session · try /replay list') + } + + let index = 1 + + if (raw && lower !== 'last') { + const parsed = parseInt(raw, 10) + + if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) { + return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`) + } + + index = parsed + } + + patchOverlayState({ agents: true, agentsInitialHistoryIndex: index }) + } + }, + + { + help: 'diff two completed spawn trees · `/replay-diff ` (indexes from /replay list or history N)', + name: 'replay-diff', + run: (arg, ctx) => { + const parts = arg.trim().split(/\s+/).filter(Boolean) + + if (parts.length !== 2) { + return ctx.transcript.sys('usage: /replay-diff (e.g. /replay-diff 1 2 for last two)') + } + + const [a, b] = parts + const history = getSpawnHistory() + + const resolve = (token: string): null | SpawnSnapshot => { + const n = parseInt(token!, 10) + + if (Number.isFinite(n) && n >= 1 && n <= history.length) { + return history[n - 1] ?? null + } + + return null + } + + const baseline = resolve(a!) + const candidate = resolve(b!) + + if (!baseline || !candidate) { + return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`) + } + + setDiffPair({ baseline, candidate }) + patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 }) + } + }, + + { + aliases: ['reload_skills'], + help: 're-scan installed skills in the live TUI gateway', + name: 'reload-skills', + run: (_arg, ctx) => { + ctx.gateway + .rpc('skills.reload', {}) + .then( + ctx.guarded(r => { + ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills') + ctx.gateway + .rpc('commands.catalog', {}) + .then( + ctx.guarded(catalog => { + if (!catalog?.pairs) { + return + } + + ctx.local.setCatalog({ + canon: (catalog.canon ?? {}) as Record, + categories: catalog.categories ?? [], + pairs: catalog.pairs as [string, string][], + skillCount: (catalog.skill_count ?? 0) as number, + sub: (catalog.sub ?? {}) as Record + }) + }) + ) + .catch(() => {}) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'browse, inspect, install skills', + name: 'skills', + run: (arg, ctx, cmd) => { + const text = arg.trim() + + if (!text) { + return patchOverlayState({ skillsHub: true }) + } + + const [sub, ...rest] = text.split(/\s+/) + const query = rest.join(' ').trim() + const { rpc } = ctx.gateway + const { panel, sys } = ctx.transcript + const runViaSlashWorker = () => { + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + const body = r?.output || '/skills: no output' + const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2 + + long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted) + }) + .catch(ctx.guardedErr) + } + + if (sub === 'list') { + rpc('skills.manage', { action: 'list' }) + .then( + ctx.guarded(r => { + const cats = Object.entries(r.skills ?? {}).sort() + + if (!cats.length) { + return sys('no skills available') + } + + panel( + 'Skills', + cats.map(([title, items]) => ({ items, title })) + ) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'inspect') { + if (!query) { + return sys('usage: /skills inspect ') + } + + rpc('skills.manage', { action: 'inspect', query }) + .then( + ctx.guarded(r => { + const info = r.info ?? {} + + if (!info.name) { + return sys(`unknown skill: ${query}`) + } + + const rows: [string, string][] = [ + ['Name', String(info.name)], + ['Category', String(info.category ?? '')], + ['Path', String(info.path ?? '')] + ] + + const sections: PanelSection[] = [{ rows }] + + if (info.description) { + sections.push({ text: String(info.description) }) + } + + panel('Skill', sections) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'search') { + if (!query) { + return sys('usage: /skills search ') + } + + rpc('skills.manage', { action: 'search', query }) + .then( + ctx.guarded(r => { + const results = r.results ?? [] + + if (!results.length) { + return sys(`no results for: ${query}`) + } + + panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'install') { + if (!query) { + return sys('usage: /skills install ') + } + + sys(`installing ${query}…`) + + rpc('skills.manage', { action: 'install', query }) + .then( + ctx.guarded(r => + sys(r.installed ? `installed ${r.name ?? query}` : 'install failed') + ) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'browse') { + const pageNum = query ? parseInt(query, 10) : 1 + + if (Number.isNaN(pageNum) || pageNum < 1) { + return sys('usage: /skills browse [page] (page must be a positive number)') + } + + sys('fetching community skills (scans 6 sources, may take ~15s)…') + + rpc('skills.manage', { action: 'browse', page: pageNum }) + .then( + ctx.guarded(r => { + const items = r.items ?? [] + + if (!items.length) { + return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`) + } + + const rows: [string, string][] = items.map(s => [ + s.trust ? `${s.name} · ${s.trust}` : s.name, + String(s.description ?? '').slice(0, 160) + ]) + + const footer: string[] = [] + + if (r.page && r.total_pages) { + footer.push(`page ${r.page} of ${r.total_pages}`) + } + + if (r.total) { + footer.push(`${r.total} skills total`) + } + + if (r.page && r.total_pages && r.page < r.total_pages) { + footer.push(`/skills browse ${r.page + 1} for more`) + } + + panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [ + { rows }, + ...(footer.length ? [{ text: footer.join(' · ') }] : []) + ]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + runViaSlashWorker() + } + }, + + { + help: 'enable or disable tools (client-side history reset on change)', + name: 'tools', + run: (arg, ctx, cmd) => { + const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) + + if (subcommand !== 'disable' && subcommand !== 'enable') { + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + const body = r?.output || '/tools: no output' + const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 + + long ? ctx.transcript.page(text, 'Tools') : ctx.transcript.sys(text) + }) + .catch(ctx.guardedErr) + + return + } + + if (!names.length) { + ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) + ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) + ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return + } + + ctx.gateway + .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (r.info) { + ctx.session.setSessionStartedAt(Date.now()) + ctx.session.resetVisibleHistory(r.info) + } + + if (r.changed?.length) { + ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + } + + if (r.unknown?.length) { + ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) + } + + if (r.missing_servers?.length) { + ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + } + + if (r.reset) { + ctx.transcript.sys('session reset. new tool configuration is active.') + } + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'interactive diff viewer — open split/unified diff overlay', + name: 'diff', + run: (_arg, ctx) => { + // Open the diff viewer overlay. When invoked without arguments, show + // a placeholder; the user can use u/s to toggle unified/split mode. + // Future: /diff rollback to populate from a checkpoint. + patchOverlayState({ + diffView: { + oldContent: `// Old version — paste content or use /rollback diff\n`, + newContent: `// New version — paste content or use /rollback diff\n`, + filePath: undefined, + mode: 'unified', + }, + }) + } + } +] diff --git a/packages/cli/src/app/slash/commands/session.ts b/packages/cli/src/app/slash/commands/session.ts new file mode 100644 index 0000000..67b1014 --- /dev/null +++ b/packages/cli/src/app/slash/commands/session.ts @@ -0,0 +1,598 @@ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +import { withInkSuspended } from '@coder/tui' + +import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' +import type { + BackgroundStartResponse, + ConfigGetValueResponse, + ConfigSetResponse, + ImageAttachResponse, + SessionBranchResponse, + SessionCompressResponse, + SessionUsageResponse, + VoiceToggleResponse +} from '../../../gateway/types.js' +import { launchCoderCommand } from '../../../lib/externalCli.js' +import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' +import { fmtK } from '../../../lib/text.js' +import type { PanelSection } from '../../../types.js' +import { DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, type IndicatorStyle } from '../../interfaces.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) +const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') + +const stripTuiSessionFlag = (trimmed: string) => trimmed.replace(TUI_SESSION_STRIP_RE, ' ').replace(/\s+/g, ' ').trim() + +const modelValueForConfigSet = (arg: string) => { + const trimmed = arg.trim() + + if (!trimmed) { + return trimmed + } + + if (TUI_SESSION_MODEL_RE.test(trimmed)) { + return stripTuiSessionFlag(trimmed) + } + + return trimmed +} + +export const sessionCommands: SlashCommand[] = [ + { + aliases: ['bg', 'btw'], + help: 'launch a background prompt', + name: 'background', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys('/background ') + } + + ctx.gateway.rpc('prompt.background', { session_id: ctx.sid, text: arg }).then( + ctx.guarded(r => { + if (!r.task_id) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) })) + ctx.transcript.sys(`bg ${r.task_id} started`) + }) + ) + } + }, + + { + help: 'change or show model', + name: 'model', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('change models')) { + return + } + + if (!arg.trim()) { + // Launch coder --model as external process (same radio-button selector as CLI) + ctx.transcript.sys('launching model selector…') + patchUiState({ status: 'model setup…' }) + + void withInkSuspended(async () => { + const result = await launchCoderCommand(['--model']) + + if (result.error) { + ctx.transcript.sys(`error launching coder: ${result.error}`) + patchUiState({ status: 'ready' }) + return + } + + if (result.code !== 0) { + ctx.transcript.sys(`coder --model exited with code ${result.code}`) + patchUiState({ status: 'ready' }) + return + } + + // Read updated settings.json to get the new model + try { + const settingsPath = join(homedir(), '.coder', 'settings.json') + const raw = await readFile(settingsPath, 'utf-8') + const settings = JSON.parse(raw) + const model = settings.default_model ?? 'unknown' + ctx.transcript.sys(`model → ${model}`) + patchUiState(state => ({ + ...state, + status: 'ready', + info: state.info ? { ...state.info, model } : { model, skills: {}, tools: {} } + })) + } catch { + ctx.transcript.sys('model updated') + patchUiState({ status: 'ready' }) + } + }) + + return + } + + ctx.gateway + .rpc('config.set', { key: 'model', session_id: ctx.sid, value: modelValueForConfigSet(arg) }) + .then( + ctx.guarded(r => { + if (!r.value) { + return ctx.transcript.sys('error: invalid response: model switch') + } + + ctx.transcript.sys(`model → ${r.value}`) + ctx.local.maybeWarn(r) + + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} } + })) + }) + ) + } + }, + + { + aliases: ['switch'], + help: 'switch between live TUI sessions', + name: 'sessions', + run: (arg, ctx) => { + if (arg.trim().toLowerCase() === 'new') { + return ctx.session.newLiveSession() + } + + patchOverlayState({ sessions: true }) + } + }, + + { + help: 'attach an image', + name: 'image', + run: (arg, ctx) => { + ctx.gateway.rpc('image.attach', { path: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + ctx.transcript.sys(attachedImageNotice(r)) + + if (r.remainder) { + ctx.composer.setInput(r.remainder) + } + }) + ) + } + }, + + { + help: 'switch personality for this session', + name: 'personality', + run: (arg, ctx) => { + if (!arg) { + return + } + + ctx.gateway.rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then( + ctx.guarded(r => { + if (r.history_reset) { + ctx.session.resetVisibleHistory(r.info ?? null) + } + + ctx.transcript.sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + ctx.local.maybeWarn(r) + }) + ) + } + }, + + { + help: 'compress transcript', + name: 'compress', + run: (arg, ctx) => { + ctx.gateway + .rpc('session.compress', { + session_id: ctx.sid, + ...(arg ? { focus_topic: arg } : {}) + }) + .then( + ctx.guarded(r => { + if (Array.isArray(r.messages)) { + const rows = toTranscriptMessages(r.messages) + + ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows) + } + + if (r.info) { + patchUiState({ info: r.info }) + } + + if (r.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + } + + if (r.summary?.headline) { + const prefix = r.summary.noop ? '' : '✓ ' + + ctx.transcript.sys(`${prefix}${r.summary.headline}`) + + if (r.summary.token_line) { + ctx.transcript.sys(` ${r.summary.token_line}`) + } + + if (r.summary.note) { + ctx.transcript.sys(` ${r.summary.note}`) + } + + return + } + + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to compress') + } + + ctx.transcript.sys( + `compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}` + ) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + aliases: ['fork'], + help: 'branch the session', + name: 'branch', + run: (arg, ctx) => { + const prevSid = ctx.sid + + ctx.gateway.rpc('session.branch', { name: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + if (!r.session_id) { + return + } + + void ctx.session.closeSession(prevSid) + patchUiState({ sid: r.session_id }) + ctx.session.setSessionStartedAt(Date.now()) + ctx.transcript.sys(`branched → ${r.title ?? ''}`) + }) + ) + } + }, + + { + help: 'voice mode: [on|off|tts|status]', + name: 'voice', + run: (arg, ctx) => { + const normalized = (arg ?? '').trim().toLowerCase() + + const action = + normalized === 'on' || normalized === 'off' || normalized === 'tts' || normalized === 'status' + ? normalized + : 'status' + + ctx.gateway.rpc('voice.toggle', { action }).then( + ctx.guarded(r => { + ctx.voice.setVoiceEnabled(!!r.enabled) + ctx.voice.setVoiceTts(!!r.tts) + + // Render the configured record key (settings.json ``voice.record_key``) + // instead of hardcoded "Ctrl+B" — the gateway response carries the + // current value so /voice status and /voice on stay in sync with + // both the CLI and the TUI's actual binding (#18994). + // + // Copilot review on #19835 caught that rendering from the fresh + // backend response WITHOUT updating the frontend ``voice.recordKey`` + // state would skew display and binding between config-edit and + // the next ``mtime`` poll (~5s). Parse once, push into state so + // ``useInputHandlers()`` picks up the new binding immediately. + // + // Round-2 follow-up: only push state when the response actually + // carries ``record_key`` — otherwise an older gateway (or a future + // branch that forgets to include it) would clobber a custom user + // binding back to the default on every /voice invocation. The + // label still falls back to the documented default for display. + const parsed = r.record_key ? parseVoiceRecordKey(r.record_key) : undefined + + if (parsed) { + ctx.voice.setVoiceRecordKey(parsed) + } + + const recordKeyLabel = formatVoiceRecordKey(parsed ?? parseVoiceRecordKey('ctrl+b')) + + // Match CLI's _show_voice_status / _enable_voice_mode / + // _toggle_voice_tts output shape so users don't have to learn + // two vocabularies. + if (action === 'status') { + const mode = r.enabled ? 'ON' : 'OFF' + const tts = r.tts ? 'ON' : 'OFF' + ctx.transcript.sys('Voice Mode Status') + ctx.transcript.sys(` Mode: ${mode}`) + ctx.transcript.sys(` TTS: ${tts}`) + ctx.transcript.sys(` Record key: ${recordKeyLabel}`) + + // CLI's "Requirements:" block — surfaces STT/audio setup issues + // so the user sees "STT provider: MISSING ..." instead of + // silently failing on every record-key press. + if (r.details) { + ctx.transcript.sys('') + ctx.transcript.sys(' Requirements:') + + for (const line of r.details.split('\n')) { + if (line.trim()) { + ctx.transcript.sys(` ${line}`) + } + } + } + + return + } + + if (action === 'tts') { + ctx.transcript.sys(`Voice TTS ${r.tts ? 'enabled' : 'disabled'}.`) + + return + } + + // on/off — mirror cli.py:_enable_voice_mode's 3-line output + if (r.enabled) { + const tts = r.tts ? ' (TTS enabled)' : '' + ctx.transcript.sys(`Voice mode enabled${tts}`) + ctx.transcript.sys(` ${recordKeyLabel} to start/stop recording`) + ctx.transcript.sys(' /voice tts to toggle speech output') + ctx.transcript.sys(' /voice off to disable voice mode') + } else { + ctx.transcript.sys('Voice mode disabled.') + } + }) + ) + } + }, + + { + help: 'switch theme skin (fires skin.changed)', + name: 'skin', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'skin' }) + .then(ctx.guarded(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`))) + } + + ctx.gateway + .rpc('config.set', { key: 'skin', value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`skin → ${r.value}`))) + } + }, + + { + help: 'pick the busy indicator: kaomoji (default), emoji, unicode (braille), or ascii', + name: 'indicator', + usage: `/indicator [${INDICATOR_STYLES.join('|')}]`, + run: (arg, ctx) => { + const value = arg.trim().toLowerCase() + + if (!value) { + return ctx.gateway + .rpc('config.get', { key: 'indicator' }) + .then( + ctx.guarded(r => + ctx.transcript.sys(`indicator: ${r.value || DEFAULT_INDICATOR_STYLE}`) + ) + ) + } + + if (!(INDICATOR_STYLES as readonly string[]).includes(value)) { + return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`) + } + + ctx.gateway.rpc('config.set', { key: 'indicator', value }).then( + ctx.guarded(r => { + if (!r.value) { + return + } + + // Hot-swap the running TUI immediately so the next render + // uses the new style without waiting for the 5s mtime poll + // to re-apply config.full. + patchUiState({ indicatorStyle: value as IndicatorStyle }) + ctx.transcript.sys(`indicator → ${r.value}`) + }) + ) + } + }, + + { + help: 'toggle yolo mode (per-session approvals)', + name: 'yolo', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'yolo', session_id: ctx.sid }) + .then(ctx.guarded(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))) + } + }, + + { + help: 'inspect or set reasoning effort (updates live agent)', + name: 'reasoning', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'reasoning' }) + .then( + ctx.guarded( + r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + ) + ) + } + + ctx.gateway + .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }) + .then( + ctx.guarded(r => { + if (!r.value) { + return + } + + if (r.value === 'hide') { + patchUiState(state => ({ + ...state, + sections: { ...state.sections, thinking: 'hidden' }, + showReasoning: false + })) + } else if (r.value === 'show') { + patchUiState(state => ({ + ...state, + sections: { ...state.sections, thinking: 'expanded' }, + showReasoning: true + })) + } + + ctx.transcript.sys(`reasoning: ${r.value}`) + }) + ) + } + }, + + { + help: 'toggle fast mode [normal|fast|status|on|off|toggle]', + name: 'fast', + run: (arg, ctx) => { + const mode = arg.trim().toLowerCase() + const valid = new Set(['', 'status', 'normal', 'fast', 'on', 'off', 'toggle']) + + if (!valid.has(mode)) { + return ctx.transcript.sys('usage: /fast [normal|fast|status|on|off|toggle]') + } + + if (!mode || mode === 'status') { + return ctx.gateway + .rpc('config.get', { key: 'fast', session_id: ctx.sid }) + .then( + ctx.guarded(r => + ctx.transcript.sys(`fast mode: ${r.value === 'fast' ? 'fast' : 'normal'}`) + ) + ) + .catch(ctx.guardedErr) + } + + ctx.gateway + .rpc('config.set', { key: 'fast', session_id: ctx.sid, value: mode }) + .then( + ctx.guarded(r => { + const next = r.value === 'fast' ? 'fast' : 'normal' + ctx.transcript.sys(`fast mode: ${next}`) + patchUiState(state => ({ + ...state, + info: state.info + ? { + ...state.info, + fast: next === 'fast', + service_tier: next === 'fast' ? 'priority' : '' + } + : state.info + })) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'control busy enter mode [queue|steer|interrupt|status]', + name: 'busy', + run: (arg, ctx) => { + const mode = arg.trim().toLowerCase() + const valid = new Set(['', 'status', 'queue', 'steer', 'interrupt']) + + if (!valid.has(mode)) { + return ctx.transcript.sys('usage: /busy [queue|steer|interrupt|status]') + } + + if (!mode || mode === 'status') { + return ctx.gateway + .rpc('config.get', { key: 'busy' }) + .then( + ctx.guarded(r => { + const current = r.value || 'interrupt' + ctx.transcript.sys(`busy input mode: ${current}`) + }) + ) + .catch(ctx.guardedErr) + } + + ctx.gateway + .rpc('config.set', { key: 'busy', value: mode }) + .then( + ctx.guarded(r => { + const next = r.value || mode + ctx.transcript.sys(`busy input mode: ${next}`) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'cycle verbose tool-output mode (updates live agent)', + name: 'verbose', + run: (arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`))) + } + }, + + { + help: 'session usage (live counts — worker sees zeros)', + name: 'usage', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { + if (ctx.stale()) { + return + } + + if (r) { + patchUiState({ + usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } + }) + } + + if (!r?.calls) { + return ctx.transcript.sys('no API calls yet') + } + + const f = (v: number | undefined) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + if (cost) { + rows.push(['Cost', cost]) + } + + const sections: PanelSection[] = [{ rows }] + + if (r.context_max) { + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + } + + if (r.compressions) { + sections.push({ text: `Compressions: ${r.compressions}` }) + } + + ctx.transcript.panel('Usage', sections) + }) + } + } +] diff --git a/packages/cli/src/app/slash/commands/setup.ts b/packages/cli/src/app/slash/commands/setup.ts new file mode 100644 index 0000000..45dce45 --- /dev/null +++ b/packages/cli/src/app/slash/commands/setup.ts @@ -0,0 +1,20 @@ +import { withInkSuspended } from '@coder/tui' + +import { launchCoderCommand } from '../../../lib/externalCli.js' +import { runExternalSetup } from '../../setupHandoff.js' +import type { SlashCommand } from '../types.js' + +export const setupCommands: SlashCommand[] = [ + { + help: 'run full setup wizard (launches `coder setup`)', + name: 'setup', + run: (arg, ctx) => + void runExternalSetup({ + args: ['setup', ...arg.split(/\s+/).filter(Boolean)], + ctx, + done: 'setup complete — starting session…', + launcher: launchCoderCommand, + suspend: withInkSuspended + }) + } +] diff --git a/packages/cli/src/app/slash/registry.ts b/packages/cli/src/app/slash/registry.ts new file mode 100644 index 0000000..a8df669 --- /dev/null +++ b/packages/cli/src/app/slash/registry.ts @@ -0,0 +1,22 @@ +import { coreCommands } from './commands/core.js' +import { debugCommands } from './commands/debug.js' +import { initCommands } from './commands/init.js' +import { opsCommands } from './commands/ops.js' +import { sessionCommands } from './commands/session.js' +import { setupCommands } from './commands/setup.js' +import type { SlashCommand } from './types.js' + +export const SLASH_COMMANDS: SlashCommand[] = [ + ...coreCommands, + ...initCommands, + ...sessionCommands, + ...opsCommands, + ...setupCommands, + ...debugCommands +] + +const byName = new Map( + SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const)) +) + +export const findSlashCommand = (name: string) => byName.get(name.toLowerCase()) diff --git a/packages/cli/src/app/slash/types.ts b/packages/cli/src/app/slash/types.ts new file mode 100644 index 0000000..bbd187a --- /dev/null +++ b/packages/cli/src/app/slash/types.ts @@ -0,0 +1,21 @@ +import type { MutableRefObject } from 'react' + +import type { SlashHandlerContext, UiState } from '../interfaces.js' + +export interface SlashRunCtx extends SlashHandlerContext { + flight: number + guarded: (fn: (r: T) => void) => (r: null | T) => void + guardedErr: (e: unknown) => void + sid: null | string + slashFlightRef: MutableRefObject + stale: () => boolean + ui: UiState +} + +export interface SlashCommand { + aliases?: string[] + help?: string + name: string + run: (arg: string, ctx: SlashRunCtx, cmd: string) => void + usage?: string +} diff --git a/packages/cli/src/app/spawnHistoryStore.ts b/packages/cli/src/app/spawnHistoryStore.ts new file mode 100644 index 0000000..edcbaf9 --- /dev/null +++ b/packages/cli/src/app/spawnHistoryStore.ts @@ -0,0 +1,159 @@ +import { atom } from 'nanostores' + +import type { SpawnTreeLoadResponse } from '../gateway/types.js' +import type { SubagentProgress, SubagentStatus } from '../types.js' + +export interface SpawnSnapshot { + finishedAt: number + fromDisk?: boolean + id: string + label: string + path?: string + sessionId: null | string + startedAt: number + subagents: SubagentProgress[] +} + +export interface SpawnDiffPair { + baseline: SpawnSnapshot + candidate: SpawnSnapshot +} + +const HISTORY_LIMIT = 10 + +const KNOWN_SUBAGENT_STATUSES = new Set([ + 'completed', + 'error', + 'failed', + 'interrupted', + 'queued', + 'running', + 'timeout' +]) + +const normalizeSubagentStatus = (status: unknown, fallback: SubagentStatus): SubagentStatus => { + if (typeof status !== 'string') { + return fallback + } + + const normalized = status.toLowerCase() as SubagentStatus + + return KNOWN_SUBAGENT_STATUSES.has(normalized) ? normalized : fallback +} + +export const $spawnHistory = atom([]) +export const $spawnDiff = atom(null) + +export const getSpawnHistory = () => $spawnHistory.get() +export const getSpawnDiff = () => $spawnDiff.get() + +export const clearSpawnHistory = () => $spawnHistory.set([]) +export const clearDiffPair = () => $spawnDiff.set(null) +export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair) + +/** + * Commit a finished turn's spawn tree to history. Keeps the last 10 + * non-empty snapshots — empty turns (no subagents) are dropped. + * + * Why in-memory? The primary investigation loop is "I just ran a fan-out, + * it misbehaved, let me look at what happened" — same-session debugging. + * Disk persistence across process restarts is a natural extension but + * adds RPC surface for a less-common path. + */ +export const pushSnapshot = ( + subagents: readonly SubagentProgress[], + meta: { sessionId?: null | string; startedAt?: null | number } +) => { + if (!subagents.length) { + return + } + + const now = Date.now() + const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now)) + + const snap: SpawnSnapshot = { + finishedAt: now, + id: `snap-${now.toString(36)}`, + label: summarizeLabel(subagents), + sessionId: meta.sessionId ?? null, + startedAt: Number.isFinite(started) ? started : now, + subagents: subagents.map(item => ({ ...item })) + } + + const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT) + $spawnHistory.set(next) +} + +function summarizeLabel(subagents: readonly SubagentProgress[]): string { + const top = subagents + .filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId)) + .slice(0, 2) + .map(s => s.goal || 'subagent') + .join(' · ') + + return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}` +} + +/** + * Push a disk-loaded snapshot onto the front of the history stack so the + * overlay can pick it up at index 1 via /replay load. Normalises the + * server payload (arbitrary list) into the same SubagentProgress shape + * used for live data — defensive against cross-version reads. + */ +export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => { + const raw = Array.isArray(r.subagents) ? r.subagents : [] + const normalised = raw.map(normaliseSubagent) + + if (!normalised.length) { + return + } + + const snap: SpawnSnapshot = { + finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000, + fromDisk: true, + id: `disk-${path}`, + label: r.label || `${normalised.length} subagents`, + path, + sessionId: r.session_id ?? null, + startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000, + subagents: normalised + } + + const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT) + $spawnHistory.set(next) +} + +function normaliseSubagent(raw: unknown): SubagentProgress { + const o = raw as Record + const s = (v: unknown) => (typeof v === 'string' ? v : undefined) + const n = (v: unknown) => (typeof v === 'number' ? v : undefined) + const arr = (v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined) + + return { + apiCalls: n(o.apiCalls), + costUsd: n(o.costUsd), + depth: typeof o.depth === 'number' ? o.depth : 0, + durationSeconds: n(o.durationSeconds), + filesRead: arr(o.filesRead), + filesWritten: arr(o.filesWritten), + goal: s(o.goal) ?? 'subagent', + id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`, + index: typeof o.index === 'number' ? o.index : 0, + inputTokens: n(o.inputTokens), + iteration: n(o.iteration), + model: s(o.model), + notes: (arr(o.notes) ?? []).filter(x => typeof x === 'string'), + outputTail: arr(o.outputTail) as SubagentProgress['outputTail'], + outputTokens: n(o.outputTokens), + parentId: s(o.parentId) ?? null, + reasoningTokens: n(o.reasoningTokens), + startedAt: n(o.startedAt), + status: normalizeSubagentStatus(o.status, 'completed'), + summary: s(o.summary), + taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1, + thinking: (arr(o.thinking) ?? []).filter(x => typeof x === 'string'), + toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0, + tools: (arr(o.tools) ?? []).filter(x => typeof x === 'string'), + toolsets: arr(o.toolsets) + } +} diff --git a/packages/cli/src/app/turnController.ts b/packages/cli/src/app/turnController.ts new file mode 100644 index 0000000..2c9fb81 --- /dev/null +++ b/packages/cli/src/app/turnController.ts @@ -0,0 +1,918 @@ +import { + REASONING_PULSE_MS, + STREAM_BATCH_MS, + STREAM_IDLE_BATCH_MS, + STREAM_SCROLL_BATCH_MS, + STREAM_TYPING_BATCH_MS +} from '../config/timing.js' +import type { SessionInterruptResponse, SubagentEventPayload } from '../gateway/types.js' +import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js' +import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' +import { + boundedLiveRenderText, + buildToolTrailLine, + buildVerboseToolTrailLine, + estimateTokensRough, + isTransientTrailLine, + parseToolArgs, + sameToolTrailGroup, + toolTrailLabel +} from '../lib/text.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js' + +import { resetFlowOverlays } from './overlayStore.js' +import { pushSnapshot } from './spawnHistoryStore.js' +import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +const INTERRUPT_COOLDOWN_MS = 1500 +const ACTIVITY_LIMIT = 8 +const TRAIL_LIMIT = 8 + +// Extracts the raw patch from a diff-only segment produced by +// pushInlineDiffSegment. Used at message.complete to dedupe against final +// assistant text that narrates the same patch. Returns null for anything +// else so real assistant narration never gets touched. +const diffSegmentBody = (msg: Msg): null | string => { + if (msg.kind !== 'diff') { + return null + } + + const m = msg.text.match(/^```diff\n([\s\S]*?)\n```$/) + + return m ? m[1]! : null +} + +const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens) + +const isTodoStatus = (status: unknown): status is TodoItem['status'] => + status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled' + +const parseTodos = (value: unknown): null | TodoItem[] => { + if (!Array.isArray(value)) { + return null + } + + return value + .map(item => { + if (!item || typeof item !== 'object') { + return null + } + + const row = item as Record + const status = row.status + + if (!isTodoStatus(status)) { + return null + } + + return { + content: String(row.content ?? '').trim(), + id: String(row.id ?? '').trim(), + status + } + }) + .filter((item): item is TodoItem => Boolean(item?.id && item.content)) +} + +const textSegments = (segments: Msg[]) => + segments.filter(msg => msg.role === 'assistant' && msg.kind !== 'diff').map(msg => msg.text) + +const finalTail = (finalText: string, segments: Msg[]) => { + let tail = finalText + + for (const text of textSegments(segments)) { + const trimmed = text.trim() + + if (trimmed && tail.startsWith(trimmed)) { + tail = tail.slice(trimmed.length).trimStart() + } + } + + return tail +} + +export interface InterruptDeps { + appendMessage: (msg: Msg) => void + gw: { request: (method: string, params?: Record) => Promise } + sid: string + sys: (text: string) => void +} + +type Timer = null | ReturnType + +const clear = (t: Timer): null => { + if (t) { + clearTimeout(t) + } + + return null +} + +class TurnController { + bufRef = '' + /** Names of tools in the current turn batch that completed with an error. */ + failedToolNames = new Set() + interrupted = false + lastStatusNote = '' + persistedToolLabels = new Set() + persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise + protocolWarned = false + reasoningText = '' + segmentMessages: Msg[] = [] + pendingSegmentTools: string[] = [] + statusTimer: Timer = null + toolTokenAcc = 0 + turnTools: string[] = [] + + private activeTools: ActiveTool[] = [] + private activeReasoningText = '' + private reasoningSegmentIndex: null | number = null + private activityId = 0 + private reasoningStreamingTimer: Timer = null + private reasoningTimer: Timer = null + private streamTimer: Timer = null + private streamDelay = STREAM_IDLE_BATCH_MS + private toolProgressTimer: Timer = null + + boostStreamingForTyping() { + this.streamDelay = STREAM_TYPING_BATCH_MS + } + + boostStreamingForScroll() { + this.streamDelay = Math.max(this.streamDelay, STREAM_SCROLL_BATCH_MS) + } + + relaxStreaming() { + this.streamDelay = STREAM_IDLE_BATCH_MS + } + + clearReasoning() { + this.reasoningTimer = clear(this.reasoningTimer) + this.activeReasoningText = '' + this.reasoningSegmentIndex = null + this.reasoningText = '' + this.toolTokenAcc = 0 + patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 }) + } + + clearStatusTimer() { + this.statusTimer = clear(this.statusTimer) + } + + endReasoningPhase() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: false, reasoningStreaming: false }) + } + + idle() { + this.endReasoningPhase() + this.streamTimer = clear(this.streamTimer) + this.bufRef = '' + this.pendingSegmentTools = [] + this.segmentMessages = [] + + patchTurnState({ + streamPendingTools: [], + streamSegments: [], + streaming: '', + subagents: [], + tools: [], + turnTrail: [] + }) + patchUiState({ busy: false }) + resetFlowOverlays() + } + + interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) { + this.interrupted = true + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + + this.closeReasoningSegment() + + const segments = this.segmentMessages + const partial = this.bufRef.trimStart() + const tools = this.pendingSegmentTools + + // Drain streaming/segment state off the nanostore before writing the + // preserved snapshot to the transcript — otherwise each flushed segment + // appears in both `turn.streamSegments` and the transcript for one frame. + this.idle() + this.clearReasoning() + this.turnTools = [] + patchTurnState({ activity: [], outcome: '' }) + + for (const msg of segments) { + appendMessage(msg) + } + + // Always surface an interruption indicator — if there's an in-flight + // `partial` or pending tools, fold them into a single assistant message; + // otherwise emit a sys note so the transcript always records that the + // turn was cancelled, even when only prior `segments` were preserved. + if (partial || tools.length) { + appendMessage({ + role: 'assistant', + text: partial ? `${partial}\n\n*[interrupted]*` : '*[interrupted]*', + ...(tools.length && { tools }) + }) + } else { + sys('interrupted') + } + + patchUiState({ status: 'interrupted' }) + this.clearStatusTimer() + + this.statusTimer = setTimeout(() => { + this.statusTimer = null + patchUiState({ status: 'ready' }) + }, INTERRUPT_COOLDOWN_MS) + } + + pruneTransient() { + this.turnTools = this.turnTools.filter(line => !isTransientTrailLine(line)) + patchTurnState(state => { + const next = state.turnTrail.filter(line => !isTransientTrailLine(line)) + + return next.length === state.turnTrail.length ? state : { ...state, turnTrail: next } + }) + } + + private syncReasoningSegment() { + const thinking = this.activeReasoningText.trim() + + if (!thinking) { + return + } + + const msg: Msg = { + kind: 'trail', + role: 'system', + text: '', + thinking, + thinkingTokens: estimateTokensRough(thinking), + toolTokens: this.toolTokenAcc || undefined + } + + if (this.reasoningSegmentIndex === null) { + this.reasoningSegmentIndex = this.segmentMessages.length + this.segmentMessages = [...this.segmentMessages, msg] + } else { + this.segmentMessages = this.segmentMessages.map((item, i) => (i === this.reasoningSegmentIndex ? msg : item)) + } + + patchTurnState({ streamSegments: this.segmentMessages }) + } + + private closeReasoningSegment() { + this.syncReasoningSegment() + this.activeReasoningText = '' + this.reasoningSegmentIndex = null + } + + private pushSegment(msg: Msg) { + this.segmentMessages = appendToolShelfMessage(this.segmentMessages, msg) + } + + flushStreamingSegment() { + const raw = this.bufRef.trimStart() + + const split = raw + ? hasReasoningTag(raw) + ? splitReasoning(raw) + : { reasoning: '', text: raw } + : { reasoning: '', text: '' } + + if (split.reasoning && !this.reasoningText.trim()) { + this.reasoningText = split.reasoning + this.activeReasoningText = split.reasoning + patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) }) + this.syncReasoningSegment() + } + + const msg: Msg = { + role: split.text ? 'assistant' : 'system', + text: split.text, + ...(!split.text && { kind: 'trail' as const }), + ...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools }) + } + + this.streamTimer = clear(this.streamTimer) + + if (split.text || hasDetails(msg)) { + this.pushSegment(msg) + } + + this.pendingSegmentTools = [] + this.bufRef = '' + patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) + } + + pulseReasoningStreaming() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: true, reasoningStreaming: true }) + + this.reasoningStreamingTimer = setTimeout(() => { + this.reasoningStreamingTimer = null + patchTurnState({ reasoningStreaming: false }) + }, REASONING_PULSE_MS) + } + + recordTodos(value: unknown) { + if (this.interrupted) { + return + } + + const todos = parseTodos(value) + + if (todos !== null) { + patchTurnState({ todos }) + } + } + + private flushPendingToolsIntoLastSegment() { + if (!this.pendingSegmentTools.length) { + return false + } + + const next = appendToolShelfMessage(this.segmentMessages, { + kind: 'trail', + role: 'system', + text: '', + tools: this.pendingSegmentTools + }) + + if (next.length === this.segmentMessages.length + 1) { + return false + } + + this.segmentMessages = next + this.pendingSegmentTools = [] + patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages }) + + return true + } + + pushInlineDiffSegment(diffText: string, tools: string[] = []) { + // Strip CLI chrome the gateway emits before the unified diff (e.g. a + // leading "┊ review diff" header written by `_emit_inline_diff` for the + // terminal printer). That header only makes sense as stdout dressing, + // not inside a markdown ```diff block. + const stripped = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim() + + if (!stripped) { + return + } + + // Flush any in-progress streaming text as its own segment first, so the + // diff lands BETWEEN the assistant narration that preceded the edit and + // whatever the agent streams afterwards — not glued onto the final + // message. This is the whole point of segment-anchored diffs: the diff + // renders where the edit actually happened. + this.flushStreamingSegment() + + const block = `\`\`\`diff\n${stripped}\n\`\`\`` + + // Skip consecutive duplicates (same tool firing tool.complete twice, or + // two edits producing the same patch). Keeping this cheap — deeper + // dedupe against the final assistant text happens at message.complete. + if (this.segmentMessages.at(-1)?.text === block) { + return + } + + this.segmentMessages = [ + ...this.segmentMessages, + { kind: 'diff', role: 'assistant', text: block, ...(tools.length && { tools }) } + ] + patchTurnState({ streamSegments: this.segmentMessages }) + } + + pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { + patchTurnState(state => { + const base = replaceLabel + ? state.activity.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) + : state.activity + + const tail = base.at(-1) + + if (tail?.text === text && tail.tone === tone) { + return state + } + + return { ...state, activity: [...base, { id: ++this.activityId, text, tone }].slice(-ACTIVITY_LIMIT) } + }) + } + + pushTrail(line: string) { + if (this.interrupted) { + return + } + + patchTurnState(state => { + if (state.turnTrail.at(-1) === line) { + return state + } + + const next = [...state.turnTrail.filter(item => !isTransientTrailLine(item)), line].slice(-TRAIL_LIMIT) + + this.turnTools = next + + return { ...state, turnTrail: next } + }) + } + + recordError() { + this.idle() + this.clearReasoning() + this.clearStatusTimer() + this.pendingSegmentTools = [] + this.segmentMessages = [] + this.turnTools = [] + this.persistedToolLabels.clear() + } + + recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { + this.closeReasoningSegment() + + // Ink renders markdown via ; the gateway's Rich-rendered ANSI + // (`payload.rendered`) is for terminals that can't. Prioritising + // `rendered` here garbles output whenever a user opts into + // `display.final_response_markdown: render` because raw ANSI escapes + // pass through into the React tree. Prefer raw text and fall back + // only when the gateway elected not to send any (#16391). + const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart() + const split = splitReasoning(rawText) + const finalText = finalTail(split.text, this.segmentMessages) + const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') + const savedToolTokens = this.toolTokenAcc + let tools = this.pendingSegmentTools + const last = this.segmentMessages[this.segmentMessages.length - 1] + + if (tools.length && isToolShelfMessage(last)) { + this.segmentMessages = [ + ...this.segmentMessages.slice(0, -1), + { ...last, tools: [...(last.tools ?? []), ...tools] } + ] + this.pendingSegmentTools = [] + tools = [] + } + + // Drop diff-only segments the agent is about to narrate in the final + // reply. Without this, a closing "here's the diff …" message would + // render two stacked copies of the same patch. Only touches segments + // with `kind: 'diff'` emitted by pushInlineDiffSegment — real + // assistant narration stays put. + const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText) + + const segments = this.segmentMessages.filter(msg => { + const body = diffSegmentBody(msg) + + return body === null || (!finalHasOwnDiffFence && !finalText.includes(body)) + }) + + const hasReasoningSegment = + this.reasoningSegmentIndex !== null || segments.some(msg => Boolean(msg.thinking?.trim())) + + const finalThinking = hasReasoningSegment ? '' : savedReasoning.trim() + + const finalDetails: Msg = { + kind: 'trail', + role: 'system', + text: '', + thinking: finalThinking || undefined, + thinkingTokens: finalThinking ? estimateTokensRough(finalThinking) : undefined, + toolTokens: savedToolTokens || undefined, + ...(tools.length && { tools }) + } + + // Archive prepended so the trail msg anchors under the user prompt, + // not between thinking/tools and final assistant text. + const finalMessages: Msg[] = [ + ...archiveDoneTodos(), + ...segments, + ...(hasDetails(finalDetails) ? [finalDetails] : []) + ] + + if (finalText) { + finalMessages.push({ role: 'assistant', text: finalText }) + } + + const wasInterrupted = this.interrupted + + // Archive the turn's spawn tree to history BEFORE idle() drops subagents + // from turnState. Lets /replay and the overlay's history nav pull up + // finished fan-outs without a round-trip to disk. + const finishedSubagents = getTurnState().subagents + const sessionId = getUiState().sid + + if (finishedSubagents.length > 0) { + pushSnapshot(finishedSubagents, { sessionId, startedAt: null }) + // Fire-and-forget disk persistence so /replay survives process restarts. + // The same snapshot lives in memory via spawnHistoryStore for immediate + // recall — disk is the long-term archive. + void this.persistSpawnTree?.(finishedSubagents, sessionId) + } + + this.idle() + this.clearReasoning() + this.turnTools = [] + this.persistedToolLabels.clear() + this.bufRef = '' + this.interrupted = false + patchTurnState({ activity: [], outcome: '' }) + + return { finalMessages, finalText, wasInterrupted } + } + + recordMessageDelta({ text }: { rendered?: string; text?: string }) { + if (this.interrupted || !text) { + return + } + + this.pruneTransient() + this.endReasoningPhase() + + // Always accumulate the raw text delta. The pre-#16391 path replaced + // the entire buffer with `rendered` (an *incremental* Rich ANSI + // fragment), which on every tick discarded everything streamed so far + // — visible as overlapping coloured text and lost prose under + // `display.final_response_markdown: render`. + this.bufRef += text + + if (getUiState().streaming) { + this.scheduleStreaming() + } + } + + recordReasoningAvailable(text: string, force = false) { + if (this.interrupted || (!force && !getUiState().showReasoning)) { + return + } + + const incoming = text.trim() + + if (!incoming || this.reasoningText.trim()) { + return + } + + this.reasoningText = incoming + this.activeReasoningText = incoming + this.scheduleReasoning() + this.syncReasoningSegment() + this.pulseReasoningStreaming() + } + + recordReasoningDelta(text: string, force = false) { + if (this.interrupted || (!force && !getUiState().showReasoning)) { + return + } + + if (!this.activeReasoningText.trim() && this.pendingSegmentTools.length) { + this.flushStreamingSegment() + } + + this.reasoningText += text + this.activeReasoningText += text + + if (this.reasoningText.length > 80_000) { + this.reasoningText = this.reasoningText.slice(-60_000) + } + + this.scheduleReasoning() + this.syncReasoningSegment() + this.pulseReasoningStreaming() + } + + recordToolComplete( + toolId: string, + fallbackName?: string, + error?: string, + summary?: string, + duration?: number, + todos?: unknown, + resultText?: string + ) { + if (this.interrupted) { + // Even when interrupted, record the tool error so the UI can show + // what went wrong. Silently returning here would drop the error + // and the user would never know a tool failed. + if (error) { + this.failedToolNames.add(fallbackName ?? 'tool') + this.pushActivity(`Tool failed: ${error}`, 'error') + } + return + } + + this.recordTodos(todos) + const line = this.completeTool(toolId, fallbackName, error, summary, duration, resultText) + + this.pendingSegmentTools = [...this.pendingSegmentTools, line] + this.flushPendingToolsIntoLastSegment() + this.publishToolState() + } + + recordInlineDiffToolComplete( + diffText: string, + toolId: string, + fallbackName?: string, + error?: string, + duration?: number, + resultText?: string + ) { + if (this.interrupted) { + return + } + + this.flushStreamingSegment() + this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration, resultText)]) + this.publishToolState() + } + + private completeTool( + toolId: string, + fallbackName?: string, + error?: string, + summary?: string, + duration?: number, + resultText?: string + ) { + const done = this.activeTools.find(tool => tool.id === toolId) + const name = done?.name ?? fallbackName ?? 'tool' + const label = toolTrailLabel(name) + const fallbackDuration = done?.startedAt ? (Date.now() - done.startedAt) / 1000 : undefined + + const line = + done?.verboseArgs || resultText + ? buildVerboseToolTrailLine( + name, + done?.context || '', + Boolean(error), + duration ?? fallbackDuration, + done?.verboseArgs, + error || resultText || summary || '', + parseToolArgs(done?.verboseArgs ?? '')?.description, + ) + : buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '', duration ?? fallbackDuration) + + this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) + + // Track error state for the turn trail. When the last tool in the + // batch finishes we use this flag to pick a status message that tells + // the user whether something went wrong (instead of always showing + // the misleading "analyzing tool output…"). + if (error) { + this.failedToolNames.add(name) + } + + const next = this.turnTools.filter(item => !sameToolTrailGroup(label, item)) + + if (!this.activeTools.length) { + if (this.failedToolNames.size > 0) { + const failed = [...this.failedToolNames].join(', ') + next.push(`Tool "${failed}" failed`) + this.failedToolNames.clear() + } else { + next.push('analyzing tool output…') + } + } + + this.turnTools = next.slice(-TRAIL_LIMIT) + + return line + } + + private publishToolState() { + patchTurnState({ + streamPendingTools: this.pendingSegmentTools, + tools: this.activeTools, + turnTrail: this.turnTools + }) + } + + recordToolProgress(toolName: string, preview: string) { + if (this.interrupted) { + return + } + + const index = this.activeTools.findIndex(tool => tool.name === toolName) + + if (index < 0) { + return + } + + this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool)) + + if (this.toolProgressTimer) { + return + } + + this.toolProgressTimer = setTimeout(() => { + this.toolProgressTimer = null + patchTurnState({ tools: [...this.activeTools] }) + }, STREAM_BATCH_MS) + } + + recordToolStart(toolId: string, name: string, context: string, verboseArgs?: string) { + if (this.interrupted) { + return + } + + this.flushStreamingSegment() + this.closeReasoningSegment() + this.pruneTransient() + this.endReasoningPhase() + + const sample = `${name} ${context}`.trim() + + this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0 + this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now(), verboseArgs }] + + patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools }) + } + + recordToolInputDelta(toolId: string, partialJson: string) { + if (this.interrupted) { + return + } + + this.activeTools = this.activeTools.map(tool => { + if (tool.id !== toolId) return tool + // The initial verboseArgs from tool.start is "{}" (empty input). + // Reset it so the accumulated deltas form valid JSON. + const base = tool.verboseArgs === '{}' ? '' : (tool.verboseArgs ?? '') + const verboseArgs = base + partialJson + return { ...tool, verboseArgs, context: verboseArgs } + }) + + patchTurnState({ tools: this.activeTools }) + } + + updateToolContext(toolId: string, command: string) { + this.activeTools = this.activeTools.map(tool => + tool.id === toolId + ? { ...tool, context: command } + : tool + ) + + patchTurnState({ tools: this.activeTools }) + } + + reset() { + this.clearReasoning() + this.clearStatusTimer() + this.idle() + this.bufRef = '' + this.interrupted = false + this.lastStatusNote = '' + this.activeReasoningText = '' + this.pendingSegmentTools = [] + this.protocolWarned = false + this.reasoningSegmentIndex = null + this.segmentMessages = [] + this.turnTools = [] + this.toolTokenAcc = 0 + this.persistedToolLabels.clear() + patchTurnState({ activity: [], outcome: '' }) + } + + fullReset() { + this.reset() + resetTurnState() + } + + scheduleReasoning() { + if (this.reasoningTimer) { + return + } + + this.reasoningTimer = setTimeout(() => { + this.reasoningTimer = null + patchTurnState({ + reasoning: this.reasoningText, + reasoningTokens: estimateTokensRough(this.reasoningText) + }) + }, STREAM_BATCH_MS) + } + + scheduleStreaming() { + if (this.streamTimer) { + return + } + + this.streamTimer = setTimeout(() => { + this.streamTimer = null + const raw = this.bufRef.trimStart() + const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw + patchTurnState({ streaming: boundedLiveRenderText(visible) }) + }, this.streamDelay) + } + + hydrateStreamingText(text: string) { + this.streamTimer = clear(this.streamTimer) + this.bufRef = text + const raw = this.bufRef.trimStart() + const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw + patchTurnState({ streaming: boundedLiveRenderText(visible) }) + } + + startMessage() { + this.endReasoningPhase() + this.clearReasoning() + this.activeTools = [] + this.activeReasoningText = '' + this.reasoningSegmentIndex = null + this.turnTools = [] + this.toolTokenAcc = 0 + this.interrupted = false + this.failedToolNames.clear() + this.persistedToolLabels.clear() + patchUiState({ busy: true }) + patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) + } + + upsertSubagent( + p: SubagentEventPayload, + patch: (current: SubagentProgress) => Partial, + opts: { createIfMissing?: boolean } = { createIfMissing: true } + ) { + // Stable id: prefer the server-issued subagent_id (survives nested + // grandchildren + cross-tree joins). Fall back to the composite key + // for older gateways that omit the field — those produce a flat list. + const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}` + + patchTurnState(state => { + const existing = state.subagents.find(item => item.id === id) + + // Late events (subagent.complete/tool/progress arriving after message.complete + // has already fired idle()) would otherwise resurrect a finished + // subagent into turn.subagents and block the "finished" title on the + // /agents overlay. When `createIfMissing` is false we drop silently. + if (!existing && !opts.createIfMissing) { + return state + } + + const base: SubagentProgress = existing ?? { + depth: p.depth ?? 0, + goal: p.goal, + id, + index: p.task_index, + model: p.model, + notes: [], + parentId: p.parent_id ?? null, + startedAt: Date.now(), + status: 'running', + taskCount: p.task_count ?? 1, + thinking: [], + toolCount: p.tool_count ?? 0, + tools: [], + toolsets: p.toolsets + } + + // Map snake_case payload keys onto camelCase state. Only overwrite + // when the event actually carries the field; `??` preserves prior + // values across streaming events that emit partial payloads. + const outputTail = p.output_tail + ? p.output_tail.map(e => ({ + isError: Boolean(e.is_error), + preview: String(e.preview ?? ''), + tool: String(e.tool ?? 'tool') + })) + : base.outputTail + + const next: SubagentProgress = { + ...base, + apiCalls: p.api_calls ?? base.apiCalls, + costUsd: p.cost_usd ?? base.costUsd, + depth: p.depth ?? base.depth, + filesRead: p.files_read ?? base.filesRead, + filesWritten: p.files_written ?? base.filesWritten, + goal: p.goal || base.goal, + inputTokens: p.input_tokens ?? base.inputTokens, + iteration: p.iteration ?? base.iteration, + model: p.model ?? base.model, + outputTail, + outputTokens: p.output_tokens ?? base.outputTokens, + parentId: p.parent_id ?? base.parentId, + reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens, + taskCount: p.task_count ?? base.taskCount, + toolCount: p.tool_count ?? base.toolCount, + toolsets: p.toolsets ?? base.toolsets, + ...patch(base) + } + + // Stable order: by spawn (depth, parent, index) rather than insert time. + // Without it, grandchildren can shuffle relative to siblings when + // events arrive out of order under high concurrency. + const subagents = existing + ? state.subagents.map(item => (item.id === id ? next : item)) + : [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index) + + return { ...state, subagents } + }) + } +} + +export const turnController = new TurnController() + +export type { TurnController } diff --git a/packages/cli/src/app/turnStore.ts b/packages/cli/src/app/turnStore.ts new file mode 100644 index 0000000..54823d1 --- /dev/null +++ b/packages/cli/src/app/turnStore.ts @@ -0,0 +1,85 @@ +import { atom } from 'nanostores' +import { useSyncExternalStore } from 'react' + +import { isTodoDone } from '../lib/liveProgress.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js' + +const buildTurnState = (): TurnState => ({ + activity: [], + outcome: '', + reasoning: '', + reasoningActive: false, + reasoningStreaming: false, + reasoningTokens: 0, + streamPendingTools: [], + streamSegments: [], + streaming: '', + subagents: [], + todoCollapsed: false, + todos: [], + toolTokens: 0, + tools: [], + turnTrail: [] +}) + +export const $turnState = atom(buildTurnState()) + +export const getTurnState = () => $turnState.get() + +const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb()) + +export const useTurnSelector = (selector: (state: TurnState) => T): T => + useSyncExternalStore( + subscribeTurn, + () => selector($turnState.get()), + () => selector($turnState.get()) + ) + +export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => + $turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next }) + +export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed })) + +export const archiveDoneTodos = () => archiveTodosAtTurnEnd() + +export const archiveTodosAtTurnEnd = () => { + const state = $turnState.get() + + if (!state.todos.length) { + return [] + } + + const done = isTodoDone(state.todos) + + const msg: Msg = { + kind: 'trail', + role: 'system', + text: '', + todos: state.todos, + ...(done ? { todoCollapsedByDefault: true } : { todoIncomplete: true }) + } + + patchTurnState({ todoCollapsed: false, todos: [] }) + + return [msg] +} + +export const resetTurnState = () => $turnState.set(buildTurnState()) + +export interface TurnState { + activity: ActivityItem[] + outcome: string + reasoning: string + reasoningActive: boolean + reasoningStreaming: boolean + reasoningTokens: number + streamPendingTools: string[] + streamSegments: Msg[] + streaming: string + subagents: SubagentProgress[] + todoCollapsed: boolean + todos: TodoItem[] + toolTokens: number + tools: ActiveTool[] + turnTrail: string[] +} diff --git a/packages/cli/src/app/uiStore.ts b/packages/cli/src/app/uiStore.ts new file mode 100644 index 0000000..04b6ddd --- /dev/null +++ b/packages/cli/src/app/uiStore.ts @@ -0,0 +1,47 @@ +import { atom, computed } from 'nanostores' + +import { MOUSE_TRACKING } from '../config/env.js' +import { ZERO } from '../domain/usage.js' +import { DEFAULT_THEME } from '../theme.js' + +import { DEFAULT_INDICATOR_STYLE, type UiState } from './interfaces.js' + +const buildUiState = (): UiState => ({ + bgTasks: new Set(), + busy: false, + busyInputMode: 'queue', + compact: false, + detailsMode: 'collapsed', + detailsModeCommandOverride: false, + indicatorStyle: DEFAULT_INDICATOR_STYLE, + info: null, + liveSessionCount: 0, + inlineDiffs: true, + mouseTracking: MOUSE_TRACKING, + pasteCollapseLines: 5, + pasteCollapseChars: 2000, + sections: {}, + showCost: false, + showReasoning: false, + sid: null, + slashCommandOpen: false, + slashCommandFilter: '', + slashCommandSelectedIndex: 0, + status: 'summoning Coder…', + statusBar: 'top', + streaming: true, + theme: DEFAULT_THEME, + usage: ZERO +}) + +export const $uiState = atom(buildUiState()) + +export const $uiTheme = computed($uiState, state => state.theme) +export const $uiSessionId = computed($uiState, state => state.sid) + +export const getUiState = () => $uiState.get() + +export const patchUiState = (next: Partial | ((state: UiState) => UiState)) => + $uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next }) + +export const resetUiState = () => $uiState.set(buildUiState()) diff --git a/packages/cli/src/app/useComposerState.ts b/packages/cli/src/app/useComposerState.ts new file mode 100644 index 0000000..8b1c751 --- /dev/null +++ b/packages/cli/src/app/useComposerState.ts @@ -0,0 +1,367 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { useStdin, withInkSuspended } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { useCallback, useMemo, useState } from 'react' + +import type { PasteEvent } from '../components/textInput.js' +import type { ImageAttachResponse, InputDetectDropResponse } from '../gateway/types.js' +import { useCompletion } from '../hooks/useCompletion.js' +import { useInputHistory } from '../hooks/useInputHistory.js' +import { useQueue } from '../hooks/useQueue.js' +import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' +import { resolveEditor } from '../lib/editor.js' +import { readOsc52Clipboard } from '../lib/osc52.js' +import { isRemoteShellSession } from '../lib/terminalSetup.js' +import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' + +import type { MaybePromise, PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' +import { $isBlocked } from './overlayStore.js' +import { getUiState } from './uiStore.js' + +const PASTE_SNIP_MAX_COUNT = 32 +const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 + +const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { + let total = 0 + const out: PasteSnippet[] = [] + + for (let i = snips.length - 1; i >= 0; i--) { + const snip = snips[i]! + const size = snip.text.length + + if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) { + break + } + + total += size + out.unshift(snip) + } + + return out.length === snips.length ? snips : out +} + +/** Insert text at the cursor position, adding spacing to separate from adjacent non-whitespace. */ +function insertAtCursor(value: string, cursor: number, text: string): { cursor: number; value: string } { + const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' + const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' + const insert = `${lead}${text}${tail}` + + return { + cursor: cursor + insert.length, + value: value.slice(0, cursor) + insert + value.slice(cursor) + } +} + +/** + * Quick client-side heuristic to detect text that looks like a dropped file path. + * When this returns true the composer sends RPC calls to the server for actual + * validation. Keep in sync with _detect_file_drop() in cli.py — see that + * function for the canonical prefix list. + */ +export function looksLikeDroppedPath(text: string): boolean { + const trimmed = text.trim() + + if (!trimmed || trimmed.includes('\n')) { + return false + } + + // file:// URIs, relative, home-relative, quoted, and Windows drive paths + if ( + trimmed.startsWith('file://') || + trimmed.startsWith('~/') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('"/') || + trimmed.startsWith("'/") || + trimmed.startsWith('"~') || + trimmed.startsWith("'~") || + /^[A-Za-z]:[/\\]/.test(trimmed) || + /^["'][A-Za-z]:[/\\]/.test(trimmed) + ) { + return true + } + + // Bare absolute paths (start with /) — require a second '/' or a '.' to avoid + // false positives on short strings like "/api" or "/help" which would trigger + // unnecessary RPC round-trips. + if (trimmed.startsWith('/')) { + const rest = trimmed.slice(1) + + return rest.includes('/') || rest.includes('.') + } + + return false +} + +export function useComposerState({ + gw, + onClipboardPaste, + onImageAttached, + submitRef +}: UseComposerStateOptions): UseComposerStateResult { + const [input, setInput] = useState('') + const [inputBuf, setInputBuf] = useState([]) + const [pasteSnips, setPasteSnips] = useState([]) + const isBlocked = useStore($isBlocked) + const { querier } = useStdin() as unknown as { querier: Parameters[0] } + + const { + queueRef, + queueEditRef, + queuedDisplay, + queueEditIdx, + enqueue, + dequeue, + removeQ, + replaceQ, + setQueueEdit, + syncQueue + } = useQueue() + + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() + const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) + + const clearIn = useCallback(() => { + setInput('') + setInputBuf([]) + setPasteSnips([]) + setQueueEdit(null) + setHistoryIdx(null) + historyDraftRef.current = '' + }, [historyDraftRef, setQueueEdit, setHistoryIdx]) + + const handleResolvedPaste = useCallback( + async ({ + bracketed, + cursor, + text, + value + }: Omit): Promise => { + const cleanedText = stripTrailingPasteNewlines(text) + + if (!cleanedText || !/[^\n]/.test(cleanedText)) { + if (bracketed) { + void onClipboardPaste(true) + } + + return null + } + + const sid = getUiState().sid + + if (sid && looksLikeDroppedPath(cleanedText)) { + try { + const attached = await gw.request('image.attach', { + path: cleanedText, + session_id: sid + }) + + if (attached?.name) { + onImageAttached?.(attached) + const remainder = attached.remainder?.trim() ?? '' + + if (!remainder) { + return { cursor, value } + } + + return insertAtCursor(value, cursor, remainder) + } + } catch { + // Fall back to generic file-drop detection below. + } + + try { + const dropped = await gw.request('input.detect_drop', { + session_id: sid, + text: cleanedText + }) + + if (dropped?.matched && dropped.text) { + return insertAtCursor(value, cursor, dropped.text) + } + } catch { + // Fall through to normal text paste behavior. + } + } + + const lineCount = cleanedText.split('\n').length + const pasteCollapseLines = getUiState().pasteCollapseLines + const pasteCollapseChars = getUiState().pasteCollapseChars + const linesHit = pasteCollapseLines > 0 && lineCount >= pasteCollapseLines + const charsHit = pasteCollapseChars > 0 && cleanedText.length >= pasteCollapseChars + + if (!linesHit && !charsHit) { + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + } + } + + const label = pasteTokenLabel(cleanedText, lineCount) + const inserted = insertAtCursor(value, cursor, label) + + setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }])) + + void gw + .request<{ path?: string }>('paste.collapse', { text: cleanedText }) + .then(r => { + const path = r?.path + + if (!path) { + return + } + + setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s))) + }) + .catch(() => {}) + + return inserted + }, + [gw, onClipboardPaste, onImageAttached] + ) + + const handleTextPaste = useCallback( + ({ + bracketed, + cursor, + hotkey, + text, + value + }: PasteEvent): MaybePromise => { + if (hotkey) { + const preferOsc52 = isRemoteShellSession(process.env) + + const readPreferredText = preferOsc52 + ? readOsc52Clipboard(querier).then(async osc52Text => { + if (isUsableClipboardText(osc52Text)) { + return osc52Text + } + + return readClipboardText() + }) + : readClipboardText().then(async clipText => { + if (isUsableClipboardText(clipText)) { + return clipText + } + + return readOsc52Clipboard(querier) + }) + + return readPreferredText.then(async preferredText => { + if (isUsableClipboardText(preferredText)) { + return handleResolvedPaste({ bracketed: false, cursor, text: preferredText, value }) + } + + void onClipboardPaste(false) + + return null + }) + } + + return handleResolvedPaste({ bracketed: !!bracketed, cursor, text, value }) + }, + [handleResolvedPaste, onClipboardPaste, querier] + ) + + const openEditor = useCallback(async () => { + const dir = mkdtempSync(join(tmpdir(), 'coder-')) + const file = join(dir, 'prompt.md') + const [cmd, ...args] = resolveEditor() + + writeFileSync(file, [...inputBuf, input].join('\n')) + + let exitCode: null | number = null + + await withInkSuspended(async () => { + exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status + }) + + try { + if (exitCode !== 0) { + return + } + + const text = readFileSync(file, 'utf8').trimEnd() + + if (!text) { + return + } + + setInput('') + setInputBuf([]) + submitRef.current(text) + } finally { + rmSync(dir, { force: true, recursive: true }) + } + }, [input, inputBuf, submitRef]) + + const actions = useMemo( + () => ({ + clearIn, + dequeue, + enqueue, + handleTextPaste, + openEditor, + pushHistory, + removeQueue: removeQ, + replaceQueue: replaceQ, + setCompIdx, + setHistoryIdx, + setInput, + setInputBuf, + setPasteSnips, + setQueueEdit, + syncQueue + }), + [ + clearIn, + dequeue, + enqueue, + handleTextPaste, + openEditor, + pushHistory, + removeQ, + replaceQ, + setCompIdx, + setHistoryIdx, + setQueueEdit, + syncQueue + ] + ) + + const refs = useMemo( + () => ({ + historyDraftRef, + historyRef, + queueEditRef, + queueRef, + submitRef + }), + [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] + ) + + const state = useMemo( + () => ({ + compIdx, + compReplace, + completions, + historyIdx, + input, + inputBuf, + pasteSnips, + queueEditIdx, + queuedDisplay + }), + [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] + ) + + return { + actions, + refs, + state + } +} diff --git a/packages/cli/src/app/useConfigSync.ts b/packages/cli/src/app/useConfigSync.ts new file mode 100644 index 0000000..f358f7f --- /dev/null +++ b/packages/cli/src/app/useConfigSync.ts @@ -0,0 +1,288 @@ +import type { MouseTrackingMode } from '@coder/tui' +import { useEffect, useRef } from 'react' + +import { resolveDetailsMode, resolveSections } from '../domain/details.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { + ConfigFullResponse, + ConfigMtimeResponse, + ReloadMcpResponse +} from '../gateway/types.js' +import { + DEFAULT_VOICE_RECORD_KEY, + type ParsedVoiceRecordKey, + parseVoiceRecordKey +} from '../lib/platform.js' +import { asRpcResult } from '../lib/rpc.js' + +import { + type BusyInputMode, + DEFAULT_INDICATOR_STYLE, + INDICATOR_STYLES, + type IndicatorStyle, + type StatusBarMode +} from './interfaces.js' +import { turnController } from './turnController.js' +import { patchUiState } from './uiStore.js' + +const STATUSBAR_ALIAS: Record = { + bottom: 'bottom', + off: 'off', + on: 'top', + top: 'top' +} + +export const normalizeStatusBar = (raw: unknown): StatusBarMode => + raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top' + +const BUSY_MODES = new Set(['interrupt', 'queue', 'steer']) + +// TUI defaults to `queue` even though the framework default +// (`coder_cli/config.py`) is `interrupt`. Rationale: in a full-screen +// TUI you're typically authoring the next prompt while the agent is +// still streaming, and an unintended interrupt loses work. Set +// `display.busy_input_mode: interrupt` (or `steer`) explicitly to +// opt out per-config; CLI / messaging adapters keep their `interrupt` +// default unchanged. +const TUI_BUSY_DEFAULT: BusyInputMode = 'queue' + +export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => { + if (typeof raw !== 'string') { + return TUI_BUSY_DEFAULT + } + + const v = raw.trim().toLowerCase() as BusyInputMode + + return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT +} + +const INDICATOR_STYLE_SET: ReadonlySet = new Set(INDICATOR_STYLES) + +export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => { + if (typeof raw !== 'string') { + return DEFAULT_INDICATOR_STYLE + } + + const v = raw.trim().toLowerCase() as IndicatorStyle + + return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE +} + +const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off']) +const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any']) +const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key) + +// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off) +// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases: +// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`). +// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover, +// which silences tmux's "No image in clipboard" spam over the prompt row. +// `buttons` adds 1002 so terminal-side text selection drags still register. +// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent. +export const normalizeMouseTracking = (display: { + mouse_tracking?: unknown + tui_mouse?: unknown +}): MouseTrackingMode => { + const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse + + if (raw === false || raw === 0) { + return 'off' + } + + if (raw === true || raw === undefined || raw === null) { + return 'all' + } + + if (typeof raw === 'number') { + return 'all' + } + + if (typeof raw !== 'string') { + return 'all' + } + + const v = raw.trim().toLowerCase() + + if (FALSEY_MOUSE.has(v)) { + return 'off' + } + + if (TRUTHY_MOUSE_ALL.has(v)) { + return 'all' + } + + if (v === 'wheel' || v === 'scroll') { + return 'wheel' + } + + if (v === 'buttons' || v === 'button' || v === 'click') { + return 'buttons' + } + + return 'all' +} + +const MTIME_POLL_MS = 5000 + +const quietRpc = async = Record>( + gw: IGatewayClient, + method: string, + params: Record = {} +): Promise => { + try { + return asRpcResult(await gw.request(method, params)) + } catch { + return null + } +} + +const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => { + const raw = cfg?.config?.voice?.record_key + + return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY +} + +const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => { + if (!cfg?.config) return 5 + const raw = cfg.config.paste_collapse_threshold + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw) + if (typeof raw === 'string') { + const n = parseInt(raw, 10) + if (Number.isFinite(n) && n >= 0) return n + } + return 5 +} + +const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => { + if (!cfg?.config) return 2000 + const raw = cfg.config.paste_collapse_char_threshold + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw) + if (typeof raw === 'string') { + const n = parseInt(raw, 10) + if (Number.isFinite(n) && n >= 0) return n + } + return 2000 +} + +/** Fetch ``config.get full`` and fan the result through ``applyDisplay``. + * + * Extracted so the mtime-reload path can be exercised by the test + * suite without a React runtime (Copilot round-12 review on #19835). + * Both the initial hydration and the mtime poller use this shared + * helper, so a regression in the fetch/apply plumbing now fails the + * useConfigSync tests instead of only being visible at runtime. */ +export async function hydrateFullConfig( + gw: IGatewayClient, + setBell: (v: boolean) => void, + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void +): Promise { + const cfg = await quietRpc(gw, 'config.get', { key: 'full' }) + applyDisplay(cfg, setBell, setVoiceRecordKey) + + return cfg +} + +export const applyDisplay = ( + cfg: ConfigFullResponse | null, + setBell: (v: boolean) => void, + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void +) => { + const d = cfg?.config?.display ?? {} + + setBell(!!d.bell_on_complete) + + // Only push the voice record key when the RPC actually returned a + // config payload. ``quietRpc()`` collapses failures to ``null``; if we + // reset the cached shortcut on every null we would clobber a custom + // binding after one transient RPC error until the next config edit + // (Copilot round-8 review on #19835). The mtime-poll loop advances + // ``mtimeRef`` before this call, so staying silent on null preserves + // the last-good state and lets the next successful poll refresh it. + if (setVoiceRecordKey && cfg) { + setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg)) + } + + patchUiState({ + busyInputMode: normalizeBusyInputMode(d.busy_input_mode), + compact: !!d.tui_compact, + detailsMode: resolveDetailsMode(d), + detailsModeCommandOverride: false, + indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator), + inlineDiffs: d.inline_diffs !== false, + mouseTracking: normalizeMouseTracking(d), + pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg), + pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg), + sections: resolveSections(d.sections), + showCost: !!d.show_cost, + showReasoning: !!d.show_reasoning, + statusBar: normalizeStatusBar(d.tui_statusbar), + streaming: d.streaming !== false + }) +} + +export function useConfigSync({ + gw, + setBellOnComplete, + setVoiceEnabled, + setVoiceRecordKey, + sid +}: UseConfigSyncOptions) { + const mtimeRef = useRef(0) + + useEffect(() => { + if (!sid) { + return + } + + // Keep startup cheap: voice.toggle status probes optional audio/STT deps and + // can run long enough to delay prompt.submit on the single stdio RPC pipe. + // Environment flags are enough to initialize the UI bit; the heavier status + // check still runs when the user opens /voice. + setVoiceEnabled(process.env.CODER_VOICE === '1') + quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { + mtimeRef.current = Number(r?.mtime ?? 0) + }) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) + }, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid]) + + useEffect(() => { + if (!sid) { + return + } + + const id = setInterval(() => { + quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { + const next = Number(r?.mtime ?? 0) + + if (!mtimeRef.current) { + if (next) { + mtimeRef.current = next + } + + return + } + + if (!next || next === mtimeRef.current) { + return + } + + mtimeRef.current = next + + quietRpc(gw, 'reload.mcp', { session_id: sid, confirm: true }).then( + r => r && turnController.pushActivity('MCP reloaded after config change') + ) + void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey) + }) + }, MTIME_POLL_MS) + + return () => clearInterval(id) + }, [gw, setBellOnComplete, setVoiceRecordKey, sid]) +} + +export interface UseConfigSyncOptions { + gw: IGatewayClient + setBellOnComplete: (v: boolean) => void + setVoiceEnabled: (v: boolean) => void + setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void + sid: null | string +} diff --git a/packages/cli/src/app/useInputHandlers.ts b/packages/cli/src/app/useInputHandlers.ts new file mode 100644 index 0000000..1466d01 --- /dev/null +++ b/packages/cli/src/app/useInputHandlers.ts @@ -0,0 +1,592 @@ +import { forceRedraw, useInput } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { SLASH_COMMANDS } from './slash/registry.js' +import type { SlashCommand } from './slash/types.js' +import type { + ApprovalRespondResponse, + ConfigSetResponse, + SecretRespondResponse, + SudoRespondResponse, + VoiceRecordResponse +} from '../gateway/types.js' +import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { setTextInputSlashPopupActive } from '../components/textInput.js' + +import { getInputSelection } from './inputSelectionStore.js' +import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { patchTurnState } from './turnStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target + +/** + * Approval / clarify / confirm overlays mount their own `useInput` handlers + * for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global + * input handler used to early-return for any other key while one of those + * overlays was up, which silently disabled transcript scrolling — the user + * couldn't read context above the prompt that the prompt itself was asking + * about. Returns true when the key is a transcript-scroll input that should + * fall through to the global scroll handlers even while a prompt is active. + * + * Modifier-held wheel (precision mode) is included — a user who wants to + * scroll a single line at a time during a prompt expects it to work. + */ +export function shouldFallThroughForScroll(key: { + downArrow: boolean + pageDown: boolean + pageUp: boolean + shift: boolean + upArrow: boolean + wheelDown: boolean + wheelUp: boolean +}): boolean { + if (key.wheelUp || key.wheelDown) { + return true + } + + if (key.pageUp || key.pageDown) { + return true + } + + if (key.shift && (key.upArrow || key.downArrow)) { + return true + } + + return false +} + +export function applyVoiceRecordResponse( + response: null | VoiceRecordResponse, + starting: boolean, + voice: Pick, + sys: (text: string) => void +) { + if (!starting || response?.status === 'recording') { + return + } + + voice.setRecording(false) + + if (response?.status === 'busy') { + voice.setProcessing(true) + sys('voice: still transcribing; try again shortly') + } else { + voice.setProcessing(false) + } +} + +export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { + const { actions, composer, gateway, terminal, voice } = ctx + const { actions: cActions, refs: cRefs, state: cState } = composer + + const overlay = useStore($overlayState) + const isBlocked = useStore($isBlocked) + const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) + + // Debounce timer for history navigation triggered by arrow keys. + // Touchpad scroll events translated by the terminal into ↑/↓ arrive + // every 16–50 ms; real keyboard strokes are ≥100 ms apart. Without + // this guard, two-finger scrolling on the trackpad races through the + // input history instead of scrolling the transcript. + const lastHistoryNavTime = useRef(0) + const HISTORY_NAV_DEBOUNCE_MS = 70 + + // ── Slash-command popup: open when input starts with "/" ────────── + useEffect(() => { + const input = cState.input + if (input.startsWith('/') && input.length > 0) { + const filter = input.slice(1) + patchUiState({ + slashCommandOpen: true, + slashCommandFilter: filter, + slashCommandSelectedIndex: 0, + }) + setTextInputSlashPopupActive(true) + } else { + patchUiState({ slashCommandOpen: false, slashCommandFilter: '', slashCommandSelectedIndex: 0 }) + setTextInputSlashPopupActive(false) + } + }, [cState.input]) + + const copySelection = () => { + // ink's copySelection() already calls setClipboard() which handles + // pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback. + terminal.selection.copySelection() + } + + const clearSelection = () => { + terminal.selection.clearSelection() + } + + const cancelOverlayFromCtrlC = () => { + if (overlay.clarify) { + return actions.answerClarify('') + } + + if (overlay.approval) { + return gateway + .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) + .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) + } + + if (overlay.sudo) { + return gateway + .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) + .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) + } + + if (overlay.secret) { + return gateway + .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) + .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + } + + if (overlay.modelPicker) { + return patchOverlayState({ modelPicker: false }) + } + + if (overlay.skillsHub) { + return patchOverlayState({ skillsHub: false }) + } + + if (overlay.picker) { + return patchOverlayState({ picker: false }) + } + + if (overlay.agents) { + return patchOverlayState({ agents: false }) + } + } + + const cycleQueue = (dir: 1 | -1) => { + const len = cRefs.queueRef.current.length + + if (!len) { + return false + } + + const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len + + cActions.setQueueEdit(index) + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.queueRef.current[index] ?? '') + + return true + } + + const cycleHistory = (dir: 1 | -1) => { + const h = cRefs.historyRef.current + const cur = cState.historyIdx + + if (dir < 0) { + if (!h.length) { + return + } + + if (cur === null) { + cRefs.historyDraftRef.current = cState.input + } + + const index = cur === null ? h.length - 1 : Math.max(0, cur - 1) + + cActions.setHistoryIdx(index) + cActions.setQueueEdit(null) + cActions.setInput(h[index] ?? '') + + return + } + + if (cur === null) { + return + } + + const next = cur + 1 + + if (next >= h.length) { + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.historyDraftRef.current) + } else { + cActions.setHistoryIdx(next) + cActions.setInput(h[next] ?? '') + } + } + + // CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture + // (NOT the voice-mode umbrella bit). The mode is enabled via /voice on; + // Ctrl+B while the mode is off sys-nudges the user. While the mode is + // on, the first press starts a single VAD-bounded capture + // (gateway -> start_continuous(auto_restart=false), VAD auto-stop -> + // transcribe -> idle), a subsequent press stops and transcribes it. + // The gateway publishes voice.status + voice.transcript events that + // createGatewayEventHandler turns into UI badges and composer injection. + const voiceRecordToggle = () => { + if (!voice.enabled) { + return actions.sys('voice: mode is off — enable with /voice on') + } + + const starting = !voice.recording + const action = starting ? 'start' : 'stop' + + // Optimistic UI — flip the REC badge immediately so the user gets + // feedback while the RPC round-trips; the voice.status event is the + // authoritative source and may correct us. + if (starting) { + voice.setRecording(true) + } else { + voice.setRecording(false) + voice.setProcessing(false) + } + + gateway + .rpc('voice.record', { action, session_id: getUiState().sid }) + .then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys)) + .catch((e: Error) => { + // Revert optimistic UI on failure. + if (starting) { + voice.setRecording(false) + } + + actions.sys(`voice error: ${e.message}`) + }) + } + + useInput((ch, key) => { + const live = getUiState() + + if (isBlocked) { + // When approval/clarify/confirm overlays are active, their own useInput + // handlers must receive keystrokes (arrow keys, numbers, Enter). Only + // intercept Ctrl+C here so the user can deny/dismiss — all other keys + // fall through to the component-level handlers. + // + // With main-screen native scrolling, wheel / PageUp / PageDown are + // handled by the terminal emulator and don't need TUI-level handlers. + const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm + + if (promptOverlay) { + if (isCtrl(key, ch, 'c')) { + cancelOverlayFromCtrlC() + } + + return + } + + if (overlay.pager) { + if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') { + return patchOverlayState({ pager: null }) + } + + const move = (delta: number | 'top' | 'bottom') => + patchOverlayState(prev => { + if (!prev.pager) { + return prev + } + + const { lines, offset } = prev.pager + const max = Math.max(0, lines.length - pagerPageSize) + const step = delta === 'top' ? -lines.length : delta === 'bottom' ? lines.length : delta + const next = Math.max(0, Math.min(offset + step, max)) + + return next === offset ? prev : { ...prev, pager: { ...prev.pager, offset: next } } + }) + + if (key.upArrow || ch === 'k') { + return move(-1) + } + + if (key.downArrow || ch === 'j') { + return move(1) + } + + if (key.pageUp || ch === 'b') { + return move(-pagerPageSize) + } + + if (ch === 'g') { + return move('top') + } + + if (ch === 'G') { + return move('bottom') + } + + if (key.return || ch === ' ' || key.pageDown) { + patchOverlayState(prev => { + if (!prev.pager) { + return prev + } + + const { lines, offset } = prev.pager + const max = Math.max(0, lines.length - pagerPageSize) + + // Auto-close only when already at the last page — otherwise clamp + // to `max` so the offset matches what the line/page-back handlers + // can reach (prevents a snap-back jump on the next ↑/↓/PgUp). + return offset >= max + ? { ...prev, pager: null } + : { ...prev, pager: { ...prev.pager, offset: Math.min(offset + pagerPageSize, max) } } + }) + } + + return + } + + if (isCtrl(key, ch, 'c')) { + cancelOverlayFromCtrlC() + } else if (key.escape && overlay.picker) { + patchOverlayState({ picker: false }) + } + + return + } + + // ── Slash command popup navigation ─────────────────────────────── + // When the popup is open, ↑↓ navigate the filtered command list, + // Enter executes the selected command, Escape dismisses the popup. + if (live.slashCommandOpen) { + const prefix = live.slashCommandFilter.toLowerCase() + // Filter SLASH_COMMANDS by name/alias prefix match + const filtered: SlashCommand[] = [] + const seen = new Set() + for (const cmd of SLASH_COMMANDS) { + if (seen.has(cmd.name)) continue + if (cmd.name.startsWith(prefix) || (cmd.aliases ?? []).some((a) => a.toLowerCase().startsWith(prefix))) { + seen.add(cmd.name) + filtered.push(cmd) + } + } + const maxIdx = Math.max(0, filtered.length - 1) + + if (key.upArrow) { + const next = live.slashCommandSelectedIndex <= 0 ? maxIdx : live.slashCommandSelectedIndex - 1 + patchUiState({ slashCommandSelectedIndex: next }) + return + } + + if (key.downArrow) { + const next = live.slashCommandSelectedIndex >= maxIdx ? 0 : live.slashCommandSelectedIndex + 1 + patchUiState({ slashCommandSelectedIndex: next }) + return + } + + if (key.return) { + // Execute the selected command directly via dispatchSubmission + // (bypasses submit() to avoid inputBuf merging issues). + if (filtered.length > 0) { + const idx = Math.min(live.slashCommandSelectedIndex, maxIdx) + const cmd = filtered[idx]! + patchUiState({ slashCommandOpen: false, slashCommandFilter: '', slashCommandSelectedIndex: 0 }) + setTextInputSlashPopupActive(false) + actions.dispatchSubmission('/' + cmd.name) + } + return + } + + if (key.escape) { + // Dismiss popup, keep input content for the user to edit + patchUiState({ slashCommandOpen: false, slashCommandFilter: '', slashCommandSelectedIndex: 0 }) + setTextInputSlashPopupActive(false) + return + } + + // Any other key — let it fall through to TextInput for typing + } + + if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) { + const len = cState.completions.length + + cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len)) + + return + } + + // Wheel / scroll inputs are handled by the terminal natively via + // scrollback in main-screen mode — no custom scroll handlers needed. + + // Escape-based voice bindings (ctrl/alt/super+escape) must win before the + // generic Esc handlers below; otherwise queue-edit cancel / selection-clear + // would swallow the chord and /voice would advertise a shortcut that never + // actually toggles recording in those UI states. + if (key.escape && isVoiceToggleKey(key, ch, voice.recordKey)) { + return voiceRecordToggle() + } + + // Queue-edit cancel beats selection-clear for plain Esc: the queue header + // explicitly promises "Esc cancel", so honoring it takes priority over the + // implicit selection-dismissal convention. Without an active edit, fall through. + if (key.escape && cState.queueEditIdx !== null) { + return cActions.clearIn() + } + + if (key.escape && terminal.hasSelection) { + return clearSelection() + } + + if (key.upArrow && !cState.inputBuf.length) { + // Debounce: ignore rapid arrow-key bursts that look like + // terminal-translated touchpad scroll (16–50 ms between events). + // Real keyboard strokes are ≥100 ms apart and pass through. + const navNow = Date.now() + if (navNow - lastHistoryNavTime.current < HISTORY_NAV_DEBOUNCE_MS) { + return + } + lastHistoryNavTime.current = navNow + + const inputSel = getInputSelection() + const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null + + const noLineAbove = + !cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0) + + if (noLineAbove) { + cycleQueue(1) || cycleHistory(-1) + + return + } + } + + if (key.downArrow && !cState.inputBuf.length) { + // Same debounce as up-arrow: prevent terminal-translated + // touchpad scroll from racing through input history. + const navNow = Date.now() + if (navNow - lastHistoryNavTime.current < HISTORY_NAV_DEBOUNCE_MS) { + return + } + lastHistoryNavTime.current = navNow + + const inputSel = getInputSelection() + const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null + const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0) + + if (noLineBelow || cState.historyIdx !== null) { + cycleQueue(-1) || cycleHistory(1) + + return + } + } + + if (isCopyShortcut(key, ch)) { + if (terminal.hasSelection) { + return copySelection() + } + + const inputSel = getInputSelection() + + if (inputSel && inputSel.end > inputSel.start) { + inputSel.clear() + + return + } + + // On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt). + // On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit. + if (isMac) { + return + } + } + + if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) { + cActions.removeQueue(cState.queueEditIdx) + + return cActions.clearIn() + } + + if (isCtrl(key, ch, 'x')) { + return patchOverlayState({ sessions: true }) + } + + if (key.ctrl && ch.toLowerCase() === 'c') { + if (live.busy && live.sid) { + return turnController.interruptTurn({ + appendMessage: actions.appendMessage, + gw: gateway.gw, + sid: live.sid, + sys: actions.sys + }) + } + + if (cState.input || cState.inputBuf.length) { + return cActions.clearIn() + } + + return actions.die() + } + + if (isAction(key, ch, 'd')) { + return actions.die() + } + + if (isAction(key, ch, 'l')) { + clearSelection() + forceRedraw(terminal.stdout ?? process.stdout) + + return + } + + if (isVoiceToggleKey(key, ch, voice.recordKey)) { + return voiceRecordToggle() + } + + // Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the + // primary keystroke to "Find Next" before the TUI sees it; Alt+G + // arrives as meta+g across platforms). + if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) { + return void cActions.openEditor().catch((err: unknown) => { + actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor') + }) + } + + // shift-tab flips yolo without spending a turn (claude-code parity) + if (key.shift && key.tab && !cState.completions.length) { + if (!live.sid) { + return void actions.sys('yolo needs an active session') + } + + // gateway.rpc swallows errors with its own sys() message and resolves to null, + // so we only speak when it came back with a real shape. null = rpc already spoke. + return void gateway.rpc('config.set', { key: 'yolo', session_id: live.sid }).then(r => { + if (r?.value === '1') { + return actions.sys('yolo on') + } + + if (r?.value === '0') { + return actions.sys('yolo off') + } + + if (r) { + actions.sys('failed to toggle yolo') + } + }) + } + + if (key.tab && cState.completions.length) { + const row = cState.completions[cState.compIdx] + + if (row?.text) { + const text = + cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0 + ? row.text.slice(1) + : row.text + + cActions.setInput(cState.input.slice(0, cState.compReplace) + text) + } + + return + } + + if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { + const next = cActions.dequeue() + + if (next) { + cActions.setQueueEdit(null) + actions.dispatchSubmission(next) + } + } + }) + + return { pagerPageSize } +} diff --git a/packages/cli/src/app/useLongRunToolCharms.ts b/packages/cli/src/app/useLongRunToolCharms.ts new file mode 100644 index 0000000..5d2f0d6 --- /dev/null +++ b/packages/cli/src/app/useLongRunToolCharms.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react' + +import { LONG_RUN_CHARMS } from '../content/charms.js' +import { pick, toolTrailLabel } from '../lib/text.js' + +import { turnController } from './turnController.js' +import { useTurnSelector } from './turnStore.js' +import { getUiState } from './uiStore.js' + +const DELAY_MS = 8_000 +const INTERVAL_MS = 10_000 +const MAX_CHARMS_PER_TOOL = 2 + +interface Slot { + count: number + lastAt: number +} + +export function useLongRunToolCharms() { + const tools = useTurnSelector(state => state.tools) + const slots = useRef(new Map()) + + useEffect(() => { + if (!getUiState().busy || !tools.length) { + slots.current.clear() + + return + } + + const tick = () => { + if (!getUiState().busy) { + slots.current.clear() + + return + } + + const now = Date.now() + const liveIds = new Set(tools.map(t => t.id)) + + for (const key of Array.from(slots.current.keys())) { + if (!liveIds.has(key)) { + slots.current.delete(key) + } + } + + for (const tool of tools) { + if (!tool.startedAt || now - tool.startedAt < DELAY_MS) { + continue + } + + const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 } + + if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) { + continue + } + + slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) + turnController.pushActivity( + `${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)` + ) + } + } + + tick() + const id = setInterval(tick, 1000) + + return () => clearInterval(id) + }, [tools]) +} diff --git a/packages/cli/src/app/useMainApp.ts b/packages/cli/src/app/useMainApp.ts new file mode 100644 index 0000000..1336f95 --- /dev/null +++ b/packages/cli/src/app/useMainApp.ts @@ -0,0 +1,901 @@ +import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { STARTUP_RESUME_ID } from '../config/env.js' +import { MAX_HISTORY } from '../config/limits.js' +import { SECTION_NAMES, sectionMode } from '../domain/details.js' +import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' +import { fmtCwdBranch, shortCwd } from '../domain/paths.js' +import { type IGatewayClient } from '../gateway/client.js' +import type { + ClarifyRespondResponse, + ClipboardPasteResponse, + ConfigSetResponse, + GatewayEvent, + SessionActiveListResponse, + SessionCloseResponse, + TerminalResizeResponse +} from '../gateway/types.js' +import { useGitBranch } from '../hooks/useGitBranch.js' +import { composerPromptWidth } from '../lib/inputMetrics.js' +import { appendTranscriptMessage } from '../lib/messages.js' +import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { terminalParityHints } from '../lib/terminalParity.js' +import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import type { Msg, PanelSection, SlashCatalog } from '../types.js' + +import { createGatewayEventHandler } from './createGatewayEventHandler.js' +import { createSlashHandler } from './createSlashHandler.js' +import { getInputSelection } from './inputSelectionStore.js' +import { type GatewayRpc } from './interfaces.js' +import { $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { patchTurnState, useTurnSelector } from './turnStore.js' +import { $uiState, getUiState, patchUiState } from './uiStore.js' +import { useComposerState } from './useComposerState.js' +import { useConfigSync } from './useConfigSync.js' +import { useInputHandlers } from './useInputHandlers.js' +import { useLongRunToolCharms } from './useLongRunToolCharms.js' +import { useSessionLifecycle } from './useSessionLifecycle.js' +import { useSubmission } from './useSubmission.js' + +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i +const BRACKET_PASTE_ON = '\x1b[?2004h' +const BRACKET_PASTE_OFF = '\x1b[?2004l' + +const capHistory = (items: Msg[]): Msg[] => { + if (items.length <= MAX_HISTORY) { + return items + } + + return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY) +} + +const statusColorOf = (status: string, t: { error: string; muted: string; ok: string; warn: string }) => { + if (status === 'ready') { + return t.ok + } + + if (status.startsWith('error')) { + return t.error + } + + if (status === 'interrupted') { + return t.warn + } + + return t.muted +} + +export interface PromptLiveSessionOptions { + dispatchSubmission: (full: string) => void + maybeWarn: (value: unknown) => void + modelArg?: string + newLiveSession: (msg?: string, title?: string) => Promise | null | string | void + onModelSwitched?: (value: string, result: ConfigSetResponse) => void + prompt: string + rpc: GatewayRpc + sys: (text: string) => void +} + +export async function startPromptLiveSession({ + dispatchSubmission, + maybeWarn, + modelArg, + newLiveSession, + onModelSwitched, + prompt, + rpc, + sys +}: PromptLiveSessionOptions) { + const trimmed = prompt.trim() + + if (!trimmed) { + return null + } + + // Let the backend-created session key (YYYYMMDD_HHMMSS_xxxxxx) remain + // the initial title. Auto-title generation can rename it after the first + // response; pre-queuing prompt text here causes duplicate-title errors when + // users dispatch common prompts like "Hello, what model are you?". + const sid = (await newLiveSession('new live session started')) ?? null + + if (!sid) { + sys('error: failed to start new live session') + + return null + } + + const requestedModel = modelArg?.trim() + + if (requestedModel) { + const result = await rpc('config.set', { key: 'model', session_id: sid, value: requestedModel }) + + if (!result?.value) { + sys('error: invalid response: model switch') + + return sid + } + + sys(`model → ${result.value}`) + maybeWarn(result) + onModelSwitched?.(result.value, result) + } + + dispatchSubmission(trimmed) + + return sid +} + +export function useMainApp(gw: IGatewayClient) { + const { exit } = useApp() + const { stdout } = useStdout() + const [cols, setCols] = useState(stdout?.columns ?? 80) + + useEffect(() => { + if (!stdout) { + return + } + + const sync = () => setCols(stdout.columns ?? 80) + + stdout.on('resize', sync) + + if (stdout.isTTY) { + stdout.write(BRACKET_PASTE_ON) + } + + return () => { + stdout.off('resize', sync) + + if (stdout.isTTY) { + stdout.write(BRACKET_PASTE_OFF) + } + } + }, [stdout]) + + const [historyItems, setHistoryItems] = useState(() => [{ kind: 'intro', role: 'system', text: '' }]) + const [lastUserMsg, setLastUserMsg] = useState('') + const [stickyPrompt, setStickyPrompt] = useState('') + const [catalog, setCatalog] = useState(null) + const [voiceEnabled, setVoiceEnabled] = useState(false) + const [voiceTts, setVoiceTts] = useState(false) + const [voiceRecording, setVoiceRecording] = useState(false) + const [voiceProcessing, setVoiceProcessing] = useState(false) + const [voiceRecordKey, setVoiceRecordKey] = useState(DEFAULT_VOICE_RECORD_KEY) + const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [turnStartedAt, setTurnStartedAt] = useState(null) + const [goodVibesTick, setGoodVibesTick] = useState(0) + const [bellOnComplete, setBellOnComplete] = useState(false) + + const ui = useStore($uiState) + const overlay = useStore($overlayState) + + const slashFlightRef = useRef(0) + const slashRef = useRef<(cmd: string) => boolean>(() => false) + const colsRef = useRef(cols) + const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) + const submitRef = useRef<(value: string) => void>(() => {}) + const terminalHintsShownRef = useRef(new Set()) + const historyItemsRef = useRef(historyItems) + const lastUserMsgRef = useRef(lastUserMsg) + + colsRef.current = cols + historyItemsRef.current = historyItems + lastUserMsgRef.current = lastUserMsg + + const hasSelection = useHasSelection() + const selection = useSelection() + const lastCopiedVersionRef = useRef(-1) + + useEffect(() => { + selection.setSelectionBgColor(ui.theme.color.selectionBg) + }, [selection, ui.theme.color.selectionBg]) + + // macOS Terminal.app does not forward Cmd+C to fullscreen TUIs that enable + // mouse tracking, so the only reliable native-feeling path is iTerm-style + // copy-on-select: once a drag creates a stable TUI selection, write it to + // the system clipboard while keeping the highlight visible. + // + // Subscribe directly via the ink selection bus (not useSyncExternalStore) + // so React doesn't re-render MainApp on every drag-move tick. The version + // ref de-dupes against re-entrant notifications. + useEffect(() => { + if (!isMac) { + return + } + + return selection.subscribe(() => { + if (!selection.hasSelection()) { + return + } + + const state = selection.getState() as { isDragging?: boolean } | null + + if (state?.isDragging) { + return + } + + const version = selection.version() + + if (version === lastCopiedVersionRef.current) { + return + } + + lastCopiedVersionRef.current = version + void selection.copySelectionNoClear() + }) + }, [selection]) + + const clearSelection = useCallback(() => { + selection.clearSelection() + getInputSelection()?.collapseToEnd() + }, [selection]) + + const composer = useComposerState({ + gw, + onClipboardPaste: quiet => clipboardPasteRef.current(quiet), + onImageAttached: info => { + sys(attachedImageNotice(info)) + }, + submitRef + }) + + const { actions: composerActions, refs: composerRefs, state: composerState } = composer + const empty = !historyItems.some(msg => msg.kind !== 'intro') + + useEffect(() => { + void terminalParityHints() + .then(hints => { + for (const hint of hints) { + if (terminalHintsShownRef.current.has(hint.key)) { + continue + } + + terminalHintsShownRef.current.add(hint.key) + turnController.pushActivity(hint.message, hint.tone) + } + }) + .catch(() => {}) + }, []) + + const appendMessage = useCallback( + (msg: Msg) => setHistoryItems(prev => capHistory(appendTranscriptMessage(prev, msg))), + [] + ) + + const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) + + const page = useCallback( + (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), + [] + ) + + const panel = useCallback( + (title: string, sections: PanelSection[]) => + appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), + [appendMessage] + ) + + const maybeWarn = useCallback( + (value: unknown) => { + const warning = (value as { warning?: unknown } | null)?.warning + + if (typeof warning === 'string' && warning) { + sys(`warning: ${warning}`) + } + }, + [sys] + ) + + const maybeGoodVibes = useCallback((text: string) => { + if (GOOD_VIBES_RE.test(text)) { + setGoodVibesTick(v => v + 1) + } + }, []) + + const rpc: GatewayRpc = useCallback( + async = Record>( + method: string, + params: Record = {} + ) => { + try { + const result = asRpcResult(await gw.request(method, params)) + + if (result) { + return result + } + + sys(`error: invalid response: ${method}`) + } catch (e) { + sys(`error: ${rpcErrorMessage(e)}`) + } + + return null + }, + [gw, sys] + ) + + const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + + const die = useCallback(() => { + gw.kill('app.die') + exit() + // Ink's exit() calls unmount() which resets terminal modes but does NOT + // call process.exit(). Without an explicit exit the Node process stays + // alive (stdin listener keeps the event loop open), so the process.on('exit') + // handler in entry.tsx — which sends the final resetTerminalModes() — never + // fires. This leaves kitty keyboard protocol, mouse modes, etc. enabled + // in the parent shell. See issue #19194. + process.exit(0) + }, [exit, gw]) + + const dieWithCode = useCallback((code: number) => { + gw.kill(`app.dieWithCode:${code}`) + exit() + process.exit(code) + }, [exit, gw]) + + const session = useSessionLifecycle({ + colsRef, + composerActions, + gw, + panel, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + }) + + useEffect(() => { + if (ui.busy) { + setTurnStartedAt(prev => prev ?? Date.now()) + } else { + setTurnStartedAt(null) + } + }, [ui.busy]) + + useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) + + useEffect(() => { + if (!ui.sid) { + patchUiState({ liveSessionCount: 0 }) + + return + } + + let stopped = false + + const refresh = () => { + gw.request('session.active_list', { current_session_id: getUiState().sid }) + .then(raw => { + const result = asRpcResult(raw) + + if (!stopped && result?.sessions) { + patchUiState({ liveSessionCount: result.sessions.length }) + } + }) + .catch(() => {}) + } + + refresh() + const timer = setInterval(refresh, 1500) + + return () => { + stopped = true + clearInterval(timer) + } + }, [gw, ui.sid]) + + // Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle. + const model = ui.info?.model?.replace(/^.*\//, '') ?? '' + + const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + + const tabCwd = ui.info?.cwd + + useTerminalTitle(model ? `${marker} ${model}${tabCwd ? ` · ${shortCwd(tabCwd, 24)}` : ''}` : 'Coder') + + useEffect(() => { + if (!ui.sid || !stdout) { + return + } + + const onResize = () => { + void rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + } + + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [rpc, stdout, ui.sid]) + + const answerClarify = useCallback( + (answer: string) => { + const clarify = overlay.clarify + + if (!clarify) { + return + } + + const label = toolTrailLabel('clarify') + + turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) + patchTurnState({ turnTrail: turnController.turnTools }) + + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + if (!r) { + return + } + + if (answer) { + turnController.persistedToolLabels.add(label) + appendMessage({ + kind: 'trail', + role: 'system', + text: '', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) + appendMessage({ role: 'user', text: answer }) + patchUiState({ status: 'running…' }) + } else { + sys('prompt cancelled') + } + + patchOverlayState({ clarify: null }) + }) + }, + [appendMessage, overlay.clarify, rpc, sys] + ) + + const paste = useCallback( + (quiet = false) => + rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => { + if (!r) { + return + } + + if (r.attached) { + const meta = imageTokenMeta(r) + + return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) + } + + if (!quiet) { + sys(r.message || 'No image found in clipboard') + } + }), + [rpc, sys] + ) + + clipboardPasteRef.current = paste + + const { dispatchSubmission, send, sendQueued, submit } = useSubmission({ + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + }) + + // Drain one queued message whenever the session settles (busy → false): + // agent turn ends, interrupt, shell.exec finishes, error recovered, or the + // session first comes up with pre-queued messages. Without this, shell.exec + // and error paths never emit message.complete, so anything enqueued while + // `!sleep` / a failed turn was running would stay stuck forever. + useEffect(() => { + if ( + !ui.sid || + ui.busy || + composerRefs.queueEditRef.current !== null || + composerRefs.queueRef.current.length === 0 + ) { + return + } + + const next = composerActions.dequeue() + + if (next) { + patchUiState({ busy: true, status: 'running…' }) + sendQueued(next) + } + }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) + + const { pagerPageSize } = useInputHandlers({ + actions: { + answerClarify, + appendMessage, + die, + dispatchSubmission, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newSession: session.newSession, + sys + }, + composer: { actions: composerActions, refs: composerRefs, state: composerState }, + gateway, + terminal: { hasSelection, selection, stdout }, + voice: { + enabled: voiceEnabled, + recordKey: voiceRecordKey, + recording: voiceRecording, + setProcessing: setVoiceProcessing, + setRecording: setVoiceRecording, + setVoiceEnabled, + setVoiceTts + } + }) + + const onEvent = useMemo( + () => + createGatewayEventHandler({ + composer: { setInput: composerActions.setInput }, + gateway, + session: { + STARTUP_RESUME_ID, + colsRef, + newSession: session.newSession, + resetSession: session.resetSession, + resumeById: session.resumeById, + setCatalog + }, + submission: { submitRef }, + system: { bellOnComplete, stdout, sys }, + transcript: { appendMessage, panel, setHistoryItems }, + voice: { + setProcessing: setVoiceProcessing, + setRecording: setVoiceRecording, + setVoiceEnabled, + setVoiceTts + } + }), + [ + appendMessage, + bellOnComplete, + clearSelection, + composerActions.setInput, + gateway, + panel, + session.newSession, + session.resetSession, + session.resumeById, + setVoiceEnabled, + setVoiceProcessing, + setVoiceRecording, + stdout, + submitRef, + sys + ] + ) + + onEventRef.current = onEvent + + useEffect(() => { + const handler = (ev: GatewayEvent) => onEventRef.current(ev) + + const exitHandler = () => { + turnController.reset() + patchUiState({ busy: false, sid: null, status: 'gateway exited' }) + turnController.pushActivity('gateway exited · /logs to inspect', 'error') + sys('error: gateway exited') + } + + gw.on('event', handler) + gw.on('exit', exitHandler) + gw.drain() + + // entry.tsx's setupGracefulExit handles process cleanup on real exit. + return () => { + gw.off('event', handler) + gw.off('exit', exitHandler) + } + }, [gw, sys]) + + useLongRunToolCharms() + + const slash = useMemo( + () => + createSlashHandler({ + composer: { + enqueue: composerActions.enqueue, + hasSelection, + paste, + queueRef: composerRefs.queueRef, + selection, + setInput: composerActions.setInput + }, + gateway, + local: { + catalog, + getHistoryItems: () => historyItemsRef.current, + getLastUserMsg: () => lastUserMsgRef.current, + maybeWarn, + setCatalog + }, + session: { + closeSession: session.closeSession, + die, + dieWithCode, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newLiveSession: session.newLiveSession, + newSession: session.newSession, + resetVisibleHistory: session.resetVisibleHistory, + resumeById: session.resumeById, + setSessionStartedAt + }, + slashFlightRef, + transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange }, + voice: { setVoiceEnabled, setVoiceRecordKey, setVoiceTts } + }), + [ + catalog, + composerActions, + composerRefs, + die, + gateway, + hasSelection, + maybeWarn, + page, + panel, + paste, + selection, + send, + session, + sys + ] + ) + + slashRef.current = slash + + const respondWith = useCallback( + (method: string, params: Record, done: () => void) => rpc(method, params).then(r => r && done()), + [rpc] + ) + + const answerApproval = useCallback( + (choice: string) => + respondWith( + 'approval.respond', + { + choice, + session_id: ui.sid, + request_id: overlay.approval?.request_id ?? overlay.approval?.tool_use_id, + }, + () => { + patchOverlayState({ approval: null }) + patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` }) + patchUiState({ status: 'running…' }) + }, + ), + [respondWith, ui.sid, overlay.approval?.request_id, overlay.approval?.tool_use_id] + ) + + const answerSudo = useCallback( + (pw: string) => { + if (!overlay.sudo) { + return + } + + return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { + patchOverlayState({ sudo: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.sudo, respondWith] + ) + + const answerSecret = useCallback( + (value: string) => { + if (!overlay.secret) { + return + } + + return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { + patchOverlayState({ secret: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.secret, respondWith] + ) + + const onModelSelect = useCallback((value: string) => { + patchOverlayState({ modelPicker: false }) + slashRef.current(`/model ${value}`) + }, []) + + const closeLiveSession = useCallback( + async (id: string) => { + patchUiState({ status: 'closing session…' }) + + try { + const result = (await session.closeSession(id)) as null | SessionCloseResponse + patchUiState({ status: 'ready' }) + + return result + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + sys(`error: ${message}`) + patchUiState({ status: 'ready' }) + + throw e + } + }, + [session, sys] + ) + + const newPromptSession = useCallback( + (prompt: string, modelArg?: string) => { + void startPromptLiveSession({ + dispatchSubmission, + maybeWarn, + modelArg, + newLiveSession: session.newLiveSession, + onModelSwitched: value => + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: value } : { model: value, skills: {}, tools: {} } + })), + prompt, + rpc, + sys + }) + }, + [dispatchSubmission, maybeWarn, rpc, session.newLiveSession, sys] + ) + + const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim())) + + // Per-section overrides win over the global mode — when every section is + // resolved to hidden, the only thing ToolTrail will surface is the + // floating-alert backstop (errors/warnings). Mirror that so we don't + // render an empty wrapper Box above the streaming area in quiet mode. + const anyPanelVisible = SECTION_NAMES.some( + s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + ) + + const thinkingPanelVisible = + sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + + const toolsPanelVisible = + sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + + const activityPanelVisible = + sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + + const showProgressArea = useTurnSelector(state => + anyPanelVisible + ? Boolean( + ui.busy || + state.outcome || + state.streamPendingTools.length || + state.streamSegments.some(segment => { + const hasThinking = Boolean(segment.thinking?.trim()) + const hasTrailTools = Boolean(segment.tools?.length) + + if (segment.kind === 'trail' && !segment.text) { + return ( + (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools) + ) + } + + return ( + Boolean(segment.text?.trim()) || + (thinkingPanelVisible && hasThinking) || + ((toolsPanelVisible || activityPanelVisible) && hasTrailTools) + ) + }) || + state.subagents.length || + state.tools.length || + state.todos.length || + state.turnTrail.length || + (thinkingPanelVisible && hasReasoning) || + state.activity.length + ) + : state.activity.some(item => item.tone !== 'info') + ) + + const appActions = useMemo( + () => ({ + activateLiveSession: session.activateLiveSession, + closeLiveSession, + answerApproval, + answerClarify, + answerSecret, + answerSudo, + clearSelection, + newLiveSession: () => session.newLiveSession(), + newPromptSession, + onModelSelect, + resumeById: session.resumeById, + setStickyPrompt + }), + [ + answerApproval, + answerClarify, + answerSecret, + answerSudo, + clearSelection, + closeLiveSession, + newPromptSession, + onModelSelect, + session.activateLiveSession, + session.newLiveSession, + session.resumeById + ] + ) + + const appComposer = useMemo( + () => ({ + cols, + compIdx: composerState.compIdx, + completions: composerState.completions, + empty, + handleTextPaste: composerActions.handleTextPaste, + input: composerState.input, + inputBuf: composerState.inputBuf, + pagerPageSize, + queueEditIdx: composerState.queueEditIdx, + queuedDisplay: composerState.queuedDisplay, + submit, + updateInput: composerActions.setInput, + voiceRecordKey + }), + [cols, composerActions, composerState, empty, pagerPageSize, submit, voiceRecordKey] + ) + + // Pass current progress through unfrozen — streaming update throttling + // handles interaction load; progress must stay truthful so panels don't + // randomly disappear when the live tail scrolls offscreen. + const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea]) + + const cwd = ui.info?.cwd || process.env.CODER_CWD || process.cwd() + const gitBranch = useGitBranch(cwd) + + const appStatus = useMemo( + () => ({ + cwdLabel: fmtCwdBranch(cwd, gitBranch), + goodVibesTick, + sessionStartedAt: ui.sid ? sessionStartedAt : null, + showStickyPrompt: !!stickyPrompt, + statusColor: statusColorOf(ui.status, ui.theme.color), + stickyPrompt, + turnStartedAt: ui.sid ? turnStartedAt : null, + // CLI parity: the classic prompt_toolkit status bar shows a red dot + // on REC (cli.py:_get_voice_status_fragments line 2344). + voiceLabel: voiceRecording ? '● REC' : voiceProcessing ? '◉ STT' : `voice ${voiceEnabled ? 'on' : 'off'}${voiceTts ? ' [tts]' : ''}` + }), + [ + cwd, + gitBranch, + goodVibesTick, + sessionStartedAt, + stickyPrompt, + turnStartedAt, + ui, + voiceEnabled, + voiceProcessing, + voiceRecording, + voiceTts + ] + ) + + const appTranscript = useMemo( + () => ({ historyItems }), + [historyItems] + ) + + return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } +} diff --git a/packages/cli/src/app/useSessionLifecycle.ts b/packages/cli/src/app/useSessionLifecycle.ts new file mode 100644 index 0000000..ecf4917 --- /dev/null +++ b/packages/cli/src/app/useSessionLifecycle.ts @@ -0,0 +1,361 @@ +import { writeFileSync } from 'node:fs' + +import { evictInkCaches } from '@coder/tui' +import { useCallback } from 'react' + +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' +import { introMsg, toTranscriptMessages } from '../domain/messages.js' +import { ZERO } from '../domain/usage.js' +import { type IGatewayClient } from '../gateway/client.js' +import type { + SessionActivateResponse, + SessionCloseResponse, + SessionCreateResponse, + SessionInflightTurn, + SessionResumeResponse, + SessionTitleResponse, + SetupStatusResponse +} from '../gateway/types.js' +import { asRpcResult } from '../lib/rpc.js' +import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' + +import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { patchTurnState } from './turnStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) + +const statusFromLiveSession = (status?: string, running = false) => { + if (status === 'waiting') { + return 'waiting for input…' + } + + if (status === 'starting') { + return 'starting agent…' + } + + return running || status === 'working' ? 'running…' : 'ready' +} + +export const writeActiveSessionFile = (sessionId: null | string, file = process.env.CODER_TUI_ACTIVE_SESSION_FILE) => { + if (!file || !sessionId) { + return + } + + try { + writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 }) + } catch { + // Best-effort shell epilogue hint only; never break live session changes. + } +} + +export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => { + const user = String(inflight?.user ?? '').trim() + + return user ? [{ role: 'user', text: user }] : [] +} + +export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => { + const assistant = String(inflight?.assistant ?? '') + + if (!assistant && !inflight?.streaming) { + return + } + + turnController.hydrateStreamingText(assistant) +} + +const trimTail = (items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q +} + +export interface UseSessionLifecycleOptions { + colsRef: { current: number } + composerActions: ComposerActions + gw: IGatewayClient + panel: (title: string, sections: PanelSection[]) => void + rpc: GatewayRpc + setHistoryItems: StateSetter + setLastUserMsg: StateSetter + setSessionStartedAt: StateSetter + setStickyPrompt: StateSetter + setVoiceProcessing: StateSetter + setVoiceRecording: StateSetter + sys: (text: string) => void +} + +export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { + const { + colsRef, + composerActions, + gw, + panel, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + } = opts + + const closeSession = useCallback( + (targetSid?: null | string) => + targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), + [rpc] + ) + + const resetSession = useCallback(() => { + turnController.fullReset() + setVoiceRecording(false) + setVoiceProcessing(false) + patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) + setHistoryItems([]) + setLastUserMsg('') + setStickyPrompt('') + composerActions.setPasteSnips([]) + // Half-prune: new session has new keys, but keep a warm pool in case + // the user resumes back to the prior session. + evictInkCaches('half') + }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) + + const resetVisibleHistory = useCallback( + (info: null | SessionInfo = null) => { + turnController.idle() + turnController.clearReasoning() + turnController.turnTools = [] + turnController.persistedToolLabels.clear() + + setHistoryItems(info ? [introMsg(info)] : []) + setStickyPrompt('') + setLastUserMsg('') + composerActions.setPasteSnips([]) + patchTurnState({ activity: [] }) + patchUiState({ info, usage: usageFrom(info) }) + }, + [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] + ) + + const startNewSession = useCallback( + async (msg?: string, title?: string, keepCurrent = false) => { + const setup = await rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) + + return null + } + + if (!keepCurrent) { + await closeSession(getUiState().sid) + } + + const r = await rpc('session.create', { cols: colsRef.current }) + + if (!r) { + patchUiState({ status: 'ready' }) + + return null + } + + const info = r.info ?? null + const requestedTitle = title?.trim() ?? '' + + resetSession() + setSessionStartedAt(Date.now()) + + writeActiveSessionFile(r.session_id) + patchUiState({ + info, + sid: r.session_id, + status: info?.version ? 'ready' : 'starting agent…', + usage: usageFrom(info) + }) + + if (info) { + setHistoryItems([introMsg(info)]) + } + + if (info?.credential_warning) { + sys(`warning: ${info.credential_warning}`) + } + + if (info?.config_warning) { + sys(`warning: ${info.config_warning}`) + } + + if (msg) { + sys(msg) + } + + if (requestedTitle) { + rpc('session.title', { + session_id: r.session_id, + title: requestedTitle + }) + .then(result => { + if (!result || getUiState().sid !== r.session_id) { + return + } + + const nextTitle = (result.title ?? requestedTitle).trim() + const suffix = result.pending ? ' (queued while session initializes)' : '' + sys(`session title set: ${nextTitle}${suffix}`) + }) + .catch((err: unknown) => { + if (getUiState().sid !== r.session_id) { + return + } + + const message = err instanceof Error ? err.message : String(err) + sys(`warning: failed to set session title: ${message}`) + }) + } + + return r.session_id + }, + [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + ) + + const newSession = useCallback( + (msg?: string, title?: string) => startNewSession(msg, title, false), + [startNewSession] + ) + + const newLiveSession = useCallback( + (msg = 'new live session started', title?: string) => { + patchOverlayState({ sessions: false }) + + return startNewSession(msg, title, true) + }, + [startNewSession] + ) + + const activateLiveSession = useCallback( + (id: string) => { + patchOverlayState({ sessions: false }) + patchUiState({ status: 'switching session…' }) + + gw.request('session.activate', { session_id: id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.activate') + + return patchUiState({ status: 'ready' }) + } + + const info = r.info ?? null + const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting') + + resetSession() + setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now()) + const transcript = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)] + setHistoryItems(info ? [introMsg(info), ...transcript] : transcript) + writeActiveSessionFile(r.session_key ?? r.session_id) + patchUiState({ + busy: running, + info, + sid: r.session_id, + status: statusFromLiveSession(r.status, running), + usage: usageFrom(info) + }) + hydrateLiveSessionInflight(r.inflight) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + }, + [gw, resetSession, setHistoryItems, setSessionStartedAt, sys] + ) + + const resumeById = useCallback( + (id: string) => { + patchOverlayState({ picker: false }) + patchUiState({ status: 'resuming…' }) + + rpc('setup.status', {}).then(setup => { + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) + + return + } + + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + + return patchUiState({ status: 'ready' }) + } + + resetSession() + setSessionStartedAt(Date.now()) + + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + writeActiveSessionFile(r.resumed ?? r.session_id) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: usageFrom(r.info ?? null) + }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }) + }, + [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + ) + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!getUiState().busy) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + return { + activateLiveSession, + closeSession, + guardBusySessionSwitch, + newLiveSession, + newSession, + resetSession, + resetVisibleHistory, + resumeById, + trimLastExchange: trimTail + } +} diff --git a/packages/cli/src/app/useSubmission.ts b/packages/cli/src/app/useSubmission.ts new file mode 100644 index 0000000..8fb0408 --- /dev/null +++ b/packages/cli/src/app/useSubmission.ts @@ -0,0 +1,429 @@ +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' + +import { TYPING_IDLE_MS } from '../config/timing.js' +import { attachedImageNotice } from '../domain/messages.js' +import { looksLikeSlashCommand } from '../domain/slash.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { + InputDetectDropResponse, + PromptSubmitResponse, + SessionSteerResponse, + ShellExecResponse +} from '../gateway/types.js' +import { asRpcResult } from '../lib/rpc.js' +import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' +import type { Msg } from '../types.js' + +import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js' +import { turnController } from './turnController.js' +import { getUiState, patchUiState } from './uiStore.js' + +const DOUBLE_ENTER_MS = 450 +const SESSION_BUSY_RE = /session busy|waiting for model response/i + +const isSessionBusyError = (e: unknown) => e instanceof Error && SESSION_BUSY_RE.test(e.message) + +const expandSnips = (snips: PasteSnippet[]) => { + const byLabel = new Map() + + for (const { label, text } of snips) { + const hit = byLabel.get(label) + hit ? hit.push(text) : byLabel.set(label, [text]) + } + + return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok) +} + +const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => + matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) + +export function useSubmission(opts: UseSubmissionOptions) { + const { + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + } = opts + + const lastEmptyAt = useRef(0) + const typingIdleTimer = useRef | null>(null) + + useEffect(() => { + if (typingIdleTimer.current) { + clearTimeout(typingIdleTimer.current) + typingIdleTimer.current = null + } + + if (!composerState.input && !composerState.inputBuf.length) { + turnController.relaxStreaming() + + return + } + + if (getUiState().busy) { + turnController.boostStreamingForTyping() + } + + typingIdleTimer.current = setTimeout(() => { + typingIdleTimer.current = null + turnController.relaxStreaming() + }, TYPING_IDLE_MS) + + return () => { + if (typingIdleTimer.current) { + clearTimeout(typingIdleTimer.current) + typingIdleTimer.current = null + } + } + }, [composerState.input, composerState.inputBuf]) + + const send = useCallback( + (text: string, showUserMessage = true) => { + const expand = expandSnips(composerState.pasteSnips) + + const startSubmit = (displayText: string, submitText: string, showUserMessage = true) => { + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + turnController.clearStatusTimer() + maybeGoodVibes(submitText) + setLastUserMsg(text) + + if (showUserMessage) { + appendMessage({ role: 'user', text: displayText }) + } + + patchUiState({ busy: true, status: 'running…' }) + turnController.bufRef = '' + turnController.interrupted = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + if (isSessionBusyError(e)) { + composerActions.enqueue(submitText) + patchUiState({ busy: true, status: 'queued for next turn' }) + + return sys(`queued: "${submitText.slice(0, 50)}${submitText.length > 50 ? '…' : ''}"`) + } + + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) + } + + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + // Always ask the backend whether this looks like a file drop. + // The backend's _detect_file_drop handles paths with spaces, quotes, + // Windows drive letters, and escaped characters correctly. + gw.request('input.detect_drop', { session_id: sid, text }) + .then(r => { + if (!r?.matched) { + return startSubmit(text, expand(text), showUserMessage) + } + + if (r.is_image) { + turnController.pushActivity(attachedImageNotice(r)) + } else { + turnController.pushActivity(`detected file: ${r.name}`) + } + + startSubmit(r.text || text, expand(r.text || text), showUserMessage) + }) + .catch(() => startSubmit(text, expand(text), showUserMessage)) + }, + [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + ) + + const shellExec = useCallback( + (cmd: string) => { + appendMessage({ role: 'user', text: `!${cmd}` }) + patchUiState({ busy: true, status: 'running…' }) + + gw.request('shell.exec', { command: cmd }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + return sys('error: invalid response: shell.exec') + } + + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => patchUiState({ busy: false, status: 'ready' })) + }, + [appendMessage, gw, sys] + ) + + const interpolate = useCallback( + (text: string, then: (result: string) => void) => { + patchUiState({ status: 'interpolating…' }) + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then(raw => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) + .catch(() => '(error)') + ) + ).then(results => then(spliceMatches(text, matches, results))) + }, + [gw] + ) + + const sendQueued = useCallback( + (text: string) => { + if (text.startsWith('!')) { + return shellExec(text.slice(1).trim()) + } + + if (hasInterpolation(text)) { + patchUiState({ busy: true }) + + return interpolate(text, send) + } + + send(text) + }, + [interpolate, send, shellExec] + ) + + // Honors `display.busy_input_mode` from settings.json (CLI parity): + // - 'queue' (legacy): append to queueRef; drains on busy → false + // - 'steer' : inject into the current turn via session.steer; falls + // back to queue when steer is rejected (no agent / no + // tool window). + // - 'interrupt' (default): cancel the in-flight turn, then send the + // new text as a fresh prompt so it actually moves. + // + // `opts.fallbackToFront` controls whether a steer fallback re-inserts + // at the front of the queue (used by the queue-edit path to preserve + // a picked item's position); the mainline submit path always appends. + const handleBusyInput = useCallback( + (full: string, opts: { fallbackToFront?: boolean } = {}) => { + const live = getUiState() + const mode = live.busyInputMode + + const fallback = (note: string) => { + if (opts.fallbackToFront) { + composerRefs.queueRef.current.unshift(full) + composerActions.syncQueue() + } else { + composerActions.enqueue(full) + } + + sys(note) + } + + if (mode === 'queue') { + return composerActions.enqueue(full) + } + + if (mode === 'steer' && live.sid) { + gw.request('session.steer', { session_id: live.sid, text: full }) + .then(raw => { + const r = asRpcResult(raw) + + if (r?.status !== 'queued') { + fallback('steer rejected — message queued for next turn') + } + }) + .catch(() => fallback('steer failed — message queued for next turn')) + + return + } + + // 'interrupt' (default): tear down the current turn, then send. + // `interruptTurn` fires `session.interrupt` without awaiting; if + // the gateway is still mid-response when `prompt.submit` lands, + // `send()`'s catch path re-queues with a "queued: ..." sys note + // (`isSessionBusyError`) — so a lost race degrades to queue + // semantics, not a dropped message. + if (live.sid) { + turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + + return interpolate(full, send) + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, gw, interpolate, send, sys] + ) + + const dispatchSubmission = useCallback( + (full: string) => { + if (!full.trim()) { + return + } + + if (looksLikeSlashCommand(full)) { + appendMessage({ kind: 'slash', role: 'system', text: full }) + composerActions.pushHistory(full) + slashRef.current(full) + composerActions.clearIn() + + return + } + + if (full.startsWith('!')) { + composerActions.clearIn() + + return shellExec(full.slice(1).trim()) + } + + const live = getUiState() + + if (!live.sid) { + composerActions.pushHistory(full) + composerActions.enqueue(full) + composerActions.clearIn() + + return + } + + const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() + + if (editIdx !== null) { + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) + + if (!picked || !live.sid) { + return + } + + if (getUiState().busy) { + // 'interrupt' / 'steer' should reach the live turn instead of + // silently going back to the queue. handleBusyInput resolves + // mode-specific behavior (interrupt-and-send, steer, or queue). + if (getUiState().busyInputMode === 'queue') { + composerRefs.queueRef.current.unshift(picked) + + return composerActions.syncQueue() + } + + return handleBusyInput(picked, { fallbackToFront: true }) + } + + return sendQueued(picked) + } + + composerActions.pushHistory(full) + + if (getUiState().busy) { + return handleBusyInput(full) + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + + return interpolate(full, send) + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef] + ) + + const submit = useCallback( + (value: string) => { + if (composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] + + if (row?.text) { + const text = value.startsWith('/') && row.text.startsWith('/') ? row.text.slice(1) : row.text + const next = value.slice(0, composerState.compReplace) + text + + if (next !== value) { + return composerActions.setInput(next) + } + } + } + + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() + const now = Date.now() + const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS + lastEmptyAt.current = now + + if (doubleTap && live.busy && live.sid) { + return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) + } + + if (doubleTap && live.sid && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() + + composerActions.syncQueue() + + if (next) { + composerActions.setQueueEdit(null) + dispatchSubmission(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + + return composerActions.setInput('') + } + + dispatchSubmission([...composerState.inputBuf, value].join('\n')) + }, + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys] + ) + + submitRef.current = submit + + return { dispatchSubmission, send, sendQueued, submit } +} + +export interface UseSubmissionOptions { + appendMessage: (msg: Msg) => void + composerActions: ComposerActions + composerRefs: ComposerRefs + composerState: ComposerState + gw: IGatewayClient + maybeGoodVibes: (text: string) => void + setLastUserMsg: (value: string) => void + slashRef: MutableRefObject<(cmd: string) => boolean> + submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void +} diff --git a/packages/cli/src/banner.ts b/packages/cli/src/banner.ts new file mode 100644 index 0000000..53393b8 --- /dev/null +++ b/packages/cli/src/banner.ts @@ -0,0 +1,94 @@ +import type { ThemeColors } from './theme.js' + +const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g + +export function parseRichMarkup(markup: string): Line[] { + const lines: Line[] = [] + + for (const raw of markup.split('\n')) { + const trimmed = raw.trimEnd() + + if (!trimmed) { + lines.push(['', ' ']) + + continue + } + + const matches = [...trimmed.matchAll(RICH_RE)] + + if (!matches.length) { + lines.push(['', trimmed]) + + continue + } + + let cursor = 0 + + for (const m of matches) { + const before = trimmed.slice(cursor, m.index) + + if (before) { + lines.push(['', before]) + } + + lines.push([m[1]!, m[2]!]) + cursor = m.index! + m[0].length + } + + if (cursor < trimmed.length) { + lines.push(['', trimmed.slice(cursor)]) + } + } + + return lines +} + +const LOGO_ART = [ + '██╗ ██╗ █████╗ ██████╗ ███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', + '██║ ██╔╝██╔══██╗██╔══██╗██╔════╝ ██╔══██╗██╔════╝██╔════╝████╗ ██║╚══██╔══╝', + '█████╔╝ ██║ ██║██║ ██║█████╗ ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', + '██╔══██╗██║ ██║██║ ██║██╔══╝ ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', + '██║ ██║██║ ██║██║ ██║███████╗ ██║ ██║██║ ██║███████╗██║ ╚████║ ██║ ', + '╚═╝ ╚═╝╚█████╔╝██████╔╝╚══════╝ ╚═╝ ╚═╝╚██████╔╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ' +] + +const CADUCEUS_ART = [ + ' ················', + ' ··●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●●··', + ' ·●●●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●●●●●●··', + ' ··●●●●●●●●●●··', + ' ··●●●●●●●●●··', + ' ··●●●●●●●●··', + ' ··●●●●●●··', + ' ··●●●●··', + ' ··●●··', + ' ····', +] + +const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const +const CADUC_GRADIENT = [2, 2, 1, 0, 0, 0, 0, 0, 1, 1, 2, 2, 1, 1, 2, 2] as const + +const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => { + const p = [c.primary, c.accent, c.border, c.muted] + + return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text]) +} + +export const LOGO_WIDTH = Math.max(...LOGO_ART.map(line => line.length)) +export const CADUCEUS_WIDTH = Math.max(...CADUCEUS_ART.map(line => line.length)) + +export const logo = (c: ThemeColors, customLogo?: string): Line[] => + customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c) + +export const caduceus = (c: ThemeColors, customHero?: string): Line[] => + customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c) + +export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0) + +type Line = [string, string] diff --git a/packages/cli/src/components/activeSessionSwitcher.tsx b/packages/cli/src/components/activeSessionSwitcher.tsx new file mode 100644 index 0000000..94dae24 --- /dev/null +++ b/packages/cli/src/components/activeSessionSwitcher.tsx @@ -0,0 +1,636 @@ +import React from 'react' +import { Box, Text, useInput, useStdout } from '@coder/tui' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse } from '../gateway/types.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { ModelPicker } from './modelPicker.js' +import { windowOffset } from './overlayControls.js' +import { TextInput } from './textInput.js' + +const VISIBLE = 12 +const MIN_WIDTH = 64 +const MAX_WIDTH = 128 +const TITLE_MAX = 64 + +const STATUS_GLYPH: Record = { + idle: '✓', + starting: '…', + waiting: '?', + working: '▶' +} + +const STATUS_LABEL: Record = { + idle: 'idle', + starting: 'starting', + waiting: 'waiting', + working: 'working' +} + +const CTRL_OFFSET = 96 + +const shortModel = (model = '') => model.replace(/^.*\//, '') || 'model?' +const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) - CTRL_OFFSET) + +export const fixedSessionColumnStyle = () => ({ flexShrink: 0 }) + +export const activeSessionCountLabel = (count: number) => + `${count} live ${count === 1 ? 'session' : 'sessions'}` + +export type OrchestratorHintRole = 'hotkey' | 'label' | 'text' + +export interface OrchestratorHintSegment { + role: OrchestratorHintRole + text: string +} + +export const orchestratorContextHintSegments = (newSelected: boolean): OrchestratorHintSegment[] => + newSelected + ? [ + { role: 'label', text: 'New row:' }, + { role: 'text', text: ' type prompt · ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' start · ' }, + { role: 'hotkey', text: 'Tab' }, + { role: 'text', text: ' model' } + ] + : [ + { role: 'label', text: 'Session row:' }, + { role: 'text', text: ' ' }, + { role: 'hotkey', text: 'Enter' }, + { role: 'text', text: ' switch · ' }, + { role: 'hotkey', text: 'Ctrl+D' }, + { role: 'text', text: ' close' } + ] + +export const orchestratorGlobalHotkeyHintSegments: OrchestratorHintSegment[] = [ + { role: 'hotkey', text: '↑↓' }, + { role: 'text', text: ' move · ' }, + { role: 'hotkey', text: 'Ctrl+N' }, + { role: 'text', text: ' new · ' }, + { role: 'hotkey', text: 'Ctrl+R' }, + { role: 'text', text: ' refresh · ' }, + { role: 'hotkey', text: 'Esc' }, + { role: 'text', text: ' close' } +] + +const hintText = (segments: readonly OrchestratorHintSegment[]) => segments.map(segment => segment.text).join('') + +export const orchestratorContextHint = (newSelected: boolean) => hintText(orchestratorContextHintSegments(newSelected)) + +export const orchestratorGlobalHotkeyHint = hintText(orchestratorGlobalHotkeyHintSegments) + +export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRole) => { + if (role === 'hotkey') { + return t.color.accent + } + + if (role === 'label') { + return t.color.label + } + + return t.color.muted +} + +export const selectedSessionRowStyle = (t: Theme) => ({ + backgroundColor: t.color.selectionBg, + color: t.color.text +}) + +export const newSessionMarkerColor = (t: Theme, selected: boolean) => + selected ? selectedSessionRowStyle(t).color : t.color.label + +export const newSessionRowIndex = (sessionCount: number) => Math.max(0, sessionCount) + +export const isNewSessionRow = (index: number, sessionCount: number) => index >= newSessionRowIndex(sessionCount) + +export const canTypeOrchestratorPrompt = (index: number, sessionCount: number) => isNewSessionRow(index, sessionCount) + +export const clampOrchestratorSelection = (index: number, sessionCount: number) => + Math.max(0, Math.min(index, newSessionRowIndex(sessionCount))) + +export const currentSessionSelectionIndex = ( + sessions: readonly SessionActiveItem[], + currentSessionId: null | string +) => { + const index = sessions.findIndex(s => Boolean(s.current) || (!!currentSessionId && s.id === currentSessionId)) + + return index >= 0 ? index : 0 +} + +export const orchestratorVisibleRowIndexes = (sessionCount: number, selected: number, visible = VISIBLE) => { + const total = Math.max(0, sessionCount) + 1 + const clamped = clampOrchestratorSelection(selected, sessionCount) + const offset = windowOffset(total, clamped, visible) + const count = Math.min(visible, total - offset) + + return Array.from({ length: count }, (_, i) => offset + i) +} + +export type CloseFallback = { action: 'activate'; sessionId: string } | { action: 'new' } | { action: 'stay' } + +export const closeFallbackAfterClose = ( + closedId: string, + currentSessionId: null | string, + remaining: readonly SessionActiveItem[] +): CloseFallback => { + if (!currentSessionId || closedId !== currentSessionId) { + return { action: 'stay' } + } + + const next = remaining.find(s => s.id !== closedId) + + return next ? { action: 'activate', sessionId: next.id } : { action: 'new' } +} + +export const draftModelArgFromPickerValue = (value: string) => { + const parts = value.trim().split(/\s+/).filter(Boolean) + const kept: string[] = [] + + for (const part of parts) { + if (part === TUI_SESSION_MODEL_FLAG || part === '--global') { + continue + } + + kept.push(part) + } + + return kept.join(' ') +} + +export const draftModelNameFromArg = (value: string) => { + const parts = draftModelArgFromPickerValue(value).split(/\s+/).filter(Boolean) + const modelParts: string[] = [] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + + if (part === '--provider') { + i++ + continue + } + + if (part.startsWith('--')) { + continue + } + + modelParts.push(part) + } + + return modelParts.join(' ').trim() +} + +export const draftModelDisplayLabel = (value: string) => { + const modelName = draftModelNameFromArg(value) + + return modelName ? shortModel(modelName) : 'current/default' +} + +export type OrchestratorRowClickAction = { action: 'activate'; sessionId: string } | { action: 'select-new' } + +export const orchestratorRowClickAction = ( + index: number, + sessions: readonly SessionActiveItem[] +): OrchestratorRowClickAction => { + const target = sessions[index] + + return target && !isNewSessionRow(index, sessions.length) + ? { action: 'activate', sessionId: target.id } + : { action: 'select-new' } +} + +export const draftTitleFromPrompt = (prompt: string, max = TITLE_MAX) => { + const compact = prompt.replace(/\s+/g, ' ').trim() + + if (compact.length <= max) { + return compact + } + + return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…` +} + +function OrchestratorHintSegments({ segments, t }: OrchestratorHintTextProps) { + return ( + <> + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + ) +} + +function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) { + return ( + + + + ) +} + +export function ActiveSessionSwitcher({ + currentSessionId, + gw, + onCancel, + onClose, + onNew, + onNewPrompt, + onSelect, + t +}: ActiveSessionSwitcherProps) { + const [items, setItems] = useState([]) + const [err, setErr] = useState('') + const [sel, setSel] = useState(0) + const [loading, setLoading] = useState(true) + const [draft, setDraft] = useState('') + const [draftModel, setDraftModel] = useState('') + const [pickingModel, setPickingModel] = useState(false) + const [closingId, setClosingId] = useState('') + const initialSelectionAppliedRef = useRef(false) + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + const promptColumns = Math.max(20, width - 11) + + const load = useCallback( + async (quiet = false) => { + if (!quiet) { + setLoading(true) + } + + try { + const raw = await gw.request('session.active_list', { + current_session_id: currentSessionId + }) + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: session.active_list') + setLoading(false) + + return [] + } + + const next = r.sessions ?? [] + const initializeSelection = !initialSelectionAppliedRef.current + initialSelectionAppliedRef.current = true + setItems(next) + setSel(s => + initializeSelection + ? clampOrchestratorSelection(currentSessionSelectionIndex(next, currentSessionId), next.length) + : clampOrchestratorSelection(s, next.length) + ) + setErr('') + setLoading(false) + + return next + } catch (e: unknown) { + setErr(rpcErrorMessage(e)) + setLoading(false) + + return [] + } + }, + [currentSessionId, gw] + ) + + useEffect(() => { + void load() + const timer = setInterval(() => void load(true), 1500) + + return () => clearInterval(timer) + }, [load]) + + const submitDraft = useCallback( + (value: string) => { + const prompt = value.trim() + + if (!prompt) { + return + } + + setDraft('') + onNewPrompt(prompt, draftModel || undefined) + }, + [draftModel, onNewPrompt] + ) + + const closeSelected = useCallback(async () => { + const target = items[sel] + + if (!target || isNewSessionRow(sel, items.length) || closingId) { + return + } + + setErr('') + setClosingId(target.id) + + try { + const result = await onClose(target.id) + const closed = Boolean(result?.closed ?? result?.ok) + + if (!closed) { + setErr('session was already closed') + + return + } + + const remaining = await load(true) + const fallback = closeFallbackAfterClose(target.id, currentSessionId, remaining) + + if (fallback.action === 'activate') { + onSelect(fallback.sessionId) + } else if (fallback.action === 'new') { + onNew() + } else { + setSel(s => clampOrchestratorSelection(s, remaining.length)) + } + } catch (e: unknown) { + setErr(rpcErrorMessage(e)) + } finally { + setClosingId('') + } + }, [closingId, currentSessionId, items, load, onClose, onNew, onSelect, sel]) + + const handleRowClick = useCallback( + (index: number) => (event: { stopImmediatePropagation?: () => void }) => { + event.stopImmediatePropagation?.() + const action = orchestratorRowClickAction(index, items) + + if (action.action === 'activate') { + setSel(clampOrchestratorSelection(index, items.length)) + onSelect(action.sessionId) + + return + } + + setSel(newSessionRowIndex(items.length)) + }, + [items, onSelect] + ) + + const newSelected = isNewSessionRow(sel, items.length) + const draftHasText = Boolean(draft.trim()) + + useInput((ch, key) => { + if (pickingModel) { + return + } + + const lower = ch?.toLowerCase() ?? '' + const isCtrl = (letter: string) => key.ctrl && (lower === letter || ch === ctrlChar(letter)) + + if (key.escape) { + return onCancel() + } + + if (isCtrl('n')) { + return onNew() + } + + if (isCtrl('r')) { + void load() + + return + } + + if (key.tab) { + if (newSelected) { + setPickingModel(true) + } + + return + } + + if (isCtrl('d')) { + if (!newSelected) { + void closeSelected() + } + + return + } + + if (newSelected && draftHasText) { + return + } + + if (key.upArrow && sel > 0) { + return setSel(s => clampOrchestratorSelection(s - 1, items.length)) + } + + if (key.downArrow && sel < newSessionRowIndex(items.length)) { + return setSel(s => clampOrchestratorSelection(s + 1, items.length)) + } + + if (key.return) { + if (newSelected) { + if (!draftHasText) { + return onNew() + } + + return + } + + if (items[sel]) { + return onSelect(items[sel]!.id) + } + } + }) + + if (pickingModel) { + return ( + setPickingModel(false)} + onSelect={value => { + setDraftModel(draftModelArgFromPickerValue(value)) + setPickingModel(false) + }} + sessionId={currentSessionId} + t={t} + /> + ) + } + + if (loading) { + return loading session orchestrator… + } + + const totalRows = items.length + 1 + const offset = windowOffset(totalRows, sel, VISIBLE) + const visibleRows = orchestratorVisibleRowIndexes(items.length, sel, VISIBLE) + + return ( + + + Session Orchestrator + + {activeSessionCountLabel(items.length)} + + {err && error: {err}} + {!items.length && ( + no live sessions — closed TUIs only leave resumable transcripts + )} + {offset > 0 && ↑ {offset} more} + + {visibleRows.map(i => { + const selected = sel === i + const selectedStyle = selected ? selectedSessionRowStyle(t) : null + const rowTextColor = selectedStyle?.color + + if (isNewSessionRow(i, items.length)) { + const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session' + const markerColor = newSessionMarkerColor(t, selected) + + return ( + + + {selected ? '▸ ' : ' '} + + + + + + + + + + + + new + + + + + + ✎ draft + + + + + + {draftModelDisplayLabel(draftModel)} + + + + + + {promptTitle} + + + + ) + } + + const s = items[i]! + const status = s.status ?? 'idle' + const current = s.current || s.id === currentSessionId + const title = closingId === s.id ? 'closing…' : s.title || s.preview || '(untitled)' + + return ( + + + {selected ? '▸ ' : ' '} + + + + + {String(i + 1).padStart(2)}. + + + + + + {current ? 'current' : s.id} + + + + + + {STATUS_GLYPH[status] ?? '·'} {STATUS_LABEL[status] ?? status} + + + + + + {shortModel(s.model)} + + + + + + {title} + + + + ) + })} + + {offset + VISIBLE < totalRows && ↓ {totalRows - offset - VISIBLE} more} + + {newSelected ? ( + <> + + prompt › + + + + + model: {draftModelDisplayLabel(draftModel)} + + + ) : ( + + + + Select +new to type a prompt + + + )} + + + + ) +} + +interface OrchestratorHintTextProps { + segments: readonly OrchestratorHintSegment[] + t: Theme +} + +interface ActiveSessionSwitcherProps { + currentSessionId: null | string + gw: IGatewayClient + onCancel: () => void + onClose: (id: string) => Promise + onNew: () => void + onNewPrompt: (prompt: string, modelArg?: string) => void + onSelect: (id: string) => void + t: Theme +} diff --git a/packages/cli/src/components/agentsOverlay.tsx b/packages/cli/src/components/agentsOverlay.tsx new file mode 100644 index 0000000..e9f9467 --- /dev/null +++ b/packages/cli/src/components/agentsOverlay.tsx @@ -0,0 +1,1074 @@ +import React from 'react' +import { Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text, useInput, useStdout } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' + +import { + $delegationState, + $overlaySectionsOpen, + applyDelegationStatus, + toggleOverlaySection +} from '../app/delegationStore.js' +import { patchOverlayState } from '../app/overlayStore.js' +import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js' +import { useTurnSelector } from '../app/turnStore.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gateway/types.js' +import { asRpcResult } from '../lib/rpc.js' +import { + buildSubagentTree, + descendantIds, + flattenTree, + fmtCost, + fmtDuration, + fmtTokens, + formatSummary, + hotnessBucket, + peakHotness, + sparkline, + topLevelSubagents, + treeTotals, + widthByDepth +} from '../lib/subagentTree.js' +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { SubagentNode, SubagentProgress } from '../types.js' + +// ── Types + lookup tables ──────────────────────────────────────────── + +type SortMode = 'depth-first' | 'duration-desc' | 'status' | 'tools-desc' +type FilterMode = 'all' | 'failed' | 'leaf' | 'running' +type Status = SubagentProgress['status'] + +const SORT_ORDER: readonly SortMode[] = ['depth-first', 'tools-desc', 'duration-desc', 'status'] +const FILTER_ORDER: readonly FilterMode[] = ['all', 'running', 'failed', 'leaf'] + +const SORT_LABEL: Record = { + 'depth-first': 'spawn order', + 'duration-desc': 'slowest', + status: 'status', + 'tools-desc': 'busiest' +} + +const FILTER_LABEL: Record = { + all: 'all', + failed: 'failed', + leaf: 'leaves', + running: 'running' +} + +const STATUS_RANK: Record = { + error: 0, + failed: 0, + interrupted: 1, + timeout: 1, + running: 2, + queued: 3, + completed: 4 +} + +const statusRank = (status: string): number => STATUS_RANK[status as Status] ?? STATUS_RANK.error + +const SORT_COMPARATORS: Record number> = { + 'depth-first': (a, b) => a.item.depth - b.item.depth || a.item.index - b.item.index, + 'tools-desc': (a, b) => b.aggregate.totalTools - a.aggregate.totalTools, + 'duration-desc': (a, b) => b.aggregate.totalDuration - a.aggregate.totalDuration, + status: (a, b) => statusRank(a.item.status) - statusRank(b.item.status) +} + +const FILTER_PREDICATES: Record boolean> = { + all: () => true, + leaf: n => n.children.length === 0, + running: n => n.item.status === 'running' || n.item.status === 'queued', + failed: n => + n.item.status === 'error' || + n.item.status === 'failed' || + n.item.status === 'interrupted' || + n.item.status === 'timeout' +} + +const STATUS_GLYPH: Record string; glyph: string }> = { + running: { color: t => t.color.accent, glyph: '●' }, + queued: { color: t => t.color.muted, glyph: '○' }, + completed: { color: t => t.color.statusGood, glyph: '✓' }, + interrupted: { color: t => t.color.warn, glyph: '■' }, + failed: { color: t => t.color.error, glyph: '✗' }, + timeout: { color: t => t.color.warn, glyph: '⌛' }, + error: { color: t => t.color.error, glyph: '⚠' } +} + +// Heatmap palette — cold → hot, resolved against the active theme. +const heatPalette = (t: Theme) => [t.color.border, t.color.accent, t.color.primary, t.color.warn, t.color.error] + +// ── Pure helpers ───────────────────────────────────────────────────── + +const fmtDur = (seconds?: number) => (seconds == null || seconds <= 0 ? '' : fmtDuration(seconds)) +const fmtElapsedLabel = (seconds: number) => (seconds < 0 ? '' : fmtDuration(seconds)) + +const displayElapsedSeconds = (item: SubagentProgress, nowMs: number): number | null => { + if (item.durationSeconds != null) { + return item.durationSeconds + } + + if (item.startedAt != null && (item.status === 'running' || item.status === 'queued')) { + return Math.max(0, (nowMs - item.startedAt) / 1000) + } + + return null +} + +const indentFor = (depth: number): string => ' '.repeat(Math.max(0, depth)) +const formatRowId = (n: number): string => String(n + 1).padStart(2, ' ') +const cycle = (order: readonly T[], current: T): T => order[(order.indexOf(current) + 1) % order.length]! + +const statusGlyph = (item: SubagentProgress, t: Theme) => { + // Defensive fallback for cross-version snapshots with unknown statuses. + const g = STATUS_GLYPH[item.status] ?? STATUS_GLYPH.error + + return { color: g.color(t), glyph: g.glyph } +} + +const prepareRows = (tree: SubagentNode[], sort: SortMode, filter: FilterMode): SubagentNode[] => + tree.length === 0 ? [] : flattenTree([...tree].sort(SORT_COMPARATORS[sort])).filter(FILTER_PREDICATES[filter]) + +const diffMetricLine = (name: string, a: number, b: number, fmt: (n: number) => string) => { + const d = b - a + const sign = d === 0 ? '' : d > 0 ? '+' : '-' + + return `${name}: ${fmt(a)} → ${fmt(b)} (${sign}${fmt(Math.abs(d)) || '0'})` +} + +// ── Sub-components ─────────────────────────────────────────────────── + +/** Polled on parent `tick` so accordions can resize the thumb without a scroll event. */ +function OverlayScrollbar({ + scrollRef, + t, + tick +}: { + scrollRef: RefObject + t: Theme + tick: number +}) { + void tick // ensures re-render when the parent clock advances + + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 + const below = Math.max(0, vp - thumbTop - thumb) + + const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '') + const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃` + const thumbColor = grab !== null ? t.color.primary : t.color.accent + const trackColor = hover ? t.color.border : t.color.muted + + const jump = (row: number, offset: number) => { + if (!s || !scrollable) { + return + } + + s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) + } + + return ( + { + const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) + const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + setGrab(off) + jump(row, off) + }} + onMouseDrag={(e: { localRow?: number }) => + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + } + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onMouseUp={() => setGrab(null)} + width={1} + > + {!scrollable ? ( + + {vBar(vp)} + + ) : ( + <> + {thumbTop > 0 ? ( + + {vBar(thumbTop)} + + ) : null} + + {thumbBody} + + {below > 0 ? ( + + {vBar(below)} + + ) : null} + + )} + + ) +} + +function GanttStrip({ + cols, + cursor, + flatNodes, + maxRows, + now, + t +}: { + cols: number + cursor: number + flatNodes: SubagentNode[] + maxRows: number + now: number + t: Theme +}) { + const spans = flatNodes + .map((node, idx) => { + const started = node.item.startedAt ?? now + + const ended = + node.item.durationSeconds != null && node.item.startedAt != null + ? node.item.startedAt + node.item.durationSeconds * 1000 + : now + + return { endAt: ended, idx, node, startAt: started } + }) + .filter(s => s.endAt >= s.startAt) + + if (!spans.length) { + return null + } + + const globalStart = Math.min(...spans.map(s => s.startAt)) + const globalEnd = Math.max(...spans.map(s => s.endAt)) + const totalSpan = Math.max(1, globalEnd - globalStart) + const totalSeconds = (globalEnd - globalStart) / 1000 + + // 5-col id gutter (" 12 ") so the bar doesn't press against the id. + // 10-col right reserve: pad + up to `12m 30s`-style label without + // truncate-end against a full-width bar. + const idGutter = 5 + const labelReserve = 10 + const barWidth = Math.max(10, cols - idGutter - labelReserve) + const startIdx = Math.max(0, Math.min(Math.max(0, spans.length - maxRows), cursor - Math.floor(maxRows / 2))) + const shown = spans.slice(startIdx, startIdx + maxRows) + + const bar = (startAt: number, endAt: number) => { + const s = Math.floor(((startAt - globalStart) / totalSpan) * barWidth) + const e = Math.min(barWidth, Math.ceil(((endAt - globalStart) / totalSpan) * barWidth)) + const fill = Math.max(1, e - s) + + return ' '.repeat(s) + '█'.repeat(fill) + ' '.repeat(Math.max(0, barWidth - s - fill)) + } + + const charStep = totalSeconds < 20 && barWidth > 20 ? 5 : 10 + + const ruler = Array.from({ length: barWidth }, (_, i) => { + if (i > 0 && i % 10 === 0) { + return '┼' + } + + if (i > 0 && i % 5 === 0) { + return '·' + } + + return '─' + }).join('') + + const rulerLabels = (() => { + const chars = new Array(barWidth).fill(' ') + + for (let pos = 0; pos < barWidth; pos += charStep) { + const secs = (pos / barWidth) * totalSeconds + const label = pos === 0 ? '0' : secs >= 1 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s` + + for (let j = 0; j < label.length && pos + j < barWidth; j++) { + chars[pos + j] = label[j]! + } + } + + return chars.join('') + })() + + const windowLabel = + spans.length > maxRows ? ` (${startIdx + 1}-${Math.min(spans.length, startIdx + maxRows)}/${spans.length})` : '' + + return ( + + + Timeline · {fmtElapsedLabel(Math.max(0, totalSeconds))} + {windowLabel} + + + {shown.map(({ endAt, idx, node, startAt }) => { + const active = idx === cursor + const { color } = statusGlyph(node.item, t) + const accent = active ? t.color.accent : t.color.muted + + const elSec = displayElapsedSeconds(node.item, now) + const elLabel = elSec != null ? fmtElapsedLabel(elSec) : '' + + return ( + + + {formatRowId(idx)} + {' '} + + + {bar(startAt, endAt)} + + {elLabel ? ( + + {' '} + {elLabel} + + ) : null} + + ) + })} + + + {' '} + {ruler} + + + {totalSeconds > 0 ? ( + + {' '} + {rulerLabels} + + ) : null} + + ) +} + +function OverlaySection({ + children, + count, + defaultOpen = false, + title, + t +}: { + children: ReactNode + count?: number + defaultOpen?: boolean + title: string + t: Theme +}) { + const openMap = useStore($overlaySectionsOpen) + const open = title in openMap ? openMap[title]! : defaultOpen + + return ( + + toggleOverlaySection(title, defaultOpen)}> + + {open ? '▾ ' : '▸ '} + {title} + {typeof count === 'number' ? ` (${count})` : ''} + + + + {open ? {children} : null} + + ) +} + +function Field({ name, t, value }: { name: string; t: Theme; value: ReactNode }) { + return ( + + {name} · + {value} + + ) +} + +function Detail({ id, node, t }: { id?: string; node: SubagentNode; t: Theme }) { + const { aggregate: agg, item } = node + const { color, glyph } = statusGlyph(item, t) + + const inputTokens = item.inputTokens ?? 0 + const outputTokens = item.outputTokens ?? 0 + const localTokens = inputTokens + outputTokens + const subtreeTokens = agg.inputTokens + agg.outputTokens - localTokens + const localCost = item.costUsd ?? 0 + const subtreeCost = agg.costUsd - localCost + + const filesRead = item.filesRead ?? [] + const filesWritten = item.filesWritten ?? [] + const outputTail = item.outputTail ?? [] + // Tool calls: prefer the live stream; for archived / post-turn views + // that stream is often empty even when tool_count > 0, so fall back to + // the tool names captured in outputTail at subagent.complete time. + const toolLines = item.tools.length > 0 ? item.tools : outputTail.map(e => e.tool).filter(Boolean) + + const filesOverflow = Math.max(0, filesRead.length - 8) + Math.max(0, filesWritten.length - 8) + + return ( + + + {id ? #{id} : null} + {glyph} {item.goal} + + + + + {item.model ? : null} + {item.toolsets?.length ? : null} + + + {item.durationSeconds ? : null} + {item.iteration != null ? : null} + {item.apiCalls ? : null} + + + {localTokens > 0 || localCost > 0 ? ( + + {localTokens > 0 ? ( + + {fmtTokens(inputTokens)} in · {fmtTokens(outputTokens)} out + {item.reasoningTokens ? ` · ${fmtTokens(item.reasoningTokens)} reasoning` : ''} + + } + /> + ) : null} + + {localCost > 0 ? ( + + {fmtCost(localCost)} + {subtreeCost >= 0.01 ? ` · subtree +${fmtCost(subtreeCost)}` : ''} + + } + /> + ) : null} + + {subtreeTokens > 0 ? : null} + + ) : null} + + {filesRead.length > 0 || filesWritten.length > 0 ? ( + + {filesWritten.slice(0, 8).map((p, i) => ( + + +{p} + + ))} + + {filesRead.slice(0, 8).map((p, i) => ( + + · {p} + + ))} + + {filesOverflow > 0 ? …+{filesOverflow} more : null} + + ) : null} + + {toolLines.length > 0 ? ( + + {toolLines.map((line, i) => ( + + · {line} + + ))} + + ) : null} + + {outputTail.length > 0 ? ( + + {outputTail.map((entry, i) => ( + + + {entry.tool} + {' '} + {entry.preview} + + ))} + + ) : null} + + {item.notes.length ? ( + + {item.notes.slice(-6).map((line, i) => ( + + · {line} + + ))} + + ) : null} + + {item.summary ? ( + + + {item.summary} + + + ) : null} + + ) +} + +function ListRow({ + active, + index, + node, + peak, + t, + width +}: { + active: boolean + index: number + node: SubagentNode + peak: number + t: Theme + width: number +}) { + const { color, glyph } = statusGlyph(node.item, t) + const palette = heatPalette(t) + const heatIdx = hotnessBucket(node.aggregate.hotness, peak, palette.length) + const heatMarker = heatIdx >= 2 ? palette[heatIdx]! : null + + const goal = compactPreview(node.item.goal || 'subagent', width - 28 - node.item.depth * 2) + const toolsCount = node.aggregate.totalTools > 0 ? ` ·${node.aggregate.totalTools}t` : '' + const kids = node.children.length ? ` ·${node.children.length}↓` : '' + const line = node.item.status === 'running' ? node.item.tools.at(-1) : undefined + const paren = line ? line.indexOf('(') : -1 + const toolShort = line ? (paren > 0 ? line.slice(0, paren) : line).trim() : '' + const trailing = toolShort ? ` · ${compactPreview(toolShort, 14)}` : '' + const fg = active ? t.color.accent : t.color.text + + return ( + + {' '} + {formatRowId(index)} + {indentFor(node.item.depth)} + {heatMarker ? : null} + {glyph} {goal} + + {toolsCount} + {kids} + {trailing} + + + ) +} + +function DiffPane({ + label, + snapshot, + t, + totals, + width +}: { + label: string + snapshot: SpawnSnapshot + t: Theme + totals: ReturnType + width: number +}) { + return ( + + + {label} + + + + {snapshot.label} + + + + + {formatSummary(totals)} + + + + + {topLevelSubagents(snapshot.subagents) + .slice(0, 8) + .map(s => { + const { color, glyph } = statusGlyph(s, t) + + return ( + + {glyph} {s.goal || 'subagent'} + + ) + })} + + + ) +} + +function DiffView({ + cols, + onClose, + pair, + t +}: { + cols: number + onClose: () => void + pair: { baseline: SpawnSnapshot; candidate: SpawnSnapshot } + t: Theme +}) { + const aTotals = useMemo(() => treeTotals(buildSubagentTree(pair.baseline.subagents)), [pair.baseline]) + const bTotals = useMemo(() => treeTotals(buildSubagentTree(pair.candidate.subagents)), [pair.candidate]) + const paneWidth = Math.floor((cols - 4) / 2) + + useInput((ch, key) => { + if (key.escape || ch === 'q') { + onClose() + } + }) + + const round = (n: number) => String(Math.round(n)) + const sumTokens = (x: typeof aTotals) => x.inputTokens + x.outputTokens + const dollars = (n: number) => fmtCost(n) || '$0.00' + + return ( + + + + Replay diff + + baseline vs candidate · esc/q close + + + + + + + + + + + Δ + + + + {diffMetricLine('agents', aTotals.descendantCount, bTotals.descendantCount, round)} + + {diffMetricLine('tools', aTotals.totalTools, bTotals.totalTools, round)} + + {diffMetricLine('depth', aTotals.maxDepthFromHere, bTotals.maxDepthFromHere, round)} + + + {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} + + {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} + {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} + + + ) +} + +// ── Main overlay ───────────────────────────────────────────────────── + +export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) { + const liveSubagents = useTurnSelector(state => state.subagents) + const delegation = useStore($delegationState) + const history = useStore($spawnHistory) + const diffPair = useStore($spawnDiff) + const { stdout } = useStdout() + + // historyIndex === 0: live turn. 1..N pulls the Nth-most-recent archived + // snapshot. /replay passes N on open. + const [historyIndex, setHistoryIndex] = useState(() => + Math.max(0, Math.min(history.length, Math.floor(initialHistoryIndex))) + ) + + const [sort, setSort] = useState('depth-first') + const [filter, setFilter] = useState('all') + const [cursor, setCursor] = useState(0) + const [flash, setFlash] = useState('') + const [now, setNow] = useState(() => Date.now()) + // cc-style view switching: list = full-width row picker, detail = full-width + // scrollable pane. Two panes side-by-side in Ink fought Yoga flex. + const [mode, setMode] = useState<'detail' | 'list'>('list') + + const detailScrollRef = useRef(null) + const prevLiveCountRef = useRef(liveSubagents.length) + + // ── Derived state ────────────────────────────────────────────────── + + const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null + // Instant fallback to history[0] the moment the live list clears — avoids + // a one-frame "no subagents" flash while the auto-follow effect fires. + const justFinishedSnapshot = historyIndex === 0 && liveSubagents.length === 0 ? (history[0] ?? null) : null + const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot + const replayMode = effectiveSnapshot != null + const subagents = replayMode ? effectiveSnapshot.subagents : liveSubagents + + const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const totals = useMemo(() => treeTotals(tree), [tree]) + const widths = useMemo(() => widthByDepth(tree), [tree]) + const spark = useMemo(() => sparkline(widths), [widths]) + const peak = useMemo(() => peakHotness(tree), [tree]) + const rows = useMemo(() => prepareRows(tree, sort, filter), [tree, sort, filter]) + + const selected = rows[cursor] ?? null + + const cols = stdout?.columns ?? 80 + const rowsH = Math.max(8, (stdout?.rows ?? 24) - 10) + const listWindowStart = Math.max(0, cursor - Math.floor(rowsH / 2)) + + // ── Effects ──────────────────────────────────────────────────────── + + useEffect(() => { + // Ticker drives both the live gantt and OverlayScrollbar content-reflow + // detection. Slower in replay (nothing's growing) but not stopped + // because accordions still expand. + const id = setInterval(() => setNow(Date.now()), replayMode ? 300 : 500) + + return () => clearInterval(id) + }, [replayMode]) + + useEffect(() => { + // Clamp stale index when history grows/shrinks beneath us. + if (historyIndex > history.length) { + setHistoryIndex(history.length) + } + }, [history.length, historyIndex]) + + useEffect(() => { + // Auto-follow the just-finished turn onto history[1] so the user isn't + // dropped into an empty live view. Fires only when transitioning from + // "had live subagents" → "live empty" while in live mode. + const prev = prevLiveCountRef.current + prevLiveCountRef.current = liveSubagents.length + + if (historyIndex === 0 && prev > 0 && liveSubagents.length === 0 && history.length > 0) { + setHistoryIndex(1) + setCursor(0) + setFlash('turn finished · inspect freely · q to close') + } + }, [history.length, historyIndex, liveSubagents.length]) + + useEffect(() => { + // Reset detail scroll on navigation so the top of the new node shows. + detailScrollRef.current?.scrollTo(0) + }, [cursor, historyIndex, mode]) + + useEffect(() => { + // Warm caps + paused flag on open. + gw.request('delegation.status', {}) + .then(r => applyDelegationStatus(asRpcResult(r))) + .catch(() => {}) + }, [gw]) + + useEffect(() => { + if (cursor >= rows.length) { + setCursor(Math.max(0, rows.length - 1)) + } + }, [cursor, rows.length]) + + // ── Actions ──────────────────────────────────────────────────────── + + const guardLive = (action: () => void) => { + if (replayMode) { + setFlash('replay mode — controls disabled') + } else { + action() + } + } + + const interrupt = (id: string) => gw.request('subagent.interrupt', { subagent_id: id }) + + const killOne = (id: string) => + guardLive(() => { + interrupt(id) + .then(raw => { + const r = asRpcResult(raw) + setFlash(r?.found ? `killing ${id}` : `not found: ${id}`) + }) + .catch(() => setFlash(`kill failed: ${id}`)) + }) + + const killSubtree = (node: SubagentNode) => + guardLive(() => { + const ids = [node.item.id, ...descendantIds(node)] + ids.forEach(id => interrupt(id).catch(() => {})) + setFlash(`killing subtree · ${ids.length} node${ids.length === 1 ? '' : 's'}`) + }) + + const togglePause = () => + guardLive(() => { + gw.request('delegation.pause', { paused: !delegation.paused }) + .then(raw => { + const r = asRpcResult(raw) + applyDelegationStatus({ paused: r?.paused }) + setFlash(r?.paused ? 'spawning paused' : 'spawning resumed') + }) + .catch(() => setFlash('pause failed')) + }) + + const stepHistory = (delta: -1 | 1) => + setHistoryIndex(idx => { + const next = Math.max(0, Math.min(history.length, idx + delta)) + + if (next !== idx) { + setCursor(0) + setFlash(next === 0 ? 'live turn' : `replay · ${next}/${history.length}`) + } + + return next + }) + + const closeWithCleanup = () => { + clearDiffPair() + onClose() + } + + // ── Input ────────────────────────────────────────────────────────── + + const detailPageSize = Math.max(4, rowsH - 2) + const wheelDetailDy = 3 + const scrollDetail = (dy: number) => detailScrollRef.current?.scrollBy(dy) + + useInput((ch, key) => { + if (ch === 'q') { + return closeWithCleanup() + } + + if (key.escape) { + return mode === 'detail' ? setMode('list') : closeWithCleanup() + } + + // Shared actions (both modes). + if (ch === '<' || ch === '[') { + return stepHistory(1) + } + + if (ch === '>' || ch === ']') { + return stepHistory(-1) + } + + if (ch === 'p') { + return togglePause() + } + + if (ch === 'x' && selected) { + return killOne(selected.item.id) + } + + if (ch === 'X' && selected) { + return killSubtree(selected) + } + + if (mode === 'detail') { + if (key.leftArrow || ch === 'h') { + return setMode('list') + } + + if (key.pageUp || (key.ctrl && ch === 'u')) { + return scrollDetail(-detailPageSize) + } + + if (key.pageDown || (key.ctrl && ch === 'd')) { + return scrollDetail(detailPageSize) + } + + if (key.wheelUp) { + return scrollDetail(-wheelDetailDy) + } + + if (key.wheelDown) { + return scrollDetail(wheelDetailDy) + } + + if (key.upArrow || ch === 'k') { + return scrollDetail(-2) + } + + if (key.downArrow || ch === 'j') { + return scrollDetail(2) + } + + if (ch === 'g') { + return detailScrollRef.current?.scrollTo(0) + } + + if (ch === 'G') { + return detailScrollRef.current?.scrollToBottom?.() + } + + return + } + + // List mode. + if ((key.return || key.rightArrow || ch === 'l') && selected) { + return setMode('detail') + } + + if (key.upArrow || ch === 'k' || key.wheelUp) { + return setCursor(c => Math.max(0, c - 1)) + } + + if (key.downArrow || ch === 'j' || key.wheelDown) { + return setCursor(c => Math.min(Math.max(0, rows.length - 1), c + 1)) + } + + if (ch === 'g') { + return setCursor(0) + } + + if (ch === 'G') { + return setCursor(Math.max(0, rows.length - 1)) + } + + if (ch === 's') { + return setSort(m => cycle(SORT_ORDER, m)) + } + + if (ch === 'f') { + return setFilter(m => cycle(FILTER_ORDER, m)) + } + }) + + // ── Header assembly ──────────────────────────────────────────────── + + const mix = Object.entries( + subagents.reduce>((acc, it) => { + const key = it.model ? it.model.split('/').pop()! : 'inherit' + acc[key] = (acc[key] ?? 0) + 1 + + return acc + }, {}) + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 4) + .map(([k, v]) => `${k}×${v}`) + .join(' · ') + + const capsLabel = delegation.maxSpawnDepth + ? `caps d${delegation.maxSpawnDepth}/${delegation.maxConcurrentChildren ?? '?'}` + : '' + + const title = + replayMode && effectiveSnapshot + ? `${historyIndex > 0 ? `Replay ${historyIndex}/${history.length}` : 'Last turn'} · finished ${new Date( + effectiveSnapshot.finishedAt + ).toLocaleTimeString()}` + : `Spawn tree${delegation.paused ? ' · ⏸ paused' : ''}` + + const metaLine = [formatSummary(totals), spark, capsLabel, mix ? `· ${mix}` : ''].filter(Boolean).join(' ') + + const controlsHint = replayMode + ? ' · controls locked' + : ` · x kill · X subtree · p ${delegation.paused ? 'resume' : 'pause'}` + + // ── Rendering ────────────────────────────────────────────────────── + + if (diffPair) { + return + } + + return ( + + + + + {title} + + {metaLine ? ( + + {' '} + {metaLine} + + ) : null} + + + + {rows.length === 0 ? ( + + No subagents this turn. Trigger delegate_task to populate the tree. + + ) : mode === 'list' ? ( + + + + + {rows.slice(listWindowStart, listWindowStart + rowsH).map((node, i) => ( + + ))} + + + ) : ( + + + + {selected ? : null} + + + + + + + + )} + + + {flash ? {flash} : null} + + {mode === 'list' ? ( + + ↑↓/jk move · g/G top/bottom · Enter/→ open detail{controlsHint} · s sort:{SORT_LABEL[sort]} · f filter: + {FILTER_LABEL[filter]} + {history.length > 0 ? ` · [ / ] history ${historyIndex}/${history.length}` : ''} + {' · q close'} + + ) : ( + + ↑↓/jk scroll · PgUp/PgDn page · g/G top/bottom · Esc/← back to list{controlsHint} · q close + + )} + + + ) +} + +interface AgentsOverlayProps { + gw: IGatewayClient + initialHistoryIndex?: number + onClose: () => void + t: Theme +} + +export const closeAgentsOverlay = () => patchOverlayState({ agents: false }) +export const openAgentsOverlay = () => patchOverlayState({ agents: true }) diff --git a/packages/cli/src/components/appChrome.tsx b/packages/cli/src/components/appChrome.tsx new file mode 100644 index 0000000..30b2645 --- /dev/null +++ b/packages/cli/src/components/appChrome.tsx @@ -0,0 +1,573 @@ +import React from 'react' +import { Box, type ScrollBoxHandle, stringWidth, Text } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' +import unicodeSpinners from 'unicode-animations' + +import { $delegationState } from '../app/delegationStore.js' +import type { IndicatorStyle } from '../app/interfaces.js' +import { useTurnSelector } from '../app/turnStore.js' +import { $uiState } from '../app/uiStore.js' +import { FACES } from '../content/faces.js' +import { getStatusVerb, VERBS } from '../content/verbs.js' +import { fmtDuration } from '../domain/messages.js' +import { stickyPromptFromViewport } from '../domain/viewport.js' +import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' +import { fmtK } from '../lib/text.js' +import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' +import type { Theme } from '../theme.js' +import type { Msg, Usage } from '../types.js' + +const FACE_TICK_MS = 2500 +const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] + +// Keep verb segment width stable so status-bar content to the right doesn't +// jitter when the status text changes between short/long labels (e.g. +// "Thinking" → "Generating" → "Running Bash"). +export const VERB_PAD_LEN = VERBS.reduce((max, v) => Math.max(max, v.length), 0) + 1 // + ellipsis +export const padVerb = (verb: string) => `${verb}…`.padEnd(VERB_PAD_LEN, ' ') + +// Compact alternates for the `emoji` and `ascii` indicator styles. +// Each entry is a fixed-width (display-width) glyph. +const EMOJI_FRAMES = ['⚕ ', '🌀', '🤔', '✨', '🍵', '🔮'] +const ASCII_FRAMES = ['|', '/', '-', '\\'] + +// Faster tick for spinner-style indicators — they read as motion only +// at frame rates closer to their authored interval. +const SPINNER_TICK_MS = 100 + +interface IndicatorRender { + frame: string + intervalMs: number + // When false, FaceTicker hides the status verb and just shows the + // glyph + duration. Lets `unicode` stay minimal while the other + // styles keep the status-label flavour users associate with the + // running… status. + showVerb: boolean +} + +const renderIndicator = (style: IndicatorStyle, tick: number): IndicatorRender => { + if (style === 'kaomoji') { + return { frame: FACES[tick % FACES.length] ?? '', intervalMs: FACE_TICK_MS, showVerb: true } + } + + if (style === 'emoji') { + return { + frame: EMOJI_FRAMES[tick % EMOJI_FRAMES.length] ?? '⚕ ', + intervalMs: SPINNER_TICK_MS * 6, + showVerb: true + } + } + + if (style === 'ascii') { + return { + frame: ASCII_FRAMES[tick % ASCII_FRAMES.length] ?? '|', + intervalMs: SPINNER_TICK_MS, + showVerb: true + } + } + + // 'unicode' — braille spinner (fixed 1-col). Authored interval is + // ~80ms; honour it but bound below at a safe minimum so React + // re-renders stay reasonable. This style is for users who want + // the cleanest possible status, so no status verb either. + const spinner = unicodeSpinners.braille + const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋' + + return { frame, intervalMs: Math.max(SPINNER_TICK_MS, spinner.interval), showVerb: false } +} + +function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { + const ui = useStore($uiState) + const style = ui.indicatorStyle + const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) + const [now, setNow] = useState(() => Date.now()) + + // Pre-compute cadence + verb-visibility for the active style so an + // `/indicator` switch re-arms the interval (and skips the verb timer + // for verb-less styles like `unicode`) without leaving the previous + // timer dangling. + const { intervalMs, showVerb } = renderIndicator(style, 0) + + useEffect(() => { + const glyph = setInterval(() => setTick(n => n + 1), intervalMs) + const clock = setInterval(() => setNow(Date.now()), 1000) + + return () => { + clearInterval(glyph) + clearInterval(clock) + } + }, [intervalMs]) + + const { frame } = renderIndicator(style, tick) + // Use the deterministic getStatusVerb() to show actual LLM state from the + // bridge's status.update events ('Thinking', 'Generating', 'Running Bash') + // instead of rotating through random verbs. Falls back to 'Thinking' when + // the current status is generic / transitional. + const verb = getStatusVerb({ busy: ui.busy, status: ui.status }) + const verbSegment = showVerb ? ` ${padVerb(verb)}` : '' + // Leading space keeps a gap between the frame and the duration when the + // verb segment is hidden (e.g. `unicode` spinner style). When the verb + // IS shown, its trailing padding already provides the gap, so the extra + // space is harmless. + const durationSegment = startedAt ? ` · ${fmtDuration(now - startedAt)}` : '' + + return ( + + {frame} + {verbSegment} + {durationSegment} + + ) +} + +function ctxBarColor(pct: number | undefined, t: Theme) { + if (pct == null) { + return t.color.muted + } + + if (pct >= 95) { + return t.color.statusCritical + } + + if (pct > 80) { + return t.color.statusBad + } + + if (pct >= 50) { + return t.color.statusWarn + } + + return t.color.statusGood +} + +function statusSessionCountLabel(count: number) { + return `${count} ${count === 1 ? 'session' : 'sessions'}` +} + +function ctxBar(pct: number | undefined, w = 10) { + const p = Math.max(0, Math.min(100, pct ?? 0)) + const filled = Math.round((p / 100) * w) + + return '█'.repeat(filled) + '░'.repeat(w - filled) +} + +export function statusRuleWidths(cols: number, cwdLabel: string) { + const width = Math.max(1, Math.floor(cols || 1)) + const desiredSeparatorWidth = width >= 24 ? 3 : 1 + const minLeftWidth = width >= 24 ? 8 : 1 + const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth) + + if (!cwdLabel || maxRightWidth <= 0) { + return { leftWidth: width, rightWidth: 0, separatorWidth: 0 } + } + + const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth)) + const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0 + const leftWidth = Math.max(1, width - separatorWidth - rightWidth) + + return { leftWidth, rightWidth, separatorWidth } +} + +function SpawnHud({ t }: { t: Theme }) { + // Tight HUD that only appears when the session is actually fanning out. + // Colour escalates to warn/error as depth or concurrency approaches the cap. + const delegation = useStore($delegationState) + const subagents = useTurnSelector(state => state.subagents) + + const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const totals = useMemo(() => treeTotals(tree), [tree]) + + if (!totals.descendantCount && !delegation.paused) { + return null + } + + const maxDepth = delegation.maxSpawnDepth + const maxConc = delegation.maxConcurrentChildren + const depth = Math.max(0, totals.maxDepthFromHere) + const active = totals.activeCount + + // `max_concurrent_children` is a per-parent cap, not a global one. + // `activeCount` sums every running agent across the tree and would + // over-warn for multi-orchestrator runs. The widest level of the tree + // is a closer proxy to "most concurrent spawns that could be hitting a + // single parent's slot budget". + const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0) + const depthRatio = maxDepth ? depth / maxDepth : 0 + const concRatio = maxConc ? widestLevel / maxConc : 0 + const ratio = Math.max(depthRatio, concRatio) + + const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.muted + + const pieces: string[] = [] + + if (delegation.paused) { + pieces.push('⏸ paused') + } + + if (totals.descendantCount > 0) { + const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}` + pieces.push(`d${depthLabel}`) + + if (active > 0) { + // Label pairs the widest-level count (drives concRatio above) with + // the total active count for context. `W/cap` triggers the warn, + // `+N` is everything else currently running across the tree. + const extra = Math.max(0, active - widestLevel) + const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}` + const suffix = extra > 0 ? `+${extra}` : '' + pieces.push(`⚡${widthLabel}${suffix}`) + } + } + + const atCap = depthRatio >= 1 || concRatio >= 1 + + return ( + + {atCap ? ' │ ⚠ ' : ' │ '} + {pieces.join(' ')} + + ) +} + +function SessionDuration({ startedAt }: { startedAt: number }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), 1000) + + return () => clearInterval(id) + }, [startedAt]) + + return fmtDuration(now - startedAt) +} + +const effortLabel = (effort?: string) => { + const value = String(effort ?? '') + .trim() + .toLowerCase() + + return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : '' +} + +const shortModelLabel = (model: string) => + model + .split('/') + .pop()! + .replace(/^claude[-_]/, '') + .replace(/^anthropic[-_]/, '') + .replace(/[-_]/g, ' ') + .replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2') + .trim() + +const modelLabel = (model: string, effort?: string, fast?: boolean) => + [shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ') + +export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { + const [active, setActive] = useState(false) + const [color, setColor] = useState(t.color.accent) + + useEffect(() => { + if (tick <= 0) { + return + } + + const palette = [t.color.error, t.color.warn, t.color.accent] + setColor(palette[Math.floor(Math.random() * palette.length)]!) + setActive(true) + + const id = setTimeout(() => setActive(false), 650) + + return () => clearTimeout(id) + }, [t.color.accent, tick]) + + if (!active) { + return null + } + + return +} + +export function StatusRule({ + cwdLabel, + cols, + busy, + status, + statusColor, + model, + modelFast, + modelReasoningEffort, + modeLabel, + onModeLabelClick, + usage, + bgCount, + liveSessionCount, + sessionStartedAt, + showCost, + turnStartedAt, + voiceLabel, + onSessionCountClick, + t +}: StatusRuleProps) { + const pct = usage.context_percent + const barColor = ctxBarColor(pct, t) + + const ctxLabel = usage.context_max + ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + : usage.total > 0 + ? `${fmtK(usage.total)} tok` + : '' + + const bar = usage.context_max ? ctxBar(pct) : '' + const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel) + const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : '' + const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => { + event.stopImmediatePropagation?.() + onSessionCountClick?.() + } + + const sessionCountNode = sessionCountText ? ( + onSessionCountClick ? ( + + │ {sessionCountText} + + ) : ( + │ {sessionCountText} + ) + ) : null + + return ( + + + + {'─ '} + + {busy ? ( + + ) : ( + + {status} + + )} + + {' │ '} + {modelLabel(model, modelReasoningEffort, modelFast)} + + {modeLabel ? onModeLabelClick ? ( + void }) => { e.stopImmediatePropagation?.(); onModeLabelClick(); }}> + + {' ['} + {modeLabel} + {']'} + + + ) : ( + + {' ['} + {modeLabel} + {']'} + + ) : null} + {ctxLabel ? ( + + {' │ '} + {ctxLabel} + + ) : null} + {bar ? ( + + {' │ '} + [{bar}] {pct != null ? `${pct}%` : ''} + + ) : null} + {sessionStartedAt ? ( + + {' │ '} + + + ) : null} + {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( + + {' │ '} + = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted} + > + cmp {usage.compressions} + + + ) : null} + + {voiceLabel ? ( + + {' │ '} + {voiceLabel} + + ) : null} + {sessionCountNode} + {bgCount > 0 ? ( + + {' │ '} + {bgCount} bg + + ) : null} + {showCost && typeof usage.cost_usd === 'number' ? ( + + {' │ $'} + {usage.cost_usd.toFixed(4)} + + ) : null} + + + {rightWidth > 0 ? ( + <> + {separatorWidth >= 3 ? ' ─ ' : ' '} + + + {cwdLabel} + + + + ) : null} + + ) +} + +export function FloatBox({ children, color }: { children: ReactNode; color: string }) { + return ( + + {children} + + ) +} + +export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) { + const { atBottom, bottom, top } = useViewportSnapshot(scrollRef) + const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom) + + useEffect(() => onChange(text), [onChange, text]) + + return null +} + +export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + const grabRef = useRef(null) + const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef) + + if (!vp) { + return + } + + const s = scrollRef.current + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 + const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border + const trackColor = hover ? t.color.border : t.color.muted + + const jump = (row: number, offset: number) => { + if (!s || !scrollable) { + return + } + + s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) + } + + return ( + { + const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) + const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + + grabRef.current = off + setGrab(off) + jump(row, off) + }} + onMouseDrag={(e: { localRow?: number }) => + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2)) + } + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onMouseUp={() => { + grabRef.current = null + setGrab(null) + }} + width={1} + > + {!scrollable ? ( + + {' \n'.repeat(Math.max(0, vp - 1))}{' '} + + ) : ( + <> + {thumbTop > 0 ? ( + + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} + + ) : null} + {thumb > 0 ? ( + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + ) : null} + {vp - thumbTop - thumb > 0 ? ( + + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} + + ) : null} + + )} + + ) +} + +interface StatusRuleProps { + bgCount: number + liveSessionCount: number + busy: boolean + cols: number + cwdLabel: string + model: string + modelFast?: boolean + modelReasoningEffort?: string + /** Agent mode label: "COORDINATOR", "WORKER", or undefined for default */ + modeLabel?: string + /** Callback when mode label is clicked (opens coordinator dashboard) */ + onModeLabelClick?: () => void + sessionStartedAt?: null | number + showCost: boolean + status: string + statusColor: string + t: Theme + turnStartedAt?: null | number + usage: Usage + voiceLabel?: string + onSessionCountClick?: () => void +} + +interface StickyPromptTrackerProps { + messages: readonly Msg[] + offsets: ArrayLike + onChange: (text: string) => void + scrollRef: RefObject +} + +interface TranscriptScrollbarProps { + scrollRef: RefObject + t: Theme +} diff --git a/packages/cli/src/components/appLayout.tsx b/packages/cli/src/components/appLayout.tsx new file mode 100644 index 0000000..4339386 --- /dev/null +++ b/packages/cli/src/components/appLayout.tsx @@ -0,0 +1,491 @@ +import React from 'react' +import { Box, MainScreen, NoSelect, Static, Text } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { memo, useMemo, useRef } from 'react' + +import { useGateway } from '../app/gatewayContext.js' +import type { AppLayoutProps } from '../app/interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' +import { $uiState } from '../app/uiStore.js' +import { SHOW_FPS } from '../config/env.js' +import { PLACEHOLDER } from '../content/placeholders.js' +import { + COMPOSER_PROMPT_GAP_WIDTH, + composerPromptWidth, + inputVisualHeight, + stableComposerColumns +} from '../lib/inputMetrics.js' +import { PerfPane } from '../lib/perfPane.js' +import { composerPromptText } from '../lib/prompt.js' + +import { AgentsOverlay } from './agentsOverlay.js' +import { GoodVibesHeart, StatusRule } from './appChrome.js' +import { CoordinatorDashboard } from './coordinatorDashboard.js' +import { FileTree } from './fileTree.js' +import { FloatingOverlays, PromptZone } from './appOverlays.js' +import { Panel, SessionPanel } from './branding.js' +import { FpsOverlay } from './fpsOverlay.js' +import { HelpHint } from './helpHint.js' +import { MessageLine } from './messageLine.js' +import { SlashCommandPopup } from './slashCommandPopup.js' +import { QueuedMessages } from './queuedMessages.js' +import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' +import { TextInput, type TextInputMouseApi } from './textInput.js' + +const PromptPrefix = memo(function PromptPrefix({ + bold = false, + color, + promptText, + width +}: { + bold?: boolean + color: string + promptText: string + width: number +}) { + const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH) + + return ( + + + + {promptText} + + + + + ) +}) + +const TranscriptPaneStatic = memo(function TranscriptPaneStatic({ + composer, + transcript +}: Pick) { + const ui = useStore($uiState) + + const lastUserIdx = useMemo(() => { + const items = transcript.historyItems + + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].role === 'user') { + return i + } + } + + return -1 + }, [transcript.historyItems]) + + const firstUserIdx = useMemo( + () => transcript.historyItems.findIndex(m => m.role === 'user'), + [transcript.historyItems] + ) + + const staticItems = useMemo( + () => + transcript.historyItems.map((msg, index) => ({ + key: `history-${index}-c${composer.cols}`, + jsx: ( + + {msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx && ( + + ─── + + )} + + {msg.kind === 'intro' ? ( + + {msg.info && ( + + )} + + ) : msg.kind === 'panel' && msg.panelData ? ( + + ) : ( + + )} + + ), + })), + [ + transcript.historyItems, + composer.cols, + firstUserIdx, + ui.compact, + ui.detailsMode, + ui.detailsModeCommandOverride, + ui.sections, + ui.sid, + ui.theme, + ] + ) + + return {(item: { jsx: React.ReactNode }) => item.jsx} +}) + +const TranscriptPaneDynamic = memo(function TranscriptPaneDynamic({ + actions, + composer, + progress, + transcript +}: Pick) { + const ui = useStore($uiState) + + return ( + + + + + + ) +}) + +const ComposerPane = memo(function ComposerPane({ + actions, + composer, + status +}: Pick) { + const ui = useStore($uiState) + const isBlocked = useStore($isBlocked) + const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') + const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh, false, composer.cols) + const promptWidth = composerPromptWidth(promptText) + const promptBlank = ' '.repeat(promptWidth) + const inputColumns = stableComposerColumns(composer.cols, promptWidth, false) + const inputHeight = inputVisualHeight(composer.input, inputColumns) + const inputMouseRef = useRef(null) + + const captureInputDrag = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.startAtBeginning() + } + + const dragFromPromptRow = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(e.localRow ?? 0, (e.localCol ?? 0) - promptWidth) + } + + const dragFromSpacer = (e: GutterMouseEvent) => { + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + inputMouseRef.current?.dragAt(0, (e.localCol ?? 0) - promptWidth) + } + + const endInputDrag = () => inputMouseRef.current?.end() + + return ( + { + if (e.cellIsBlank) { + actions.clearSelection() + } + }} + paddingX={1} + > + + + {ui.bgTasks.size > 0 && ( + + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + {status.showStickyPrompt ? ( + + + + {status.stickyPrompt} + + ) : ( + + )} + + + + + + + {composer.input === '?' && !composer.inputBuf.length && } + + {!isBlocked && ( + <> + {composer.inputBuf.map((line, i) => ( + + + {i === 0 ? ( + + ) : ( + {promptBlank} + )} + + + {line || ' '} + + ))} + + + + {sh ? ( + + ) : composer.inputBuf.length ? ( + {promptBlank} + ) : ( + + )} + + + + + + + + + + + + )} + + + {!composer.empty && !ui.sid && ⚕ {ui.status}} + + + + ) +}) + +const AgentsOverlayPane = memo(function AgentsOverlayPane() { + const { gw } = useGateway() + const ui = useStore($uiState) + const overlay = useStore($overlayState) + + return ( + patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })} + t={ui.theme} + /> + ) +}) + +const CoordinatorDashboardPane = memo(function CoordinatorDashboardPane() { + const ui = useStore($uiState) + + return ( + patchOverlayState({ coordinatorDashboard: false })} + t={ui.theme} + /> + ) +}) + +const FILETREE_AUTO_HIDE_COLS = 100; + +const FileTreePane = memo(function FileTreePane({ + cwd, +}: { + cwd: string; +}) { + const ui = useStore($uiState); + const overlay = useStore($overlayState); + + if (!overlay.fileTreeVisible) return null; + + return ( + + + + + + ); +}); + +const StatusRulePane = memo(function StatusRulePane({ + at, + composer, + status +}: Pick & { at: 'bottom' | 'top' }) { + const ui = useStore($uiState) + + if (ui.statusBar !== at) { + return null + } + + const modeLabel = + process.env.CODER_COORDINATOR_MODE === 'true' + ? 'COORDINATOR' + : process.env.CODER_WORKER_MODE === 'true' + ? 'WORKER' + : undefined + + return ( + + patchOverlayState({ coordinatorDashboard: true }) + : undefined + } + onSessionCountClick={() => patchOverlayState({ sessions: true })} + sessionStartedAt={status.sessionStartedAt} + showCost={ui.showCost} + status={ui.status} + statusColor={status.statusColor} + t={ui.theme} + turnStartedAt={status.turnStartedAt} + usage={ui.usage} + voiceLabel={status.voiceLabel} + /> + + ) +}) + +export const AppLayout = memo(function AppLayout({ + actions, + composer, + mouseTracking, + progress, + status, + transcript +}: AppLayoutProps) { + const overlay = useStore($overlayState) + const ui = useStore($uiState) + + return ( + + + + + + {overlay.agents ? ( + + + + ) : overlay.coordinatorDashboard ? ( + + + + ) : ( + <> + {composer.cols >= FILETREE_AUTO_HIDE_COLS && } + + + + + )} + + + {!overlay.agents && !overlay.coordinatorDashboard && ( + <> + + + + + + + + + + + {SHOW_FPS && ( + + + + )} + + )} + + + ) +}) + +type GutterMouseEvent = { + button: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} diff --git a/packages/cli/src/components/appOverlays.tsx b/packages/cli/src/components/appOverlays.tsx new file mode 100644 index 0000000..73886ec --- /dev/null +++ b/packages/cli/src/components/appOverlays.tsx @@ -0,0 +1,263 @@ +import React from 'react' +import { Box, Text } from '@coder/tui' +import { useStore } from '@nanostores/react' + +import { useGateway } from '../app/gatewayContext.js' +import type { AppOverlaysProps } from '../app/interfaces.js' +import { $overlayState, patchOverlayState } from '../app/overlayStore.js' +import { $uiSessionId, $uiTheme } from '../app/uiStore.js' + +import { ActiveSessionSwitcher } from './activeSessionSwitcher.js' +import { FloatBox } from './appChrome.js' +import { DiffView } from './diffView.js' +import { MaskedPrompt } from './maskedPrompt.js' +import { ModelPicker } from './modelPicker.js' +import { OverlayHint } from './overlayControls.js' +import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' +import { SessionPicker } from './sessionPicker.js' +import { SkillsHub } from './skillsHub.js' + +const COMPLETION_WINDOW = 16 + +export function PromptZone({ + cols, + onApprovalChoice, + onClarifyAnswer, + onSecretSubmit, + onSudoSubmit +}: Pick) { + const overlay = useStore($overlayState) + const theme = useStore($uiTheme) + + if (overlay.approval) { + return ( + + + + ) + } + + if (overlay.confirm) { + const req = overlay.confirm + + const onConfirm = () => { + patchOverlayState({ confirm: null }) + req.onConfirm() + } + + const onCancel = () => patchOverlayState({ confirm: null }) + + return ( + + + + ) + } + + if (overlay.clarify) { + return ( + + onClarifyAnswer('')} + req={overlay.clarify} + t={theme} + /> + + ) + } + + if (overlay.sudo) { + return ( + + + + ) + } + + if (overlay.secret) { + return ( + + + + ) + } + + return null +} + +export function FloatingOverlays({ + cols, + compIdx, + completions, + onActiveSessionSelect, + onActiveSessionClose, + onModelSelect, + onNewLiveSession, + onNewPromptSession, + onPickerSelect, + pagerPageSize +}: Pick< + AppOverlaysProps, + | 'cols' + | 'compIdx' + | 'completions' + | 'onActiveSessionSelect' + | 'onActiveSessionClose' + | 'onModelSelect' + | 'onNewLiveSession' + | 'onNewPromptSession' + | 'onPickerSelect' + | 'pagerPageSize' +>) { + const { gw } = useGateway() + const overlay = useStore($overlayState) + const sid = useStore($uiSessionId) + const theme = useStore($uiTheme) + + const hasAny = + overlay.diffView || + overlay.modelPicker || + overlay.pager || + overlay.picker || + overlay.sessions || + overlay.skillsHub || + completions.length + + if (!hasAny) { + return null + } + + // Fixed viewport centered on compIdx — previously the slice end was + // compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled + // down, bouncing the height on every keystroke. + const viewportSize = Math.min(COMPLETION_WINDOW, completions.length) + + const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize)) + + return ( + + {overlay.diffView && ( + + + + )} + + {overlay.picker && ( + + patchOverlayState({ picker: false })} + onSelect={onPickerSelect} + t={theme} + /> + + )} + + {overlay.sessions && ( + + patchOverlayState({ sessions: false })} + onClose={onActiveSessionClose} + onNew={onNewLiveSession} + onNewPrompt={onNewPromptSession} + onSelect={onActiveSessionSelect} + t={theme} + /> + + )} + + {overlay.modelPicker && ( + + patchOverlayState({ modelPicker: false })} + onSelect={onModelSelect} + sessionId={sid} + t={theme} + /> + + )} + + {overlay.skillsHub && ( + + patchOverlayState({ skillsHub: false })} t={theme} /> + + )} + + {overlay.pager && ( + + + {overlay.pager.title && ( + + + {overlay.pager.title} + + + )} + + {overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length + ? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` + : `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`} + + + + + )} + + {!!completions.length && ( + + + {completions.slice(start, start + viewportSize).map((item, i) => { + const active = start + i === compIdx + + return ( + + {/* flexShrink=0 — when meta overflows the row, Ink/Yoga + otherwise shaves the last char off the display column + (e.g. /goal renders as /goa). */} + + + {' '} + {item.display} + + + {item.meta ? ( + + {' '} + {item.meta} + + ) : null} + + ) + })} + + + )} + + ) +} diff --git a/packages/cli/src/components/branding.tsx b/packages/cli/src/components/branding.tsx new file mode 100644 index 0000000..9f1aad0 --- /dev/null +++ b/packages/cli/src/components/branding.tsx @@ -0,0 +1,449 @@ +import React from 'react' +import { Box, Text, useStdout } from '@coder/tui' +import { useEffect, useState } from 'react' +import unicodeSpinners from 'unicode-animations' + +import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' +import { flat } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { PanelSection, SessionInfo } from '../types.js' + +const LOADER_TICK_MS = 120 + +function InlineLoader({ label, t }: { label: string; t: Theme }) { + const [tick, setTick] = useState(0) + const spinner = unicodeSpinners.braille + const frame = spinner.frames[tick % spinner.frames.length] ?? '⠋' + + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), Math.max(LOADER_TICK_MS, spinner.interval)) + + return () => clearInterval(id) + }, [spinner.interval]) + + return ( + + {frame} {label} + + ) +} + +export function ArtLines({ lines }: { lines: [string, string][] }) { + return ( + + {lines.map(([c, text], i) => ( + + {text} + + ))} + + ) +} + +// Responsive Banner: full art → compact rule → text → hidden. +// +// Terminals can't scale glyphs, so "responsive" means picking a layout that +// fits the available columns. Thresholds are picked so each tier reads +// comfortably without forcing wrap or truncation drift on box-drawing edges. +const HIDE_BELOW = 34 +const COMPACT_FROM = 58 + +const clip = (s: string, w: number) => + w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s + + +const ruleIn = (label: string, w: number) => { + const f = clip(label, Math.max(1, w - 4)) + const slack = Math.max(0, w - f.length - 2) + const left = slack >> 1 + + return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}` +} + +function CompactBanner({ cols, t }: { cols: number; t: Theme }) { + // -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap. + const w = Math.max(28, cols - 4) + + return ( + + {ruleIn(t.brand.name, w)} + {'─'.repeat(w)} + + ) +} + +export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) { + const term = useStdout().stdout?.columns ?? 80 + const cols = Math.max(1, Math.min(term, maxWidth ?? term)) + + if (cols < HIDE_BELOW) { + return null + } + + const logoLines = logo(t.color, t.bannerLogo || undefined) + const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH + + if (cols >= logoW + 2) { + return ( + + + + ) + } + + if (cols >= COMPACT_FROM) { + return + } + + const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name) + + return ( + + {t.brand.icon} {name} + + ) +} + +// ── Collapsible helpers ────────────────────────────────────────────── + +function CollapseToggle({ + count, + open, + suffix, + t, + title, + onToggle +}: { + count?: number + open: boolean + suffix?: string + t: Theme + title: string + onToggle: () => void +}) { + return ( + + {open ? '▾ ' : '▸ '} + + {title} + + {typeof count === 'number' ? ( + ({count}) + ) : null} + {suffix ? ( + {suffix} + ) : null} + + ) +} + +// ── SessionPanel ───────────────────────────────────────────────────── + +const SKILLS_MAX = 8 +const TOOLSETS_MAX = 8 + +export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { + const term = useStdout().stdout?.columns ?? 100 + const cols = Math.max(20, Math.min(term, maxWidth ?? term)) + const heroLines = caduceus(t.color, t.bannerHero || undefined) + const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) + const wide = cols >= 90 && leftW + 40 < cols + const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) + const lineBudget = Math.max(12, w - 2) + const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + + // ── Local collapse state for each section ── + const [toolsOpen, setToolsOpen] = useState(true) + const [skillsOpen, setSkillsOpen] = useState(false) + const [systemOpen, setSystemOpen] = useState(false) + const [mcpOpen, setMcpOpen] = useState(false) + + const truncLine = (pfx: string, items: string[]) => { + let line = '' + let shown = 0 + + for (const item of [...items].sort()) { + const next = line ? `${line}, ${item}` : item + + if (pfx.length + next.length > lineBudget) { + return line ? `${line}, …+${items.length - shown}` : `${item}, …` + } + + line = next + shown++ + } + + return line + } + + // ── Collapsible skills section ── + const skillEntries = Object.entries(info.skills ?? {}).sort() + const skillsTotal = flat(info.skills ?? {}).length + const skillsCatCount = skillEntries.length + + const skillsBody = () => { + if (info.lazy && skillEntries.length === 0) { + return + } + + const shown = skillEntries.slice(0, SKILLS_MAX) + const overflow = skillEntries.length - SKILLS_MAX + + return ( + <> + {shown.map(([k, vs]) => ( + + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} + + ))} + {overflow > 0 && ( + (and {overflow} more categories…) + )} + + ) + } + + // ── Collapsible tools section ── + const toolEntries = Object.entries(info.tools ?? {}).sort() + const toolsTotal = flat(info.tools ?? {}).length + + const toolsBody = () => { + const shown = toolEntries.slice(0, TOOLSETS_MAX) + const overflow = toolEntries.length - TOOLSETS_MAX + + return ( + <> + {shown.map(([k, vs]) => ( + + {strip(k)}: + {truncLine(strip(k) + ': ', vs)} + + ))} + {overflow > 0 && ( + (and {overflow} more toolsets…) + )} + + ) + } + + // ── Collapsible MCP section ── + const mcpBody = () => ( + <> + {(info.mcp_servers ?? []).map(s => ( + + {` ${s.name} `} + {`[${s.transport}]`} + : + {s.connected ? ( + + {s.tools} tool{s.tools === 1 ? '' : 's'} + + ) : ( + failed + )} + + ))} + + ) + + // ── System prompt body ── + const sysPromptLen = (info.system_prompt ?? '').length + + const systemBody = () => { + if (sysPromptLen === 0) { + return No system prompt loaded. + } + + return ( + + {info.system_prompt} + + ) + } + + return ( + + {wide && ( + + + + + + {info.model.split('/').pop()} + + + + {info.cwd || process.cwd()} + + + {sid && ( + + Session: + {sid} + + )} + + )} + + + {wide ? ( + + + {t.brand.name} + {` v${info.version || '0.1.0'}`} + {info.release_date ? ` (${info.release_date})` : ''} + + + ) : ( + // Narrow layout hides the hero column; surface model/cwd/session + // here so they aren't lost. + + + {info.model.split('/').pop()} + + + {info.cwd || process.cwd()} + + {sid && ( + + Session: + {sid} + + )} + + )} + + {/* ── Tools (expanded by default) ── */} + + setToolsOpen(v => !v)} + open={toolsOpen} + t={t} + title="Available Tools" + /> + {toolsOpen && toolsBody()} + + + {/* ── Skills (collapsed by default) ── */} + + setSkillsOpen(v => !v)} + open={skillsOpen} + suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined} + t={t} + title="Available Skills" + /> + {skillsOpen && skillsBody()} + + + {/* ── System Prompt (collapsed by default) ── */} + {sysPromptLen > 0 && ( + + setSystemOpen(v => !v)} + open={systemOpen} + suffix={`— ${sysPromptLen.toLocaleString()} chars`} + t={t} + title="System Prompt" + /> + {systemOpen && systemBody()} + + )} + + {/* ── MCP Servers (collapsed by default) ── */} + {info.mcp_servers && info.mcp_servers.length > 0 && ( + + setMcpOpen(v => !v)} + open={mcpOpen} + suffix="connected" + t={t} + title="MCP Servers" + /> + {mcpOpen && mcpBody()} + + )} + + + + + {toolsTotal} tools{' · '} + {skillsTotal} skills + {info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''} + {' · '} + /help for commands + + + {typeof info.update_behind === 'number' && info.update_behind > 0 && ( + + ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind + + {' '} + - run{' '} + + + {info.update_command || 'coder update'} + + + {' '} + to update + + + )} + + + ) +} + +export function Panel({ sections, t, title }: PanelProps) { + return ( + + + + {title} + + + + {sections.map((sec, si) => ( + 0 ? 1 : 0}> + {sec.title && ( + + {sec.title} + + )} + + {sec.rows?.map(([k, v], ri) => ( + + {k.padEnd(20)} + {v} + + ))} + + {sec.items?.map((item, ii) => ( + + {item} + + ))} + + {sec.text && {sec.text}} + + ))} + + ) +} + +interface PanelProps { + sections: PanelSection[] + t: Theme + title: string +} + +interface SessionPanelProps { + info: SessionInfo + maxWidth?: number + sid?: string | null + t: Theme +} diff --git a/packages/cli/src/components/coordinatorDashboard.tsx b/packages/cli/src/components/coordinatorDashboard.tsx new file mode 100644 index 0000000..3f5945f --- /dev/null +++ b/packages/cli/src/components/coordinatorDashboard.tsx @@ -0,0 +1,244 @@ +/** + * CoordinatorDashboard — TUI component for Coordinator mode status. + * + * Shows: + * - Coordinator mode status (enabled / team / max workers) + * - Active workers list from SubagentNode tree + * - Quick summary of running / completed counts + * + * Used as an overlay panel launched via the TUI. + */ + +import React from 'react' +import { Box, NoSelect, Text } from '@coder/tui' +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { $delegationState } from '../app/delegationStore.js' +import { $uiState } from '../app/uiStore.js' +import { useTurnSelector } from '../app/turnStore.js' +import { buildSubagentTree, treeTotals } from '../lib/subagentTree.js' +import type { Theme } from '../theme.js' + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface CoordinatorDashboardProps { + /** Theme from UI state */ + t: Theme + /** Callback to close the dashboard */ + onClose: () => void +} + +// --------------------------------------------------------------------------- +// Worker Row +// --------------------------------------------------------------------------- + +interface WorkerRowProps { + agentId: string + goal: string + status: string + apiCalls: number + isActive: boolean + t: Theme +} + +function WorkerRow({ agentId, goal, status, apiCalls, isActive, t }: WorkerRowProps) { + const statusColor = + status === 'running' + ? t.color.statusGood + : status === 'completed' + ? t.color.muted + : status === 'error' || status === 'failed' + ? t.color.error + : t.color.muted + + const statusIcon = + status === 'running' + ? '▶' + : status === 'completed' + ? '✓' + : status === 'error' || status === 'failed' + ? '✗' + : '○' + + return ( + + + {' '} + {statusIcon} + {' '} + + + {agentId.slice(0, 8)} + + + {' '} + {goal.slice(0, 40)} + {goal.length > 40 ? '…' : ''} + + {apiCalls > 0 ? ( + + {' '} + {apiCalls} + {' calls'} + + ) : null} + + ) +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +export function CoordinatorDashboard({ t, onClose }: CoordinatorDashboardProps) { + const delegation = useStore($delegationState) + const subagents = useTurnSelector((state) => state.subagents) + const ui = useStore($uiState) + const [, setNow] = useState(() => Date.now()) + + // Periodic refresh for duration labels + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 2000) + return () => clearInterval(id) + }, []) + + const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const totals = useMemo(() => treeTotals(tree), [tree]) + + const coordinatorEnabled = + process.env.CODER_COORDINATOR_MODE === 'true' + const teamId = process.env.CODER_TEAM_ID ?? '(none)' + const maxWorkers = process.env.CODER_MAX_WORKERS + ? parseInt(process.env.CODER_MAX_WORKERS, 10) + : 3 + + const activeCount = totals.activeCount + const totalCount = totals.descendantCount + + // Filter subagents using tree (SubagentNode[]) with item.status + const runningAgents = tree.filter((node) => node.item.status === 'running') + const completedAgents = tree.filter((node) => node.item.status === 'completed' || node.item.status === 'failed' || node.item.status === 'error') + const completedCount = completedAgents.length + const failedCount = tree.filter((node) => node.item.status === 'failed' || node.item.status === 'error').length + + return ( + + + {/* Header */} + + + {'⚑ Coordinator Dashboard'} + + + + Esc to close + + + + {/* Divider */} + {'─'.repeat(40)} + + {/* Status Section */} + + + Status + + + Mode: + + {coordinatorEnabled ? 'Enabled' : 'Disabled'} + + + + Team: + {teamId} + + + Max workers: + {maxWorkers} + + + Delegation: + + {delegation.paused ? 'Paused' : 'Active'} + + + + + {/* Worker Summary */} + + + Workers + + + ▶ {activeCount} active + {' ✓ '}{completedCount} completed + {failedCount > 0 ? ( + {' ✗ '}{failedCount} failed + ) : null} + {' Σ '}{totalCount} total + + + + {/* Active Workers List */} + {runningAgents.length > 0 && ( + + Active workers: + {runningAgents.slice(0, 10).map((node) => ( + + ))} + {runningAgents.length > 10 ? ( + + {' ... and '} + {runningAgents.length - 10} + {' more'} + + ) : null} + + )} + + {/* Completed Workers (collapsed) */} + {completedAgents.length > 0 && ( + + + ✓ {completedAgents.length} completed worker(s) — check transcript for results + + + )} + + {/* No Workers */} + {totalCount === 0 && ( + + + No workers spawned yet. Use task delegation to create workers. + + + )} + + {/* Divider */} + {'─'.repeat(40)} + + {/* Quick Info */} + + + Model: {ui.info?.model ?? 'unknown'} + + + Session: {ui.sid?.slice(0, 8) ?? 'none'} + + + + + ) +} diff --git a/packages/cli/src/components/diffView.tsx b/packages/cli/src/components/diffView.tsx new file mode 100644 index 0000000..cdf82e5 --- /dev/null +++ b/packages/cli/src/components/diffView.tsx @@ -0,0 +1,429 @@ +/** + * DiffView — Interactive unified / split diff viewer + * + * Renders as a full-overlay component in the TUI. Uses Myers diff from + * @coder/shared. Supports keyboard navigation (j/k/↑↓), view mode toggling + * (u/s), and optional accept/reject callbacks. + */ +import React, { useCallback, useMemo, useState } from 'react' +import { Box, Text, useInput } from '@coder/tui' +import { diffLines } from '@coder/shared' +import type { DiffEdit } from '@coder/shared' + +import type { DiffViewState } from '../app/interfaces.js' +import { patchOverlayState } from '../app/overlayStore.js' +import type { Theme } from '../theme.js' + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface DiffViewProps { + state: DiffViewState + t: Theme +} + +// --------------------------------------------------------------------------- +// Line helper +// --------------------------------------------------------------------------- + +interface DiffLine { + type: 'add' | 'del' | 'ctx' | 'hunk' + oldNum: number | null + newNum: number | null + text: string +} + +/** + * Convert raw diff edits into numbered DiffLine rows (unified format). + */ +function buildUnifiedLines( + edits: DiffEdit[], + oldLabel: string, + newLabel: string, +): DiffLine[] { + const lines: DiffLine[] = [] + let oldLine = 0 + let newLine = 0 + + // Build hunks the same way shared/src/utils/diff.ts does + const hunks: DiffEdit[][] = [] + let currentHunk: DiffEdit[] = [] + let ctxBeforeHunk = 0 + + for (const edit of edits) { + if (edit.type === 'equal') { + if (currentHunk.length === 0) { + ctxBeforeHunk++ + } else { + currentHunk.push(edit) + } + } else { + // First change: include up to 3 context lines before + if (currentHunk.length === 0 && ctxBeforeHunk > 0) { + const start = Math.max(0, edits.indexOf(edit) - Math.min(ctxBeforeHunk, 3)) + for (let k = start; k < edits.indexOf(edit); k++) { + if (edits[k]!.type === 'equal') currentHunk.push(edits[k]!) + } + } + currentHunk.push(edit) + ctxBeforeHunk = 0 + } + // End hunk when we have 3+ trailing context equal lines + if (edit.type === 'equal' && currentHunk.length > 0) { + const remaining = edits.slice(edits.indexOf(edit) + 1) + if (remaining.filter(e => e.type !== 'equal').length === 0) { + // Last change in file — include remaining context + for (const r of remaining) { + if (r.type === 'equal') currentHunk.push(r) + } + hunks.push(currentHunk) + currentHunk = [] + break + } + if (edit === edits[edits.indexOf(edit)] && ctxBeforeHuntEnd(edits, edits.indexOf(edit))) { + hunks.push(currentHunk) + currentHunk = [] + } + } + } + if (currentHunk.length > 0) hunks.push(currentHunk) + + // Fallback: if no hunks, create one big hunk + if (hunks.length === 0 && edits.length > 0) { + hunks.push([...edits]) + } + + for (const hunk of hunks) { + const hasChanges = hunk.some(e => e.type !== 'equal') + if (!hasChanges) continue + + let oldStart = 0 + let newStart = 0 + let oldCount = 0 + let newCount = 0 + for (const e of hunk) { + if (e.type === 'equal') { oldCount++; newCount++; } + else if (e.type === 'delete') oldCount++ + else if (e.type === 'insert') newCount++ + } + // Compute line numbers + oldStart = edits.indexOf(hunk[0]!) + 1 // rough estimate + newStart = oldStart // simplified + + lines.push({ + type: 'hunk', + oldNum: null, + newNum: null, + text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`, + }) + + for (const edit of hunk) { + oldLine++ + newLine++ + if (edit.type === 'equal') { + lines.push({ type: 'ctx', oldNum: oldLine, newNum: newLine, text: edit.value ?? '' }) + } else if (edit.type === 'delete') { + lines.push({ type: 'del', oldNum: oldLine, newNum: null, text: edit.value ?? '' }) + newLine-- + } else if (edit.type === 'insert') { + lines.push({ type: 'add', oldNum: null, newNum: newLine, text: edit.value ?? '' }) + oldLine-- + } + } + } + + return lines +} + +function ctxBeforeHuntEnd(edits: DiffEdit[], idx: number): boolean { + let eqCount = 0 + for (let i = idx + 1; i < edits.length; i++) { + if (edits[i]!.type === 'equal') eqCount++ + else return eqCount >= 3 + if (eqCount >= 3) return true + } + return false +} + +/** + * Split mode: produce paired [oldLine, newLine] for side-by-side. + */ +interface SplitRow { + oldNum: number | null + oldText: string + oldType: 'del' | 'ctx' | 'empty' + newNum: number | null + newText: string + newType: 'add' | 'ctx' | 'empty' +} + +function buildSplitRows(edits: DiffEdit[]): SplitRow[] { + const rows: SplitRow[] = [] + let oi = 1 + let ni = 1 + + for (const edit of edits) { + if (edit.type === 'equal') { + rows.push({ + oldNum: oi++, oldText: edit.value ?? '', oldType: 'ctx', + newNum: ni++, newText: edit.value ?? '', newType: 'ctx', + }) + } else if (edit.type === 'delete') { + // Pair delete with the next insert if possible + const next = edits[edits.indexOf(edit) + 1] + if (next?.type === 'insert') { + rows.push({ + oldNum: oi++, oldText: edit.value ?? '', oldType: 'del', + newNum: ni++, newText: next.value ?? '', newType: 'add', + }) + edits.splice(edits.indexOf(next), 1) + } else { + rows.push({ + oldNum: oi++, oldText: edit.value ?? '', oldType: 'del', + newNum: null, newText: '', newType: 'empty', + }) + } + } else if (edit.type === 'insert') { + rows.push({ + oldNum: null, oldText: '', oldType: 'empty', + newNum: ni++, newText: edit.value ?? '', newType: 'add', + }) + } + } + return rows +} + +// --------------------------------------------------------------------------- +// Line number formatter +// --------------------------------------------------------------------------- + +function fmtNum(n: number | null, width: number): string { + if (n == null) return ' '.repeat(width) + return String(n).padStart(width) +} + +const LINE_NUM_WIDTH = 4 + +// --------------------------------------------------------------------------- +// DiffView +// --------------------------------------------------------------------------- + +export function DiffView({ state, t }: DiffViewProps) { + const { oldContent, newContent, filePath, mode } = state + const [scrollOffset, setScrollOffset] = useState(0) + const [displayMode, setDisplayMode] = useState<'unified' | 'split'>(mode) + + const diffResult = useMemo( + () => diffLines(oldContent.split('\n'), newContent.split('\n')), + [oldContent, newContent], + ) + + const unifiedLines = useMemo( + () => buildUnifiedLines(diffResult.edits, '--- a/old', `+++ b/${filePath ?? 'new'}`), + [diffResult.edits, filePath], + ) + + const splitRows = useMemo( + () => buildSplitRows([...diffResult.edits]), + [diffResult.edits], + ) + + // -- keyboard handling -- + const close = useCallback(() => patchOverlayState({ diffView: null }), []) + + useInput((ch, key) => { + if (key.escape || (key.ctrl && ch.toLowerCase() === 'c')) return close() + + if (key.upArrow || ch === 'k') return setScrollOffset(o => Math.max(0, o - 1)) + if (key.downArrow || ch === 'j') return setScrollOffset(o => o + 1) + + if (ch === 'u') return setDisplayMode('unified') + if (ch === 's') return setDisplayMode('split') + + if (key.pageDown) return setScrollOffset(o => o + 10) + if (key.pageUp) return setScrollOffset(o => Math.max(0, o - 10)) + if (ch === 'g') return setScrollOffset(0) + if (ch === 'G') return setScrollOffset(Number.MAX_SAFE_INTEGER) + }) + + // -- compute viewport -- + const viewportHeight = 20 + const totalLines = displayMode === 'unified' ? unifiedLines.length : splitRows.length + const clampedOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, totalLines - viewportHeight))) + + // -- colours -- + const bgAdd = t.color.diffAdded + const bgDel = t.color.diffRemoved + const fgAdd = t.color.diffAddedWord + const fgDel = t.color.diffRemovedWord + const fgMuted = t.color.muted + const fgAccent = t.color.accent + const fgBody = t.color.text + const fgStatus = t.color.statusFg + + return ( + + {/* Header */} + + + + Diff + + {filePath ? — {filePath} : null} + ({diffResult.changeCount} changes) + + + + [ + + + u + + nified | + + s + + plit] + + + + {/* Divider */} + {'─'.repeat(40)} + + {/* Diff content */} + {displayMode === 'unified' ? ( + + ) : ( + + )} + + {/* Bottom bar */} + + + ↑↓/jk navigate · u unified · s split · g/G top/bottom · PgUp/PgDn page · Esc close + + + {clampedOffset + 1}-{Math.min(clampedOffset + viewportHeight, totalLines)} / {totalLines} + + + + ) +} + +// --------------------------------------------------------------------------- +// Unified view (single column) +// --------------------------------------------------------------------------- + +function UnifiedView({ + bgAdd, bgDel, fgAdd, fgDel, fgAccent, fgBody, fgMuted, + lines, offset, viewportHeight, +}: { + bgAdd: string; bgDel: string; fgAdd: string; fgDel: string + fgAccent: string; fgBody: string; fgMuted: string + lines: DiffLine[]; offset: number; viewportHeight: number +}) { + const visible = lines.slice(offset, offset + viewportHeight) + + return ( + + {visible.map((line, i) => { + const prefix = line.type === 'add' ? '+' : line.type === 'del' ? '-' : line.type === 'hunk' ? '@@' : ' ' + const bg = line.type === 'add' ? bgAdd : line.type === 'del' ? bgDel : undefined + const fg = line.type === 'hunk' ? fgAccent + : line.type === 'add' ? fgAdd + : line.type === 'del' ? fgDel + : line.type === 'ctx' ? fgMuted : fgBody + const bold = line.type === 'hunk' + + return ( + + {prefix}{line.text.slice(0, 100)} + + ) + })} + + ) +} + +// --------------------------------------------------------------------------- +// Split view (side by side) +// --------------------------------------------------------------------------- + +function SplitView({ + bgAdd, bgDel, fgAdd, fgDel, fgBody, fgMuted, + rows, offset, viewportHeight, +}: { + bgAdd: string; bgDel: string; fgAdd: string; fgDel: string + fgBody: string; fgMuted: string + rows: SplitRow[]; offset: number; viewportHeight: number +}) { + const visible = rows.slice(offset, offset + viewportHeight) + const colWidth = 48 + + return ( + + {/* Column headers */} + + + --- a/old + + + + +++ b/new + + + + {visible.map((row, i) => { + const oldBg = row.oldType === 'del' ? bgDel : undefined + const newBg = row.newType === 'add' ? bgAdd : undefined + const oldFg = row.oldType === 'del' ? fgDel : fgMuted + const newFg = row.newType === 'add' ? fgAdd : fgBody + + const oldPrefix = row.oldType === 'del' ? '-' : ' ' + const newPrefix = row.newType === 'add' ? '+' : ' ' + + const oldNum = fmtNum(row.oldNum, LINE_NUM_WIDTH) + const newNum = fmtNum(row.newNum, LINE_NUM_WIDTH) + + const oldText = (oldPrefix + row.oldText).slice(0, colWidth).padEnd(colWidth) + const newText = (newPrefix + row.newText).slice(0, colWidth).padEnd(colWidth) + + return ( + + + {oldNum} + {oldText} + + + + {newNum} + {newText} + + + ) + })} + + ) +} diff --git a/packages/cli/src/components/fileTree.tsx b/packages/cli/src/components/fileTree.tsx new file mode 100644 index 0000000..e268133 --- /dev/null +++ b/packages/cli/src/components/fileTree.tsx @@ -0,0 +1,289 @@ +/** + * fileTree.tsx — Project file tree sidebar component + * + * Renders a directory tree using Ink Box + Text primitives. + * Supports expand/collapse, tree-drawing glyphs, and git status colors. + * + * Integration: embedded in AppLayout as a left sidebar. + * Toggle: Ctrl+B (handled via useInput in the parent). + */ + +import React from 'react'; +import { Box, NoSelect, Text } from '@coder/tui'; +import { memo, useCallback, useMemo, useState } from 'react'; + +import type { FileNode } from '../lib/fileTreeBuilder.js'; +import { buildFileTree, toggleNode } from '../lib/fileTreeBuilder.js'; +import type { Theme } from '../theme.js'; + +// --------------------------------------------------------------------------- +// Tree-drawing helpers +// --------------------------------------------------------------------------- + +type TreeBranch = 'mid' | 'last' | 'none'; +type TreeRails = readonly boolean[]; + +/** + * Compute the lead string for a tree row. + * + * ``` + * rails=[true, false] branch="mid" → "│ ├─ " + * rails=[true, false] branch="last" → "│ └─ " + * rails=[] branch="none" → "" + * ``` + */ +function treeLead(rails: TreeRails, branch: TreeBranch): string { + if (branch === 'none') return ''; + const prefix = rails.map(on => (on ? '│ ' : ' ')).join(''); + const stem = branch === 'mid' ? '├─ ' : '└─ '; + return prefix + stem; +} + +function nextRails(rails: TreeRails, branch: TreeBranch): TreeRails { + if (branch === 'none') return rails; + return [...rails, branch === 'mid']; +} + +// --------------------------------------------------------------------------- +// Git status → color mapping +// --------------------------------------------------------------------------- + +const GIT_COLOR_KEYS = { + A: 'gitAdded', + M: 'gitModified', + D: 'gitDeleted', + U: 'gitUntracked', + R: 'gitModified', // renamed → same as modified +} as const; + +function gitStatusColor(status: string | undefined, t: Theme): string { + if (!status) return t.fileTree.file; + const key = GIT_COLOR_KEYS[status as keyof typeof GIT_COLOR_KEYS]; + return key ? t.fileTree[key] : t.fileTree.file; +} + +function gitStatusLabel(status: string | undefined): string { + if (!status) return ''; + return ` ${status}`; +} + +// --------------------------------------------------------------------------- +// FileTreeRow — single entry in the tree +// --------------------------------------------------------------------------- + +interface FileTreeRowProps { + node: FileNode; + branch: TreeBranch; + rails: TreeRails; + t: Theme; + onToggle: (path: string) => void; +} + +const FileTreeRow = memo(function FileTreeRow({ + node, + branch, + rails, + t, + onToggle, +}: FileTreeRowProps) { + const isDir = node.type === 'directory'; + const isOpen = node.expanded === true; + const isToggleable = isDir && (node.children?.length ?? 0) > 0; + + const lead = treeLead(rails, branch); + const color = isDir + ? t.fileTree.directory + : gitStatusColor(node.gitStatus, t); + + const gitLabel = gitStatusLabel(node.gitStatus); + + const handleClick = useCallback(() => { + if (isToggleable) { + onToggle(node.path); + } + }, [isToggleable, onToggle, node.path]); + + return ( + + + + + {lead} + + + + + {isToggleable ? (isOpen ? '▾ ' : '▸ ') : isDir ? ' ' : ' '} + {node.name} + {gitLabel ? ( + + {gitLabel} + + ) : null} + + + + {isDir && isOpen && node.children && node.children.length > 0 && ( + + {node.children.map((child, i) => ( + + ))} + + )} + + ); +}); + +// --------------------------------------------------------------------------- +// FileTree — top-level component +// --------------------------------------------------------------------------- + +export interface FileTreeProps { + /** Project root path */ + rootPath: string; + /** Theme for colors */ + t: Theme; + /** Maximum visible width in columns */ + maxWidth?: number; +} + +export const FileTree = memo(function FileTree({ + rootPath, + t, + maxWidth = 30, +}: FileTreeProps) { + const [tree, setTree] = useState(() => + buildFileTree(rootPath, { maxDepth: 3 }), + ); + + // Rebuild when rootPath changes + const [, setRootPath] = useState(rootPath); + if (rootPath !== '') { + // Track rootPath changes via a ref pattern to avoid stale closure + } + + const handleToggle = useCallback((path: string) => { + setTree(prevTree => { + // Shallow clone the tree to trigger re-render + const newTree = { ...prevTree, children: prevTree.children ? [...prevTree.children] : undefined }; + toggleNode(newTree, path); + return newTree; + }); + }, []); + + const children = tree.children ?? []; + + return ( + + + + {tree.name} + + + {' '} + ({children.length} items) + + + + {children.length === 0 ? ( + + (empty) + + ) : ( + + {children.map((child, i) => ( + + ))} + + )} + + ); +}); + +// --------------------------------------------------------------------------- +// Static / mock tree (for test / preview without filesystem access) +// --------------------------------------------------------------------------- + +export function createMockTree(): FileNode { + return { + name: 'coder-agent', + path: '/project/coder-agent', + type: 'directory', + depth: 0, + expanded: true, + children: [ + { + name: 'packages', + path: '/project/coder-agent/packages', + type: 'directory', + depth: 1, + expanded: true, + children: [ + { + name: 'core', + path: '/project/coder-agent/packages/core', + type: 'directory', + depth: 2, + expanded: false, + children: [ + { name: 'src', path: '/project/coder-agent/packages/core/src', type: 'directory', depth: 3, expanded: false, children: [] }, + ], + }, + { + name: 'cli', + path: '/project/coder-agent/packages/cli', + type: 'directory', + depth: 2, + expanded: false, + children: [ + { name: 'src', path: '/project/coder-agent/packages/cli/src', type: 'directory', depth: 3, expanded: false, children: [] }, + ], + }, + { + name: 'shared', + path: '/project/coder-agent/packages/shared', + type: 'directory', + depth: 2, + expanded: false, + children: [ + { name: 'src', path: '/project/coder-agent/packages/shared/src', type: 'directory', depth: 3, expanded: false, children: [] }, + ], + }, + ], + }, + { + name: 'package.json', + path: '/project/coder-agent/package.json', + type: 'file', + depth: 1, + gitStatus: 'M', + }, + { + name: 'README.md', + path: '/project/coder-agent/README.md', + type: 'file', + depth: 1, + }, + { + name: 'pnpm-lock.yaml', + path: '/project/coder-agent/pnpm-lock.yaml', + type: 'file', + depth: 1, + }, + ], + }; +} diff --git a/packages/cli/src/components/fpsOverlay.tsx b/packages/cli/src/components/fpsOverlay.tsx new file mode 100644 index 0000000..443dcf8 --- /dev/null +++ b/packages/cli/src/components/fpsOverlay.tsx @@ -0,0 +1,31 @@ +import React from 'react' +// FPS counter overlay (CODER_TUI_FPS=1). Zero-cost when disabled. + +import { Text } from '@coder/tui' +import { useStore } from '@nanostores/react' + +import { SHOW_FPS } from '../config/env.js' +import { $fpsState } from '../lib/fpsStore.js' +import type { Theme } from '../theme.js' + +const fpsColor = (fps: number, t: Theme) => + fps >= 50 ? t.color.statusGood : fps >= 30 ? t.color.statusWarn : t.color.error + +export function FpsOverlay({ t }: { t: Theme }) { + if (!SHOW_FPS) { + return null + } + + return +} + +function FpsOverlayInner({ t }: { t: Theme }) { + const { fps, lastDurationMs, totalFrames } = useStore($fpsState) + + // Zero-pad widths so digit churn doesn't jitter the corner. + return ( + + {fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames} + + ) +} diff --git a/packages/cli/src/components/helpHint.tsx b/packages/cli/src/components/helpHint.tsx new file mode 100644 index 0000000..940a6e0 --- /dev/null +++ b/packages/cli/src/components/helpHint.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { Box, Text } from '@coder/tui' + +import { HOTKEYS } from '../content/hotkeys.js' +import type { Theme } from '../theme.js' + +const COMMON_COMMANDS: [string, string][] = [ + ['/help', 'full list of commands + hotkeys'], + ['/clear', 'start a new session'], + ['/resume', 'resume a prior session'], + ['/details', 'control transcript detail level'], + ['/copy', 'copy selection or last assistant message'], + ['/quit', 'exit Coder'] +] + +const HOTKEY_PREVIEW = HOTKEYS.slice(0, 8) + +export function HelpHint({ t }: { t: Theme }) { + const labelW = Math.max( + ...COMMON_COMMANDS.map(([k]) => k.length), + ...HOTKEY_PREVIEW.map(([k]) => k.length) + ) + + const pad = (s: string) => s + ' '.repeat(Math.max(0, labelW - s.length + 2)) + + return ( + + + + + ? quick help + + + {' · type /help for the full panel · backspace to dismiss'} + + + + + + Common commands + + + + {COMMON_COMMANDS.map(([k, v]) => ( + + {pad(k)} + {v} + + ))} + + + + Hotkeys + + + + {HOTKEY_PREVIEW.map(([k, v]) => ( + + {pad(k)} + {v} + + ))} + + + ) +} diff --git a/packages/cli/src/components/markdown.tsx b/packages/cli/src/components/markdown.tsx new file mode 100644 index 0000000..de135e3 --- /dev/null +++ b/packages/cli/src/components/markdown.tsx @@ -0,0 +1,1150 @@ +import React from 'react' +import { Box, Link, stringWidth, Text } from '@coder/tui' +import { Fragment, memo, type ReactNode, useMemo } from 'react' + +import { ensureEmojiPresentation } from '../lib/emoji.js' +import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js' +import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js' +import { highlightLine, isHighlightable } from '../lib/syntax.js' +import type { Theme } from '../theme.js' + +// `\boxed{X}` regions in `texToUnicode` output are marked with the +// non-printable U+0001 / U+0002 sentinels. Split on them and render the +// boxed segment with `inverse + bold` so it reads as a highlighter-pen +// emphasis on top of whatever color the parent `` is using (the +// theme accent for math). The leading / trailing space inside the +// highlight gives a one-cell visual margin so the highlight reads as a +// block, not a hug. +const renderMath = (text: string): ReactNode => { + if (!text.includes(BOX_OPEN)) { + return text + } + + const out: ReactNode[] = [] + let i = 0 + let key = 0 + + while (i < text.length) { + const start = text.indexOf(BOX_OPEN, i) + + if (start < 0) { + out.push(text.slice(i)) + + break + } + + if (start > i) { + out.push(text.slice(i, start)) + } + + const end = text.indexOf(BOX_CLOSE, start + 1) + + if (end < 0) { + out.push(text.slice(start)) + + break + } + + out.push( + + {' '} + {text.slice(start + 1, end)}{' '} + + ) + + i = end + 1 + } + + return out +} + +const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ +const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/ +const HR_RE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/ +const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/ +const SETEXT_RE = /^\s{0,3}(=+|-+)\s*$/ +const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/ +const DEF_RE = /^\s*:\s+(.+)$/ +const BULLET_RE = /^(\s*)[-+*]\s+(.*)$/ +const TASK_RE = /^\[( |x|X)\]\s+(.*)$/ +const NUMBERED_RE = /^(\s*)(\d+)[.)]\s+(.*)$/ +const QUOTE_RE = /^\s*(?:>\s*)+/ +const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/ +const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)' +const MD_IDENTIFIER_RE = '[A-Za-z_][A-Za-z0-9_]*' +const MD_DUNDER_IDENTIFIER_RE = `(?:${MD_IDENTIFIER_RE}__(?!\\w))` +const MD_UNDERSCORE_BOLD_RE = `(?\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>`, // 5 autolink + `~~(.+?)~~`, // 6 strike + `\`([^\\\`]+)\``, // 7 code + `\\*\\*(.+?)\\*\\*`, // 8 bold * + MD_UNDERSCORE_BOLD_RE, // 9 bold _ + `\\*(.+?)\\*`, // 10 italic * + MD_UNDERSCORE_ITALIC_RE, // 11 italic _ + `==(.+?)==`, // 12 highlight + `\\[\\^([^\\]]+)\\]`, // 13 footnote ref + `\\^([^^\\s][^^]*?)\\^`, // 14 superscript + `~([A-Za-z0-9]{1,8})~`, // 15 subscript + `(https?:\\/\\/[^\\s<]+)`, // 16 bare URL — wrapped so it owns its own + // capture group; without this, the math + // spans below would land in m[16] and the + // MdInline dispatcher would treat them as + // bare URLs and render them as autolinks. + `(? Math.floor(s.replace(/\t/g, ' ').length / 2) + +const splitRow = (row: string) => + row + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map(c => c.trim()) + +const isTableDivider = (row: string) => { + const cells = splitRow(row) + + return cells.length > 1 && cells.every(c => TABLE_DIVIDER_CELL_RE.test(c)) +} + +const autolinkUrl = (raw: string) => + raw.startsWith('mailto:') || raw.startsWith('http') || !raw.includes('@') ? raw : `mailto:${raw}` + +const defaultLinkLabel = (url: string) => + url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : /^https?:\/\//i.test(url) ? urlSlugTitleLabel(url) : url + +const pickFallbackLabel = (label: string | undefined, target: string): string | undefined => { + const trimmed = label?.trim() + + if (!trimmed) { + return undefined + } + + return normalizeExternalUrl(trimmed) === target ? undefined : trimmed +} + +interface ResolvedLinkProps { + fallbackLabel?: string + t: Theme + url: string +} + +function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) { + const fetched = useLinkTitle(url) + const display = fetched || fallbackLabel || defaultLinkLabel(url) + + return ( + + + {display} + + + ) +} + +const renderResolvedLink = (k: number, t: Theme, rawUrl: string, label?: string) => { + const target = normalizeExternalUrl(rawUrl) + + return +} + +export const stripInlineMarkup = (v: string) => + v + .replace(/!\[(.*?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '[image: $1] $2') + .replace(/\[(.+?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(STRIP_UNDERSCORE_BOLD_RE, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(STRIP_UNDERSCORE_ITALIC_RE, '$1') + .replace(/==(.+?)==/g, '$1') + .replace(/\[\^([^\]]+)\]/g, '[$1]') + .replace(/\^([^^\s][^^]*?)\^/g, '^$1') + .replace(/~([A-Za-z0-9]{1,8})~/g, '_$1') + .replace(/(? + +const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => { + // Guard: empty table + if (rows.length === 0 || rows[0]!.length === 0) return null + + const cellDisplayWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) + + // Minimum width: longest word in a cell (to avoid breaking words) + const minCellWidth = (raw: string) => { + const text = stripInlineMarkup(raw) + const words = text.split(/\s+/).filter(w => w.length > 0) + if (words.length === 0) return MIN_COL_WIDTH + return Math.max(...words.map(w => stringWidth(w)), MIN_COL_WIDTH) + } + + const numCols = rows[0]!.length + + // Normalize ragged rows: ensure every row has exactly numCols cells + const normalizedRows = rows.map(row => { + if (row.length >= numCols) return row.slice(0, numCols) + return [...row, ...Array(numCols - row.length).fill('')] + }) + + // Ideal widths: max cell content per column + const idealWidths = normalizedRows[0]!.map((_, ci) => + Math.max(...normalizedRows.map(r => cellDisplayWidth(r[ci] ?? '')), MIN_COL_WIDTH) + ) + + // Min widths: longest word per column + const minWidths = normalizedRows[0]!.map((_, ci) => + Math.max(...normalizedRows.map(r => minCellWidth(r[ci] ?? '')), MIN_COL_WIDTH) + ) + + // Available width: cols minus table padding minus column gaps minus safety. + // transcriptBodyWidth (source of cols) subtracts message gutter + scrollbar, + // but NOT this table's paddingLeft — we subtract it here. + const gapOverhead = (numCols - 1) * COL_GAP + const availableWidth = cols + ? Math.max(cols - TABLE_PADDING_LEFT - gapOverhead - SAFETY_MARGIN, numCols * MIN_COL_WIDTH) + : Infinity + + const totalIdeal = idealWidths.reduce((a, b) => a + b, 0) + const totalMin = minWidths.reduce((a, b) => a + b, 0) + + let columnWidths: number[] + let needsWrap = false + + if (totalIdeal <= availableWidth) { + // Tier 1: everything fits at ideal widths + columnWidths = idealWidths + } else if (totalMin <= availableWidth) { + // Tier 2: proportional shrink — distribute extra space beyond minimums + needsWrap = true + const extraSpace = availableWidth - totalMin + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) + const totalOverflow = overflows.reduce((a, b) => a + b, 0) + if (totalOverflow === 0) { + columnWidths = [...minWidths] + } else { + const rawAlloc = minWidths.map((min, i) => + min + (overflows[i]! / totalOverflow) * extraSpace + ) + columnWidths = rawAlloc.map(v => Math.floor(v)) + // Distribute rounding remainders to columns with largest fractional part + let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) + const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) + .sort((a, b) => b.frac - a.frac) + for (const { i } of fracs) { + if (remainder <= 0) break + columnWidths[i]!++ + remainder-- + } + } + } else { + // Tier 3: even min-widths don't fit — scale proportionally, allow hard breaks. + // NOTE: Math.max(..., MIN_COL_WIDTH) can push total above availableWidth when + // many columns are scaled below 3. This is caught by safetyOverflow → vertical fallback. + needsWrap = true + const scaleFactor = availableWidth / totalMin + const rawAlloc = minWidths.map(w => w * scaleFactor) + columnWidths = rawAlloc.map(v => Math.max(Math.floor(v), MIN_COL_WIDTH)) + let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) + const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) + .sort((a, b) => b.frac - a.frac) + for (const { i } of fracs) { + if (remainder <= 0) break + columnWidths[i]!++ + remainder-- + } + } + + // Grapheme-safe hard-break: prefer Intl.Segmenter, fall back to code-point split + const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' }) + : null + + const graphemes = (s: string): string[] => + segmenter + ? [...segmenter.segment(s)].map((seg: { segment: string }) => seg.segment) + : [...s] + + // Word-wrap plain text to fit within `width` display columns. + // Operates on stripped text for correct width measurement. + const wrapCell = (raw: string, width: number, hard: boolean): string[] => { + const text = stripInlineMarkup(raw) + if (width <= 0) return [text] + if (stringWidth(text) <= width) return [text] + + const words = text.split(/\s+/).filter(w => w.length > 0) + const lines: string[] = [] + let current = '' + let currentWidth = 0 + + for (const word of words) { + const w = stringWidth(word) + if (currentWidth === 0) { + if (hard && w > width) { + for (const ch of graphemes(word)) { + const cw = stringWidth(ch) + if (currentWidth + cw > width && current) { + lines.push(current) + current = '' + currentWidth = 0 + } + current += ch + currentWidth += cw + } + } else { + current = word + currentWidth = w + } + } else if (currentWidth + 1 + w <= width) { + current += ' ' + word + currentWidth += 1 + w + } else { + lines.push(current) + current = word + currentWidth = w + } + } + if (current) lines.push(current) + return lines.length > 0 ? lines : [''] + } + + const isHard = totalMin > availableWidth // tier 3 needs hard word breaks + const sep = columnWidths.map(w => '─'.repeat(Math.max(1, w))).join(' ') + + // When wrapping isn't needed, build single-line strings per row. + // All cells render as plain text via stripInlineMarkup. + if (!needsWrap) { + const buildRowString = (row: string[]): string => + row.map((cell, ci) => { + const text = stripInlineMarkup(cell) + const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(text))) + const gap = ci < numCols - 1 ? ' ' : '' + return text + pad + gap + }).join('') + + return ( + + {normalizedRows.map((row, ri) => ( + + + {buildRowString(row)} + + {ri === 0 && normalizedRows.length > 1 ? ( + {sep} + ) : null} + + ))} + + ) + } + + // Wrapping path: build multi-line rows as complete strings. + type LineEntry = { text: string; kind: 'header' | 'separator' | 'body' } + + const buildRowLines = (row: string[]): string[] => { + const cellLines = row.map((cell, ci) => + wrapCell(cell, columnWidths[ci]!, isHard) + ) + const maxLines = Math.max(...cellLines.map(l => l.length), 1) + + const result: string[] = [] + for (let li = 0; li < maxLines; li++) { + let line = '' + for (let ci = 0; ci < numCols; ci++) { + const cl = cellLines[ci] ?? [''] + const cellText = li < cl.length ? cl[li]! : '' + const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(cellText))) + line += cellText + pad + if (ci < numCols - 1) line += ' ' + } + result.push(line) + } + return result + } + + // Build all lines with metadata for styling, tracking tallest body row + const allEntries: LineEntry[] = [] + let tallestBodyRow = 0 + normalizedRows.forEach((row, ri) => { + const kind = ri === 0 ? 'header' as const : 'body' as const + const rowLines = buildRowLines(row) + rowLines.forEach(text => allEntries.push({ text, kind })) + if (ri > 0) tallestBodyRow = Math.max(tallestBodyRow, rowLines.length) + if (ri === 0 && normalizedRows.length > 1) { + allEntries.push({ text: sep, kind: 'separator' }) + } + }) + + // Post-render safety condition: compute max line width. + const maxLineWidth = Math.max(...allEntries.map(e => stringWidth(e.text))) + const safetyOverflow = cols != null && maxLineWidth > cols - TABLE_PADDING_LEFT - SAFETY_MARGIN + + // Scaled vertical threshold — 2-3 col tables stay tabular even with tall cells + const maxRowLinesThreshold = numCols <= 3 ? 8 : numCols <= 6 ? 5 : 4 + + const useVertical = tallestBodyRow > maxRowLinesThreshold || safetyOverflow + + if (useVertical) { + // Edge case: header-only table + if (normalizedRows.length <= 1) { + return ( + + + {normalizedRows[0]!.map(h => stripInlineMarkup(h)).join(' · ')} + + + ) + } + + const headers = normalizedRows[0]! + const dataRows = normalizedRows.slice(1) + const sepWidth = Math.max(1, cols ? Math.min(cols - TABLE_PADDING_LEFT - 1, 40) : 40) + + return ( + + {dataRows.map((row, ri) => ( + + {ri > 0 ? ( + {'─'.repeat(sepWidth)} + ) : null} + {headers.map((header, ci) => { + const cell = row[ci] ?? '' + const label = stripInlineMarkup(header) || `Col ${ci + 1}` + return ( + + {label}: + {' '}{stripInlineMarkup(cell)} + + ) + })} + + ))} + + ) + } + + // Render wrapped horizontal rows — one per visual line. + return ( + + {allEntries.map((entry, i) => ( + + {entry.text} + + ))} + + ) +} + +function MdInline({ t, text }: { t: Theme; text: string }) { + const parts: ReactNode[] = [] + + let last = 0 + + for (const m of text.matchAll(INLINE_RE)) { + const i = m.index ?? 0 + const k = parts.length + + if (i > last) { + parts.push({text.slice(last, i)}) + } + + if (m[1] && m[2]) { + parts.push( + + [image: {m[1]}] {m[2]} + + ) + } else if (m[3] && m[4]) { + parts.push(renderResolvedLink(parts.length, t, m[4], m[3])) + } else if (m[5]) { + parts.push(renderResolvedLink(parts.length, t, autolinkUrl(m[5]), m[5].replace(/^mailto:/, ''))) + } else if (m[6]) { + parts.push( + + + + ) + } else if (m[7]) { + // Code is the one wrap that does NOT recurse — inline `code` spans + // are verbatim by definition. Letting MdInline reprocess them + // would corrupt regex examples and shell snippets. + parts.push( + + {m[7]} + + ) + } else if (m[8] ?? m[9]) { + // Recurse into bold / italic / strike / highlight so nested + // `$...$` math (and other inline tokens) inside a `**bolded + // statement with $\mathbb{Z}$ math**` actually render. Without + // this the inner content is dropped into a single `` + // verbatim and the math renderer never sees it. + parts.push( + + + + ) + } else if (m[10] ?? m[11]) { + parts.push( + + + + ) + } else if (m[12]) { + parts.push( + + + + ) + } else if (m[13]) { + parts.push( + + [{m[13]}] + + ) + } else if (m[14]) { + parts.push( + + ^{m[14]} + + ) + } else if (m[15]) { + parts.push( + + _{m[15]} + + ) + } else if (m[16]) { + // Bare URL — trim trailing prose punctuation into a sibling text node + // so `see https://x.com/, which…` keeps the comma outside the link. + const url = m[16].replace(/[),.;:!?]+$/g, '') + + parts.push(renderResolvedLink(parts.length, t, url)) + + if (url.length < m[16].length) { + parts.push({m[16].slice(url.length)}) + } + } else if (m[17] ?? m[18]) { + // Inline math is run through `texToUnicode` (Greek letters, ℕℤℚℝ, + // operators, sub/superscripts, fractions) and rendered in italic + // accent. Italic is the disambiguator — links use accent+underline, + // so without italic readers can't tell `\mathbb{R}` (math) from a + // hyperlinked word. Anything `texToUnicode` doesn't recognise is + // preserved verbatim, so unfamiliar commands just look like their + // raw LaTeX rather than vanishing. + parts.push( + + {renderMath(texToUnicode(m[17] ?? m[18]!))} + + ) + } + + last = i + m[0].length + } + + if (last < text.length) { + parts.push({text.slice(last)}) + } + + return {parts.length ? parts : text} +} + +// Cross-instance parsed-children cache: useMemo's per-instance cache dies +// on remount, so virtualization re-parses every row that scrolls back into +// view. Theme-keyed WeakMap drops stale palettes; inner Map is LRU-bounded. +const MD_CACHE_LIMIT = 512 +const mdCache = new WeakMap>() + +const cacheBucket = (t: Theme) => { + const b = mdCache.get(t) + + if (b) { + return b + } + + const fresh = new Map() + mdCache.set(t, fresh) + + return fresh +} + +const cacheGet = (b: Map, key: string) => { + const v = b.get(key) + + if (v) { + b.delete(key) + b.set(key, v) + } + + return v +} + +const cacheSet = (b: Map, key: string, v: ReactNode[]) => { + b.set(key, v) + + if (b.size > MD_CACHE_LIMIT) { + b.delete(b.keys().next().value!) + } +} + +function MdImpl({ cols, compact, t, text }: MdProps) { + const nodes = useMemo(() => { + const bucket = cacheBucket(t) + const cacheKey = `${compact ? '1' : '0'}|${cols ?? ''}|${text}` + const cached = cacheGet(bucket, cacheKey) + + if (cached) { + return cached + } + + const lines = ensureEmojiPresentation(text).split('\n') + const nodes: ReactNode[] = [] + + let prevKind: Kind = null + let i = 0 + + const gap = () => { + if (nodes.length && prevKind !== 'blank') { + nodes.push( ) + prevKind = 'blank' + } + } + + const start = (kind: Exclude) => { + if (prevKind && prevKind !== 'blank' && prevKind !== kind) { + gap() + } + + prevKind = kind + } + + while (i < lines.length) { + const line = lines[i]! + const key = nodes.length + + if (!line.trim()) { + if (!compact) { + gap() + } + + i++ + + continue + } + + if (AUDIO_DIRECTIVE_RE.test(line)) { + i++ + + continue + } + + const media = line.match(MEDIA_LINE_RE)?.[1] + + if (media) { + start('paragraph') + nodes.push( + + {'▸ '} + + + + {media} + + + + ) + i++ + + continue + } + + const fence = line.match(FENCE_RE) + + if (fence) { + const char = fence[1]![0] as '`' | '~' + const len = fence[1]!.length + const lang = fence[2]!.trim().toLowerCase() + const block: string[] = [] + + for (i++; i < lines.length; i++) { + const close = lines[i]!.match(FENCE_CLOSE_RE)?.[1] + + if (close && close[0] === char && close.length >= len) { + break + } + + block.push(lines[i]!) + } + + if (i < lines.length) { + i++ + } + + if (['md', 'markdown'].includes(lang)) { + start('paragraph') + nodes.push() + + continue + } + + start('code') + + const isDiff = lang === 'diff' + const highlighted = !isDiff && isHighlightable(lang) + + // Diff file header: extract `+++ b/path` from the block + const diffFileName = isDiff + ? (() => { + for (const line of block) { + const m = line.match(/^\+\+\+ b\/(.+)$/) + if (m) return m[1] + } + return undefined + })() + : undefined + + const MAX_DIFF_LINE = 120 + + nodes.push( + + {lang && !isDiff && {'─ ' + lang}} + {diffFileName && ( + + + diff{' '} + + — {diffFileName} + + )} + + {block.map((l, j) => { + if (highlighted) { + return ( + + {highlightLine(l, lang, t).map(([color, text], kk) => + color ? ( + + {text} + + ) : ( + {text} + ) + )} + + ) + } + + const add = isDiff && l.startsWith('+') + const del = isDiff && l.startsWith('-') + const hunk = isDiff && l.startsWith('@@') + + // Truncate long diff lines + const displayLine = isDiff && l.length > MAX_DIFF_LINE + ? l.slice(0, MAX_DIFF_LINE) + '…' + : l + + return ( + + {displayLine} + + ) + })} + + ) + + continue + } + + const mathOpen = line.match(MATH_BLOCK_OPEN_RE) + + if (mathOpen) { + const opener = mathOpen[1]! + const closeRe = opener === '$$' ? MATH_BLOCK_CLOSE_DOLLAR_RE : MATH_BLOCK_CLOSE_BRACKET_RE + const headRest = mathOpen[2] ?? '' + const block: string[] = [] + + // Single-line block: `$$x + y = z$$` or `\[x\]`. Capture inner content + // and emit the block immediately. Without this, the close-scan loop + // skips line `i` and treats the next opener as our closer, swallowing + // every paragraph in between. + const sameLineClose = headRest.match(closeRe) + + if (sameLineClose) { + const inner = sameLineClose[1]!.trim() + + start('code') + nodes.push( + + {inner ? {renderMath(texToUnicode(inner))} : null} + + ) + i++ + + continue + } + + // Multi-line block: scan ahead for a real closer before committing. + // If none exists in the rest of the document, render this line as a + // paragraph instead of consuming everything that follows. + let closeIdx = -1 + + for (let j = i + 1; j < lines.length; j++) { + if (closeRe.test(lines[j]!)) { + closeIdx = j + + break + } + } + + if (closeIdx < 0) { + start('paragraph') + nodes.push() + i++ + + continue + } + + if (headRest.trim()) { + block.push(headRest) + } + + for (let j = i + 1; j < closeIdx; j++) { + block.push(lines[j]!) + } + + const tail = lines[closeIdx]!.match(closeRe)![1]!.trimEnd() + + if (tail.trim()) { + block.push(tail) + } + + start('code') + nodes.push( + + {block.map((l, j) => ( + + {renderMath(texToUnicode(l))} + + ))} + + ) + i = closeIdx + 1 + + continue + } + + const heading = line.match(HEADING_RE)?.[2] + + if (heading) { + start('heading') + nodes.push( + + + + ) + i++ + + continue + } + + if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { + start('heading') + nodes.push( + + + + ) + i += 2 + + continue + } + + if (HR_RE.test(line)) { + start('rule') + nodes.push( + + {'─'.repeat(36)} + + ) + i++ + + continue + } + + const footnote = line.match(FOOTNOTE_RE) + + if (footnote) { + start('list') + nodes.push( + + [{footnote[1]}] + + ) + i++ + + while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { + nodes.push( + + + + + + ) + i++ + } + + continue + } + + if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { + start('list') + nodes.push( + + {line.trim()} + + ) + i++ + + while (i < lines.length) { + const def = lines[i]!.match(DEF_RE)?.[1] + + if (!def) { + break + } + + nodes.push( + + · + + + ) + i++ + } + + continue + } + + const bullet = line.match(BULLET_RE) + + if (bullet) { + start('list') + + const task = bullet[2]!.match(TASK_RE) + const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' + + nodes.push( + + + {marker} + + + + ) + i++ + + continue + } + + const numbered = line.match(NUMBERED_RE) + + if (numbered) { + start('list') + nodes.push( + + + {numbered[2]}. + + + + ) + i++ + + continue + } + + if (QUOTE_RE.test(line)) { + start('quote') + + const quoteLines: Array<{ depth: number; text: string }> = [] + + while (i < lines.length && QUOTE_RE.test(lines[i]!)) { + const prefix = lines[i]!.match(QUOTE_RE)?.[0] ?? '' + + quoteLines.push({ depth: (prefix.match(/>/g) ?? []).length, text: lines[i]!.slice(prefix.length) }) + i++ + } + + nodes.push( + + {quoteLines.map((ql, qi) => ( + + + │ + + + ))} + + ) + + continue + } + + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { + start('table') + + const rows: string[][] = [splitRow(line)] + + for (i += 2; i < lines.length && lines[i]!.includes('|') && lines[i]!.trim(); i++) { + rows.push(splitRow(lines[i]!)) + } + + nodes.push(renderTable(key, rows, t, cols)) + + continue + } + + if (/^<\/?details\b/i.test(line)) { + i++ + + continue + } + + const summary = line.match(/^(.*?)<\/summary>$/i)?.[1] + + if (summary) { + start('paragraph') + nodes.push( + + ▶ {summary} + + ) + i++ + + continue + } + + if (/^<\/?[^>]+>$/.test(line.trim())) { + start('paragraph') + nodes.push( + + {line.trim()} + + ) + i++ + + continue + } + + if (line.includes('|') && line.trim().startsWith('|')) { + start('table') + + const rows: string[][] = [] + + while (i < lines.length && lines[i]!.trim().startsWith('|')) { + const row = lines[i]!.trim() + + if (!/^[|\s:-]+$/.test(row)) { + rows.push(splitRow(row)) + } + + i++ + } + + if (rows.length) { + nodes.push(renderTable(key, rows, t, cols)) + } + + continue + } + + start('paragraph') + nodes.push() + i++ + } + + cacheSet(bucket, cacheKey, nodes) + + return nodes + }, [cols, compact, t, text]) + + return {nodes} +} + +export const Md = memo(MdImpl) + +type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null + +interface MdProps { + cols?: number + compact?: boolean + t: Theme + text: string +} diff --git a/packages/cli/src/components/maskedPrompt.tsx b/packages/cli/src/components/maskedPrompt.tsx new file mode 100644 index 0000000..9434104 --- /dev/null +++ b/packages/cli/src/components/maskedPrompt.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Box, Text } from '@coder/tui' +import { useState } from 'react' + +import type { Theme } from '../theme.js' + +import { TextInput } from './textInput.js' + +export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) { + const [value, setValue] = useState('') + + return ( + + + {icon} {label} + + + {sub && {sub}} + + + {'> '} + + + + ) +} + +interface MaskedPromptProps { + cols?: number + icon: string + label: string + onSubmit: (v: string) => void + sub?: string + t: Theme +} diff --git a/packages/cli/src/components/messageLine.tsx b/packages/cli/src/components/messageLine.tsx new file mode 100644 index 0000000..3e45e65 --- /dev/null +++ b/packages/cli/src/components/messageLine.tsx @@ -0,0 +1,246 @@ +import React from 'react' +import { Ansi, Box, NoSelect, Text } from '@coder/tui' +import { memo, useState } from 'react' + +import { TERMUX_TUI_MODE } from '../config/env.js' +import { LONG_MSG } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' +import { userDisplay } from '../domain/messages.js' +import { ROLE } from '../domain/roles.js' +import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js' +import { + boundedLiveRenderText, + compactPreview, + hasAnsi, + isPasteBackedText, + sanitizeAnsiForRender, + stripAnsi +} from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js' + +import { Md } from './markdown.js' +import { StreamingMd } from './streamingMarkdown.js' +import { ToolTrail } from './thinking.js' +import { TodoPanel } from './todoPanel.js' + +// Collapse threshold for long system messages (system prompt etc.) +const SYSTEM_COLLAPSE_CHARS = 400 + +export const MessageLine = memo(function MessageLine({ + cols, + compact, + detailsMode = 'collapsed', + detailsModeCommandOverride = false, + isStreaming = false, + msg, + reasoningActive = false, + reasoningStreaming = false, + sections, + t, + tools = [] +}: MessageLineProps) { + // Per-section overrides win over the global mode, so resolve each section + // we might consume here once and gate visibility on the *content-bearing* + // sections only — never on the global mode. A `trail` message feeds Tool + // calls + Activity; an assistant message with thinking/tools metadata + // feeds Thinking + Tool calls. Gating on every section would let + // `thinking` (expanded by default) keep an empty wrapper alive when only + // `tools` is hidden — exactly the empty-Box bug Copilot caught. + const thinkingMode = sectionMode('thinking', detailsMode, sections, detailsModeCommandOverride) + const toolsMode = sectionMode('tools', detailsMode, sections, detailsModeCommandOverride) + const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride) + const thinking = msg.thinking?.trim() ?? '' + + // Collapse toggle for long system messages + const systemIsLong = msg.role === 'system' && msg.text.length > SYSTEM_COLLAPSE_CHARS + const [systemOpen, setSystemOpen] = useState(false) + + if (msg.kind === 'trail' && msg.todos?.length) { + return ( + + ) + } + + if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) { + return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? ( + + + + ) : null + } + + if (msg.role === 'tool') { + const maxChars = Math.max(24, cols - 14) + const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text + const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text + const preview = compactPreview(stripped, maxChars) || '(empty tool result)' + + return ( + + {hasAnsi(msg.text) ? ( + + {safeAnsi} + + ) : ( + + {preview} + + )} + + ) + } + + const { body, glyph, prefix } = ROLE[msg.role](t) + const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt) + + const showDetails = + (toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking)) + + const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails) + + const content = (() => { + if (msg.kind === 'slash') { + return {msg.text} + } + + // ── Collapsible long system message (system prompt, AGENTS.md, etc.) ── + // MUST come before the hasAnsi check — system messages from the backend + // contain Rich markup escape codes that would otherwise hit full render. + if (systemIsLong) { + const firstLine = (msg.text.split('\n')[0] ?? '').trim().slice(0, 120) || '(system message)' + + return ( + + setSystemOpen(v => !v)}> + {systemOpen ? '▾ ' : '▸ '} + {firstLine} + + {' — '} + {msg.text.length.toLocaleString()} chars + + + {systemOpen && {sanitizeAnsiForRender(msg.text)}} + + ) + } + + if (msg.role !== 'user' && hasAnsi(msg.text)) { + return {sanitizeAnsiForRender(msg.text)} + } + + if (msg.role === 'assistant') { + const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE) + + return isStreaming ? ( + // Incremental markdown: split at the last stable block boundary so + // only the in-flight tail re-tokenizes per delta. See + // streamingMarkdown.tsx for the cost model. + + ) : ( + + ) + } + + if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { + const [head, ...rest] = userDisplay(msg.text).split('[long message]') + + return ( + + {head} + + [long message] + + {rest.join('')} + + ) + } + + return {msg.text} + })() + + // Diff segments (emitted by pushInlineDiffSegment between narration + // segments) need a blank line on both sides so the patch doesn't butt up + // against the prose around it. + const isDiffSegment = msg.kind === 'diff' + + return ( + + {showDetails && ( + + + + )} + + {showResponseSeparator && ( + + + └─ + + + Response + + + )} + + + + + {glyph}{' '} + + + + {content} + + + ) +}) + +export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean => + msg.role === 'assistant' && showDetails && /\S/.test(msg.text) + +interface MessageLineProps { + cols: number + compact?: boolean + detailsMode?: DetailsMode + detailsModeCommandOverride?: boolean + isStreaming?: boolean + msg: Msg + reasoningActive?: boolean + reasoningStreaming?: boolean + sections?: SectionVisibility + t: Theme + tools?: ActiveTool[] +} diff --git a/packages/cli/src/components/modelPicker.tsx b/packages/cli/src/components/modelPicker.tsx new file mode 100644 index 0000000..f5501fb --- /dev/null +++ b/packages/cli/src/components/modelPicker.tsx @@ -0,0 +1,1065 @@ +import React from 'react' +import { Box, Text, useInput, useStdout } from '@coder/tui' +import { useEffect, useMemo, useState } from 'react' + +import { providerDisplayNames } from '../domain/providers.js' +import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { ModelOptionProvider, ModelOptionsResponse } from '../gateway/types.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js' + +const VISIBLE = 12 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 + +type Stage = 'provider' | 'key' | 'model' | 'disconnect' | 'custom_provider' | 'custom_model' | 'remove_provider' | 'remove_model' + +export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { + const [providers, setProviders] = useState([]) + const [currentModel, setCurrentModel] = useState('') + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + const [persistGlobal, setPersistGlobal] = useState(false) + const [providerIdx, setProviderIdx] = useState(0) + const [modelIdx, setModelIdx] = useState(0) + const [stage, setStage] = useState('provider') + const [keyInput, setKeyInput] = useState('') + const [keySaving, setKeySaving] = useState(false) + const [keyError, setKeyError] = useState('') + const [customProviderSlug, setCustomProviderSlug] = useState('') + const [customProviderUrl, setCustomProviderUrl] = useState('') + const [customProviderKey, setCustomProviderKey] = useState('') + const [customProviderProxy, setCustomProviderProxy] = useState('') + const [customProviderField, setCustomProviderField] = useState(0) + const [customSaving, setCustomSaving] = useState(false) + const [customError, setCustomError] = useState('') + const [customModelName, setCustomModelName] = useState('') + const [customModelSaving, setCustomModelSaving] = useState(false) + const [customModelError, setCustomModelError] = useState('') + const [lastProviderIdx, setLastProviderIdx] = useState(0) + const [lastModelIdx, setLastModelIdx] = useState(0) + const [removeSaving, setRemoveSaving] = useState(false) + const [removeError, setRemoveError] = useState('') + + const { stdout } = useStdout() + // Pin the picker to a stable width so the FloatBox parent (which shrinks- + // to-fit with alignSelf="flex-start") doesn't resize as long provider / + // model names scroll into view, and so `wrap="truncate-end"` on each row + // has an actual constraint to truncate against. + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useEffect(() => { + gw.request('model.options', sessionId ? { session_id: sessionId } : {}) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: model.options') + setLoading(false) + + return + } + + const next = r.providers ?? [] + setProviders(next) + setCurrentModel(String(r.model ?? '')) + setProviderIdx( + Math.max( + 0, + next.findIndex(p => p.is_current) + ) + ) + setModelIdx(0) + setStage('provider') + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw, sessionId]) + + const provider = providers[providerIdx] + const models = provider?.models ?? [] + const names = useMemo(() => providerDisplayNames(providers), [providers]) + + const back = () => { + if (stage === 'model' || stage === 'key' || stage === 'disconnect' || stage === 'custom_provider' || stage === 'custom_model' || stage === 'remove_provider' || stage === 'remove_model') { + setStage('provider') + setModelIdx(0) + setKeyInput('') + setKeyError('') + setKeySaving(false) + + return + } + + onCancel() + } + + useOverlayKeys({ onBack: back, onClose: onCancel }) + + useInput((ch, key) => { + // Key entry stage handles its own input + if (stage === 'key') { + if (keySaving) { + return + } + + if (key.return) { + if (!keyInput.trim()) { + return + } + + setKeySaving(true) + setKeyError('') + gw.request<{ provider?: ModelOptionProvider }>('model.save_key', { + slug: provider?.slug, + api_key: keyInput.trim(), + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) + + if (!r?.provider) { + setKeyError('failed to save key') + setKeySaving(false) + + return + } + + // Update the provider in our list with fresh data + setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p))) + setKeyInput('') + setKeySaving(false) + setStage('model') + setModelIdx(0) + }) + .catch((e: unknown) => { + setKeyError(rpcErrorMessage(e)) + setKeySaving(false) + }) + + return + } + + if (key.backspace || key.delete) { + setKeyInput(v => v.slice(0, -1)) + + return + } + + // ctrl+u clears input + if (ch === '\u0015') { + setKeyInput('') + + return + } + + if (ch && !key.ctrl && !key.meta) { + setKeyInput(v => v + ch) + } + + return + } + + // Custom provider creation stage + if (stage === 'custom_provider') { + if (customSaving) { + return + } + + if (key.return) { + if (customProviderField < 3) { + setCustomProviderField(f => f + 1) + } else { + // Save on last field + const slug = customProviderSlug.trim() + if (!slug) { + return + } + + setCustomSaving(true) + setCustomError('') + gw.request<{ provider?: ModelOptionProvider }>('model.add_custom_provider', { + slug, + name: slug, + base_url: customProviderUrl.trim() || undefined, + api_key: customProviderKey.trim() || undefined, + proxy: customProviderProxy.trim() || null, + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) + + if (!r?.provider) { + setCustomError('failed to add provider') + setCustomSaving(false) + + return + } + + setProviders(prev => [...prev, r.provider!]) + setCustomProviderSlug('') + setCustomProviderUrl('') + setCustomProviderKey('') + setCustomProviderProxy('') + setCustomProviderField(0) + setCustomSaving(false) + setProviderIdx(providers.length) + setStage('model') + setModelIdx(0) + }) + .catch((e: unknown) => { + setCustomError(rpcErrorMessage(e)) + setCustomSaving(false) + }) + } + + return + } + + if (key.backspace || key.delete) { + const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy] + setters[customProviderField](v => v.slice(0, -1)) + + return + } + + if (ch === '') { + const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy] + setters[customProviderField]('') + + return + } + + if (ch && !key.ctrl && !key.meta) { + const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy] + setters[customProviderField](v => v + ch) + } + + return + } + + // Custom model name stage + if (stage === 'custom_model') { + if (customModelSaving) { + return + } + + if (key.return) { + const model = customModelName.trim() + if (!model) { + return + } + + setCustomModelSaving(true) + setCustomModelError('') + gw.request<{ provider?: ModelOptionProvider }>('model.add_custom_model', { + slug: provider?.slug, + model, + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw) + + if (!r?.provider) { + setCustomModelError('failed to add model') + setCustomModelSaving(false) + + return + } + + setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p))) + setCustomModelName('') + setCustomModelSaving(false) + onSelect( + `${model} --provider ${provider!.slug}${allowPersistGlobal && persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}` + ) + }) + .catch((e: unknown) => { + setCustomModelError(rpcErrorMessage(e)) + setCustomModelSaving(false) + }) + + return + } + + if (key.backspace || key.delete) { + setCustomModelName(v => v.slice(0, -1)) + + return + } + + if (ch === '') { + setCustomModelName('') + + return + } + + if (ch && !key.ctrl && !key.meta) { + setCustomModelName(v => v + ch) + } + + return + } + + // Disconnect confirmation stage + if (stage === 'disconnect') { + if (ch.toLowerCase() === 'y' || key.return) { + if (!provider) { + setStage('provider') + + return + } + + setKeySaving(true) + gw.request<{ disconnected?: boolean }>('model.disconnect', { + slug: provider.slug, + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ disconnected?: boolean }>(raw) + + if (r?.disconnected) { + // Mark provider as unauthenticated in local state + setProviders(prev => + prev.map(p => + p.slug === provider.slug + ? { + ...p, + authenticated: false, + models: [], + total_models: 0, + warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `coder model` to configure' + } + : p + ) + ) + } + + setKeySaving(false) + setStage('provider') + }) + .catch(() => { + setKeySaving(false) + setStage('provider') + }) + + return + } + + if (ch.toLowerCase() === 'n' || key.escape) { + setStage('provider') + + return + } + + return + } + + // Remove provider confirmation stage + if (stage === 'remove_provider') { + if (removeSaving) { + return + } + + const targetProvider = providers[lastProviderIdx] + if (!targetProvider) { + setStage('provider') + + return + } + + if (ch.toLowerCase() === 'y' || key.return) { + setRemoveSaving(true) + setRemoveError('') + gw.request<{ removed?: boolean }>('model.remove_provider', { + slug: targetProvider.slug, + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ removed?: boolean }>(raw) + + if (r?.removed) { + setProviders(prev => prev.filter(p => p.slug !== targetProvider.slug)) + } + + setRemoveSaving(false) + setStage('provider') + if (lastProviderIdx >= providers.length - 1) { + setProviderIdx(Math.max(0, providers.length - 2)) + } + }) + .catch((e: unknown) => { + setRemoveError(rpcErrorMessage(e)) + setRemoveSaving(false) + }) + + return + } + + if (ch.toLowerCase() === 'n' || key.escape) { + setStage('provider') + + return + } + + return + } + + // Remove model confirmation stage + if (stage === 'remove_model') { + if (removeSaving) { + return + } + + const modelToRemove = models[lastModelIdx] + if (!modelToRemove || !provider) { + setStage('provider') + + return + } + + if (ch.toLowerCase() === 'y' || key.return) { + setRemoveSaving(true) + setRemoveError('') + gw.request<{ removed?: boolean }>('model.remove_model', { + slug: provider.slug, + model: modelToRemove, + ...(sessionId ? { session_id: sessionId } : {}) + }) + .then(raw => { + const r = asRpcResult<{ removed?: boolean }>(raw) + + if (r?.removed) { + setProviders(prev => + prev.map(p => + p.slug === provider.slug + ? { ...p, models: p.models?.filter(m => m !== modelToRemove) ?? [], total_models: (p.total_models ?? 0) - 1 } + : p + ) + ) + } + + setRemoveSaving(false) + if (models.length <= 1) { + setStage('provider') + } else { + setStage('model') + setModelIdx(Math.max(0, lastModelIdx >= models.length - 1 ? models.length - 2 : lastModelIdx)) + } + }) + .catch((e: unknown) => { + setRemoveError(rpcErrorMessage(e)) + setRemoveSaving(false) + }) + + return + } + + if (ch.toLowerCase() === 'n' || key.escape) { + setStage('model') + + return + } + + return + } + + const count = + stage === 'provider' + ? providers.length + 2 + : stage === 'model' + ? (models.length > 0 ? models.length + 2 : models.length + 1) + : 0 + const sel = stage === 'provider' ? providerIdx : modelIdx + const setSel = stage === 'provider' ? setProviderIdx : setModelIdx + + if (key.upArrow && sel > 0) { + const next = sel - 1 + setSel(next) + if (stage === 'provider' && next < providers.length) setLastProviderIdx(next) + if (stage === 'model' && next < models.length) setLastModelIdx(next) + + return + } + + if (key.downArrow && sel < count - 1) { + const next = sel + 1 + setSel(next) + if (stage === 'provider' && next < providers.length) setLastProviderIdx(next) + if (stage === 'model' && next < models.length) setLastModelIdx(next) + + return + } + + if (key.return) { + if (stage === 'provider') { + if (providerIdx === providers.length) { + setStage('custom_provider') + setCustomProviderSlug('') + setCustomProviderUrl('') + setCustomProviderKey('') + setCustomProviderProxy('') + setCustomProviderField(0) + setCustomSaving(false) + setCustomError('') + + return + } + + if (providerIdx === providers.length + 1) { + setStage('remove_provider') + setRemoveSaving(false) + setRemoveError('') + + return + } + + if (!provider) { + return + } + + if (provider.authenticated === false) { + // api_key providers: prompt for key inline + if (provider.auth_type === 'api_key' && provider.key_env) { + setStage('key') + setKeyInput('') + setKeyError('') + } + + // Other auth types: no-op (warning shown tells them to run coder model) + return + } + + setStage('model') + setModelIdx(0) + + return + } + + if (modelIdx === models.length) { + setStage('custom_model') + setCustomModelName('') + setCustomModelSaving(false) + setCustomModelError('') + + return + } + + if (modelIdx === models.length + 1 && models.length > 0) { + setStage('remove_model') + setRemoveSaving(false) + setRemoveError('') + + return + } + + const model = models[modelIdx] + + if (provider && model) { + onSelect( + `${model} --provider ${provider.slug}${allowPersistGlobal && persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}` + ) + } else { + setStage('provider') + } + + return + } + + if (allowPersistGlobal && ch.toLowerCase() === 'g') { + setPersistGlobal(v => !v) + + return + } + + // Disconnect: only in provider stage, only for authenticated providers + if (ch.toLowerCase() === 'd' && stage === 'provider' && provider?.authenticated !== false) { + setStage('disconnect') + + return + } + }) + + if (loading) { + return loading models… + } + + if (err) { + return ( + + error: {err} + Esc/q cancel + + ) + } + + if (!providers.length) { + return ( + + no providers available + Esc/q cancel + + ) + } + + // ── Key entry stage ────────────────────────────────────────────────── + if (stage === 'key' && provider) { + const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : '' + + return ( + + + Configure {provider.name} + + + + Paste your API key below (saved to ~/.coder/.env) + + + + {' '} + + + + {provider.key_env}: + + + + {' '} + {masked || '(empty)'} + {keySaving ? '' : '▎'} + + + + {' '} + + + {keyError ? ( + + error: {keyError} + + ) : keySaving ? ( + + saving… + + ) : ( + + {' '} + + )} + + Enter save · Ctrl+U clear · Esc back + + ) + } + + // ── Disconnect confirmation stage ───────────────────────────────────── + if (stage === 'disconnect' && provider) { + return ( + + + Disconnect {provider.name}? + + + + {' '} + + + + This removes saved credentials for {provider.name}. + + + + You can re-authenticate later by selecting it again. + + + + {' '} + + + {keySaving ? ( + + disconnecting… + + ) : ( + y/Enter confirm · n/Esc cancel + )} + + ) + } + + // ── Remove provider confirmation stage ──────────────────────────────── + if (stage === 'remove_provider') { + const targetProvider = providers[lastProviderIdx] + + if (!targetProvider) { + return ( + + No provider selected to remove. + Esc back + + ) + } + + return ( + + + Remove {targetProvider.name}? + + + + {' '} + + + + This permanently removes {targetProvider.name} from settings. + + + + All associated models and credentials will be removed. + + + + {' '} + + + {removeError ? ( + + error: {removeError} + + ) : removeSaving ? ( + + removing… + + ) : ( + y/Enter confirm · n/Esc cancel + )} + + ) + } + + // ── Custom provider creation stage ──────────────────────────────────── + if (stage === 'custom_provider') { + const fieldLabels = ['Provider slug', 'Base URL (optional)', 'API Key (optional)', 'Proxy URL (optional)'] + const fieldValues = [customProviderSlug, customProviderUrl, customProviderKey, customProviderProxy] + const fieldMasked = [false, false, true, false] + + return ( + + + Custom Provider Setup + + + + Enter provider details · Enter advances to next field + + + + {' '} + + + {fieldLabels.map((label, i) => { + const isActive = customProviderField === i + const raw = fieldValues[i] + const display = fieldMasked[i] && raw ? '•'.repeat(Math.min(raw.length, 40)) : raw || '(empty)' + + return ( + + + {isActive ? '▸ ' : ' '}{label}: + + + {' '}{display}{isActive && !customSaving ? '▎' : ''} + + + ) + })} + + + {' '} + + + {customError ? ( + + error: {customError} + + ) : customSaving ? ( + + saving… + + ) : ( + + {' '} + + )} + + + {customProviderField < 3 + ? 'Enter next field · Ctrl+U clear field · Esc back' + : 'Enter save · Ctrl+U clear field · Esc back'} + + + ) + } + + // ── Provider selection stage ───────────────────────────────────────── + if (stage === 'provider') { + const rows = providers.map((p, i) => { + const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●' + const modelCount = p.total_models ?? p.models?.length ?? 0 + const suffix = + p.authenticated === false + ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') + : p.is_current + ? `${modelCount} models <- currently active` + : `${modelCount} models` + + return `${authMark} ${names[i]} · ${suffix}` + }) + + rows.push('Custom new provider') + rows.push('Remove provider') + + const { items, offset } = windowItems(rows, providerIdx, VISIBLE) + + return ( + + + Select provider (step 1/2) + + + + Full model IDs on the next step · Enter to continue + + + + Current: {currentModel || '(unknown)'} + + + {provider?.warning ? `warning: ${provider.warning}` : ' '} + + + {offset > 0 ? ` ↑ ${offset} more` : ' '} + + + {Array.from({ length: VISIBLE }, (_, i) => { + const row = items[i] + const idx = offset + i + const p = providers[idx] + const dimmed = p?.authenticated === false + + return row ? ( + + {providerIdx === idx ? '▸ ' : ' '} + {idx + 1}. {row} + + ) : ( + + {' '} + + ) + })} + + + {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} + + + + persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} + {allowPersistGlobal ? ' · g toggle' : ' only'} + + ↑/↓ select · Enter choose · d disconnect · Esc/q cancel + + ) + } + + // ── Model selection stage ──────────────────────────────────────────── + const displayModels = models.length > 0 ? [...models, 'Custom model name', 'Remove model'] : [...models, 'Custom model name'] + const { items, offset } = windowItems(displayModels, modelIdx, VISIBLE) + + return ( + + + Select model (step 2/2) + + + + {names[providerIdx] || '(unknown provider)'} · Esc back + + + {provider?.warning ? `warning: ${provider.warning}` : ' '} + + + {offset > 0 ? ` ↑ ${offset} more` : ' '} + + + {Array.from({ length: VISIBLE }, (_, i) => { + const row = items[i] + const idx = offset + i + + if (!row) { + return !models.length && i === 0 ? ( + + no models listed for this provider + + ) : ( + + {' '} + + ) + } + + const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' ' + + return ( + + {prefix} + {idx + 1}. {row} + + ) + })} + + + {offset + VISIBLE < displayModels.length ? ` ↓ ${displayModels.length - offset - VISIBLE} more` : ' '} + + + + persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} + {allowPersistGlobal ? ' · g toggle' : ' only'} + + + {models.length ? '↑/↓ select · Enter switch · Esc back · q close' : 'Enter/Esc back · q close'} + + + ) + + // ── Remove model confirmation stage ─────────────────────────────────── + if (stage === 'remove_model' && provider) { + const modelToRemove = models[lastModelIdx] + + if (!modelToRemove) { + return ( + + No model selected to remove. + Esc back + + ) + } + + return ( + + + Remove model from {provider.name}? + + + + {' '} + + + + Model: {modelToRemove} + + + + {' '} + + + {removeError ? ( + + error: {removeError} + + ) : removeSaving ? ( + + removing… + + ) : ( + y/Enter confirm · n/Esc cancel + )} + + ) + } + + // ── Custom model name stage ─────────────────────────────────────────── + if (stage === 'custom_model' && provider) { + return ( + + + Custom Model for {provider.name} + + + + Enter the model name/ID (e.g. gpt-4o, claude-sonnet-4-20250514) + + + + {' '} + + + + Model name: + + + + {' '} + {customModelName || '(empty)'} + {customModelSaving ? '' : '▎'} + + + + {' '} + + + {customModelError ? ( + + error: {customModelError} + + ) : customModelSaving ? ( + + saving… + + ) : ( + + {' '} + + )} + + Enter save · Ctrl+U clear · Esc back + + ) + } +} + +interface ModelPickerProps { + allowPersistGlobal?: boolean + gw: IGatewayClient + onCancel: () => void + onSelect: (value: string) => void + sessionId: string | null + t: Theme +} diff --git a/packages/cli/src/components/overlayControls.tsx b/packages/cli/src/components/overlayControls.tsx new file mode 100644 index 0000000..d6a9929 --- /dev/null +++ b/packages/cli/src/components/overlayControls.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Text, useInput } from '@coder/tui' + +import type { Theme } from '../theme.js' + +export function useOverlayKeys({ disabled = false, onBack, onClose }: OverlayKeysOptions) { + useInput((ch, key) => { + if (disabled) { + return + } + + if (ch === 'q') { + return onClose() + } + + if (key.escape) { + return onBack ? onBack() : onClose() + } + }) +} + +export function OverlayHint({ children, t }: OverlayHintProps) { + return ( + + {children} + + ) +} + +export const windowOffset = (count: number, selected: number, visible: number) => + Math.max(0, Math.min(selected - Math.floor(visible / 2), count - visible)) + +export function windowItems(items: T[], selected: number, visible: number) { + const offset = windowOffset(items.length, selected, visible) + + return { + items: items.slice(offset, offset + visible), + offset + } +} + +interface OverlayHintProps { + children: string + t: Theme +} + +interface OverlayKeysOptions { + disabled?: boolean + onBack?: () => void + onClose: () => void +} diff --git a/packages/cli/src/components/prompts.tsx b/packages/cli/src/components/prompts.tsx new file mode 100644 index 0000000..546ce8d --- /dev/null +++ b/packages/cli/src/components/prompts.tsx @@ -0,0 +1,277 @@ +import React from 'react' +import { Box, Text, useInput } from '@coder/tui' +import { useState } from 'react' + +import { isMac } from '../lib/platform.js' +import type { Theme } from '../theme.js' +import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' + +import { TextInput } from './textInput.js' + +const OPTS = ['once', 'session', 'always', 'deny'] as const +const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const +const CMD_PREVIEW_LINES = 10 + +type ApprovalKey = { + downArrow?: boolean + escape?: boolean + return?: boolean + upArrow?: boolean +} + +type ApprovalAction = + | { kind: 'choose'; choice: (typeof OPTS)[number] } + | { kind: 'move'; delta: -1 | 1 } + | { kind: 'noop' } + +/** + * Pure key-dispatch for the approval prompt — exported so the regression + * matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable + * without mounting React + Ink + a fake stdin. The component just maps the + * action onto its own state setters. + * + * Esc and number keys both terminate the prompt; Esc maps to deny (parity + * with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC + * for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter + * confirms the current selection. ↑/↓ moves the selection within bounds. + */ +export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction { + if (key.escape) { + return { kind: 'choose', choice: 'deny' } + } + + const n = parseInt(ch, 10) + + if (n >= 1 && n <= OPTS.length) { + return { kind: 'choose', choice: OPTS[n - 1]! } + } + + if (key.return) { + return { kind: 'choose', choice: OPTS[sel]! } + } + + if (key.upArrow && sel > 0) { + return { kind: 'move', delta: -1 } + } + + if (key.downArrow && sel < OPTS.length - 1) { + return { kind: 'move', delta: 1 } + } + + return { kind: 'noop' } +} + +export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + const action = approvalAction(ch, key, sel) + + if (action.kind === 'choose') { + onChoice(action.choice) + } else if (action.kind === 'move') { + setSel(s => s + action.delta) + } + }) + + const rawLines = req.command.split('\n') + const shown = rawLines.slice(0, CMD_PREVIEW_LINES) + const overflow = rawLines.length - shown.length + + return ( + + + ⚠ approval required · {req.description} + + + + {shown.map((line, i) => ( + + {line || ' '} + + ))} + + {overflow > 0 ? ( + + … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) + + ) : null} + + + + + {OPTS.map((o, i) => ( + + + {sel === i ? '▸ ' : ' '} + {i + 1}. {LABELS[o]} + + + ))} + + ↑/↓ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny + + ) +} + +export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) { + const [sel, setSel] = useState(0) + const [custom, setCustom] = useState('') + const [typing, setTyping] = useState(false) + const choices = req.choices ?? [] + + const heading = ( + + ask + {req.question} + + ) + + useInput((ch, key) => { + if (key.escape) { + typing && choices.length ? setTyping(false) : onCancel() + + return + } + + if (typing || !choices.length) { + return + } + + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < choices.length) { + setSel(s => s + 1) + } + + if (key.return) { + sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!) + } + + const n = parseInt(ch) + + if (n >= 1 && n <= choices.length) { + onAnswer(choices[n - 1]!) + } + }) + + if (typing || !choices.length) { + return ( + + {heading} + + + {'> '} + + + + + Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} + {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} + + + ) + } + + return ( + + {heading} + + {[...choices, 'Other (type your answer)'].map((c, i) => ( + + + {sel === i ? '▸ ' : ' '} + {i + 1}. {c} + + + ))} + + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel + + ) +} + +export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + const lower = ch.toLowerCase() + + if (key.escape || (key.ctrl && lower === 'c') || lower === 'n') { + return onCancel() + } + + if (lower === 'y') { + return onConfirm() + } + + if (key.upArrow) { + setSel(0) + } + + if (key.downArrow) { + setSel(1) + } + + if (key.return) { + sel === 0 ? onCancel() : onConfirm() + } + }) + + const accent = req.danger ? t.color.error : t.color.warn + + const rows = [ + { color: t.color.text, label: req.cancelLabel ?? 'No' }, + { color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' } + ] + + return ( + + + {req.danger ? '⚠' : '?'} {req.title} + + + {req.detail ? ( + + + {req.detail} + + + ) : null} + + + + {rows.map((row, i) => ( + + {sel === i ? '▸ ' : ' '} + {row.label} + + ))} + + ↑/↓ select · Enter confirm · Y/N quick · Esc cancel + + ) +} + +interface ApprovalPromptProps { + onChoice: (s: string) => void + req: ApprovalReq + t: Theme +} + +interface ClarifyPromptProps { + cols?: number + onAnswer: (s: string) => void + onCancel: () => void + req: ClarifyReq + t: Theme +} + +interface ConfirmPromptProps { + onCancel: () => void + onConfirm: () => void + req: ConfirmReq + t: Theme +} diff --git a/packages/cli/src/components/queuedMessages.tsx b/packages/cli/src/components/queuedMessages.tsx new file mode 100644 index 0000000..fc9e35d --- /dev/null +++ b/packages/cli/src/components/queuedMessages.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Box, Text } from '@coder/tui' + +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' + +export const QUEUE_WINDOW = 3 + +export function getQueueWindow(queueLen: number, queueEditIdx: number | null) { + const start = + queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queueLen - QUEUE_WINDOW))) + + const end = Math.min(queueLen, start + QUEUE_WINDOW) + + return { end, showLead: start > 0, showTail: end < queueLen, start } +} + +export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) { + if (!queued.length) { + return null + } + + const q = getQueueWindow(queued.length, queueEditIdx) + + return ( + + + {`queued (${queued.length})${ + queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : '' + }`} + + + {q.showLead && ( + + {' '} + … + + )} + + {queued.slice(q.start, q.end).map((item, i) => { + const idx = q.start + i + const active = queueEditIdx === idx + + return ( + + {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))} + + ) + })} + + {q.showTail && ( + + {' '}…and {queued.length - q.end} more + + )} + + ) +} + +interface QueuedMessagesProps { + cols: number + queueEditIdx: number | null + queued: string[] + t: Theme +} diff --git a/packages/cli/src/components/sessionPicker.tsx b/packages/cli/src/components/sessionPicker.tsx new file mode 100644 index 0000000..9c5b5c7 --- /dev/null +++ b/packages/cli/src/components/sessionPicker.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import { Box, Text, useInput, useStdout } from '@coder/tui' +import { useEffect, useState } from 'react' + +import type { IGatewayClient } from '../gateway/client.js' +import type { SessionDeleteResponse, SessionListItem, SessionListResponse } from '../gateway/types.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js' + +const VISIBLE = 15 +const MIN_WIDTH = 60 +const MAX_WIDTH = 120 + +const age = (ts: number) => { + const d = (Date.now() / 1000 - ts) / 86400 + + if (d < 1) { + return 'today' + } + + if (d < 2) { + return 'yesterday' + } + + return `${Math.floor(d)}d ago` +} + +export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) { + const [items, setItems] = useState([]) + const [err, setErr] = useState('') + const [sel, setSel] = useState(0) + const [loading, setLoading] = useState(true) + // When non-null, the user pressed `d` on this index and we're waiting for + // a second `d`/`D` to confirm deletion. Any other key cancels the prompt. + const [confirmDelete, setConfirmDelete] = useState(null) + const [deleting, setDeleting] = useState(false) + + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useOverlayKeys({ onClose: onCancel }) + + useEffect(() => { + gw.request('session.list', { limit: 200 }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: session.list') + setLoading(false) + + return + } + + setItems(r.sessions ?? []) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw]) + + const performDelete = (index: number) => { + const target = items[index] + + if (!target || deleting) { + return + } + + setDeleting(true) + gw.request('session.delete', { session_id: target.id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r || r.deleted !== target.id) { + setErr('invalid response: session.delete') + setDeleting(false) + + return + } + + setItems(prev => { + const next = prev.filter((_, i) => i !== index) + setSel(s => Math.max(0, Math.min(s, next.length - 1))) + + return next + }) + setErr('') + setDeleting(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setDeleting(false) + }) + } + + useInput((ch, key) => { + if (deleting) { + return + } + + if (confirmDelete !== null) { + if (ch?.toLowerCase() === 'd') { + const idx = confirmDelete + setConfirmDelete(null) + performDelete(idx) + } else { + setConfirmDelete(null) + } + + return + } + + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < items.length - 1) { + setSel(s => s + 1) + } + + if (key.return && items[sel]) { + onSelect(items[sel]!.id) + + return + } + + if (ch?.toLowerCase() === 'd' && items[sel]) { + setConfirmDelete(sel) + + return + } + + const n = parseInt(ch) + + if (n >= 1 && n <= Math.min(9, items.length)) { + onSelect(items[n - 1]!.id) + } + }) + + if (loading) { + return loading sessions… + } + + if (err && !items.length) { + return ( + + error: {err} + Esc/q cancel + + ) + } + + if (!items.length) { + return ( + + no previous sessions + Esc/q cancel + + ) + } + + const offset = windowOffset(items.length, sel, VISIBLE) + + return ( + + + Resume Session + + + {offset > 0 && ↑ {offset} more} + + {items.slice(offset, offset + VISIBLE).map((s, vi) => { + const i = offset + vi + const selected = sel === i + const pendingDelete = confirmDelete === i + + return ( + + + {selected ? '▸ ' : ' '} + + + + + {String(i + 1).padStart(2)}. [{s.id}] + + + + + + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) + + + + + {pendingDelete ? 'press d again to delete' : s.title || s.preview || '(untitled)'} + + + ) + })} + + {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} + {err && error: {err}} + {deleting ? ( + deleting… + ) : ( + ↑/↓ select · Enter resume · 1-9 quick · d delete · Esc/q cancel + )} + + ) +} + +interface SessionPickerProps { + gw: IGatewayClient + onCancel: () => void + onSelect: (id: string) => void + t: Theme +} diff --git a/packages/cli/src/components/skillsHub.tsx b/packages/cli/src/components/skillsHub.tsx new file mode 100644 index 0000000..2bfb0b7 --- /dev/null +++ b/packages/cli/src/components/skillsHub.tsx @@ -0,0 +1,309 @@ +import React from 'react' +import { Box, Text, useInput, useStdout } from '@coder/tui' +import { useEffect, useState } from 'react' + +import type { IGatewayClient } from '../gateway/client.js' +import { rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' + +const VISIBLE = 12 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 + +export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { + const [skillsByCat, setSkillsByCat] = useState>({}) + const [selectedCat, setSelectedCat] = useState('') + const [catIdx, setCatIdx] = useState(0) + const [skillIdx, setSkillIdx] = useState(0) + const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category') + const [info, setInfo] = useState(null) + const [installing, setInstalling] = useState(false) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useEffect(() => { + gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) + .then(r => { + setSkillsByCat(r?.skills ?? {}) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw]) + + const cats = Object.keys(skillsByCat).sort() + const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] + const skillName = skills[skillIdx] ?? '' + + const back = () => { + if (stage === 'actions') { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (stage === 'skill') { + setStage('category') + setSkillIdx(0) + + return + } + + onClose() + } + + useOverlayKeys({ disabled: installing, onBack: back, onClose }) + + const inspect = (name: string) => { + setInfo(null) + setErr('') + + gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name }) + .then(r => setInfo(r?.info ?? { name })) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + } + + const install = (name: string) => { + setInstalling(true) + setErr('') + + gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name }) + .then(() => onClose()) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + .finally(() => setInstalling(false)) + } + + useInput((ch, key) => { + if (installing) { + return + } + + if (stage === 'actions') { + if (key.return) { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (ch.toLowerCase() === 'x' && skillName) { + install(skillName) + + return + } + + if (ch.toLowerCase() === 'i' && skillName) { + inspect(skillName) + } + + return + } + + const count = stage === 'category' ? cats.length : skills.length + const sel = stage === 'category' ? catIdx : skillIdx + const setSel = stage === 'category' ? setCatIdx : setSkillIdx + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + + return + } + + if (key.downArrow && sel < count - 1) { + setSel(v => v + 1) + + return + } + + if (key.return) { + if (stage === 'category') { + const cat = cats[catIdx] + + if (!cat) { + return + } + + setSelectedCat(cat) + setSkillIdx(0) + setStage('skill') + + return + } + + const name = skills[skillIdx] + + if (name) { + setStage('actions') + inspect(name) + } + + return + } + + const n = ch === '0' ? 10 : parseInt(ch, 10) + + if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { + const next = windowOffset(count, sel, VISIBLE) + n - 1 + + if (stage === 'category') { + const cat = cats[next] + + if (cat) { + setSelectedCat(cat) + setCatIdx(next) + setSkillIdx(0) + setStage('skill') + } + + return + } + + const name = skills[next] + + if (name) { + setSkillIdx(next) + setStage('actions') + inspect(name) + } + } + }) + + if (loading) { + return loading skills… + } + + if (err && stage === 'category') { + return ( + + error: {err} + Esc/q cancel + + ) + } + + if (!cats.length) { + return ( + + no skills available + Esc/q cancel + + ) + } + + if (stage === 'category') { + const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`) + const { items, offset } = windowItems(rows, catIdx, VISIBLE) + + return ( + + + Skills Hub + + + select a category + {offset > 0 && ↑ {offset} more} + + {items.map((row, i) => { + const idx = offset + i + + return ( + + {catIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + + {offset + VISIBLE < rows.length && ↓ {rows.length - offset - VISIBLE} more} + ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel + + ) + } + + if (stage === 'skill') { + const { items, offset } = windowItems(skills, skillIdx, VISIBLE) + + return ( + + + {selectedCat} + + + {skills.length} skill(s) + {!skills.length ? no skills in this category : null} + {offset > 0 && ↑ {offset} more} + + {items.map((row, i) => { + const idx = offset + i + + return ( + + {skillIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + + {offset + VISIBLE < skills.length && ( + ↓ {skills.length - offset - VISIBLE} more + )} + + {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} + + + ) + } + + return ( + + + {info?.name ?? skillName} + + + {info?.category ?? selectedCat} + {info?.description ? {info.description} : null} + {info?.path ? path: {info.path} : null} + {!info && !err ? loading… : null} + {err ? error: {err} : null} + {installing ? installing… : null} + + i reinspect · x reinstall · Enter/Esc back · q close + + ) +} + +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsHubProps { + gw: IGatewayClient + onClose: () => void + t: Theme +} diff --git a/packages/cli/src/components/slashCommandPopup.tsx b/packages/cli/src/components/slashCommandPopup.tsx new file mode 100644 index 0000000..6a182f9 --- /dev/null +++ b/packages/cli/src/components/slashCommandPopup.tsx @@ -0,0 +1,125 @@ +/** + * slashCommandPopup.tsx — Slash Command Suggestion Popup + * + * Renders a compact dropdown above the composer when the user types "/" + * as the first character. Shows matching slash commands with arrow-key + * navigation, Enter to execute, and Escape to dismiss. + */ +import React, { useMemo } from 'react' +import { Box, Text, useStdout } from '@coder/tui' +import { useStore } from '@nanostores/react' + +import type { SlashCommand } from '../app/slash/types.js' +import { SLASH_COMMANDS } from '../app/slash/registry.js' +import { $uiState } from '../app/uiStore.js' + +const MAX_VISIBLE = 10 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Filter SLASH_COMMANDS by prefix, matching against name and aliases. + * Deduplicates — a command whose alias also matches is only shown once. + */ +function filterCommands(prefix: string): SlashCommand[] { + if (!prefix) return [...SLASH_COMMANDS] + const lower = prefix.toLowerCase() + const seen = new Set() + const results: SlashCommand[] = [] + for (const cmd of SLASH_COMMANDS) { + if (seen.has(cmd.name)) continue + const nameMatch = cmd.name.startsWith(lower) + const aliasMatch = (cmd.aliases ?? []).some((a) => a.toLowerCase().startsWith(lower)) + if (nameMatch || aliasMatch) { + seen.add(cmd.name) + results.push(cmd) + } + } + // Sort: exact name matches first, then prefix matches, then alias matches + return results.sort((a, b) => { + const aExact = a.name === lower + const bExact = b.name === lower + if (aExact !== bExact) return aExact ? -1 : 1 + return a.name.localeCompare(b.name) + }) +} + +// --------------------------------------------------------------------------- +// SlashCommandPopup +// --------------------------------------------------------------------------- + +export function SlashCommandPopup() { + const uiState = useStore($uiState) + const { stdout } = useStdout() + + const { slashCommandOpen, slashCommandFilter, slashCommandSelectedIndex } = uiState + const t = uiState.theme + + // ── Filter commands by prefix ───────────────────────────────────── + const filtered = useMemo(() => { + if (!slashCommandOpen) return [] + return filterCommands(slashCommandFilter) + }, [slashCommandOpen, slashCommandFilter]) + + // ── Visibility check ────────────────────────────────────────────── + if (!slashCommandOpen) return null + if (filtered.length === 0 && slashCommandFilter !== '') return null + + // ── Compute visible window ──────────────────────────────────────── + const visibleCount = Math.min(filtered.length, MAX_VISIBLE) + const selIdx = Math.max(0, Math.min(slashCommandSelectedIndex, filtered.length - 1)) + const scrollOffset = Math.max( + 0, + Math.min(selIdx - Math.floor(visibleCount / 2), filtered.length - visibleCount), + ) + const visibleCmds = filtered.slice(scrollOffset, scrollOffset + visibleCount) + + const popupWidth = Math.min(80, (stdout?.columns ?? 80) - 4) + + return ( + + {'─'.repeat(popupWidth)} + + {visibleCmds.map((cmd, vi) => { + const globalIdx = scrollOffset + vi + const isSelected = globalIdx === slashCommandSelectedIndex + + // Build display: aliases shown in muted after name + const aliasHint = cmd.aliases?.length + ? ` (${cmd.aliases.slice(0, 2).join(', ')})` + : '' + const usageHint = cmd.usage ? ` ${cmd.usage}` : '' + const desc = cmd.help ?? '' + + return ( + + + {isSelected ? '▶ ' : ' '}/{cmd.name} + + {!!usageHint && ( + + {usageHint} + + )} + {!!aliasHint && ( + {aliasHint} + )} + {!!desc && ( + + {' — '}{desc} + + )} + + ) + })} + + {/* Footer hint */} + + ↑↓ navigate · Enter execute · Esc close · type to filter + + {'─'.repeat(popupWidth)} + + ) +} diff --git a/packages/cli/src/components/streamingAssistant.tsx b/packages/cli/src/components/streamingAssistant.tsx new file mode 100644 index 0000000..a042ea6 --- /dev/null +++ b/packages/cli/src/components/streamingAssistant.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import { useStore } from '@nanostores/react' +import { memo } from 'react' + +import type { AppLayoutProgressProps } from '../app/interfaces.js' +import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js' +import { $uiState } from '../app/uiStore.js' +import { appendToolShelfMessage } from '../lib/liveProgress.js' +import type { DetailsMode, Msg, SectionVisibility } from '../types.js' + +import { MessageLine } from './messageLine.js' +import { TodoPanel } from './todoPanel.js' + +const groupedSegments = (segments: Msg[]): Msg[] => + segments.reduce((acc, msg) => appendToolShelfMessage(acc, msg), []) + +export const StreamingAssistant = memo(function StreamingAssistant({ + cols, + compact, + detailsMode, + detailsModeCommandOverride, + progress, + sections +}: StreamingAssistantProps) { + const ui = useStore($uiState) + const streamSegments = useTurnSelector(state => state.streamSegments) + const streamPendingTools = useTurnSelector(state => state.streamPendingTools) + const streaming = useTurnSelector(state => state.streaming) + const activeTools = useTurnSelector(state => state.tools) + const reasoningActive = useTurnSelector(state => state.reasoningActive) + const reasoningStreaming = useTurnSelector(state => state.reasoningStreaming) + const showStreamingArea = Boolean(streaming) + + if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) { + return null + } + + return ( + <> + {groupedSegments(streamSegments).map((msg, i) => ( + + ))} + + {!!activeTools.length && ( + + )} + + {showStreamingArea && ( + + )} + + {!showStreamingArea && !!streamPendingTools.length && ( + + )} + + ) +}) + +export const LiveTodoPanel = memo(function LiveTodoPanel() { + const ui = useStore($uiState) + const todos = useTurnSelector(state => state.todos) + const collapsed = useTurnSelector(state => state.todoCollapsed) + + return +}) + +interface StreamingAssistantProps { + cols: number + compact?: boolean + detailsMode: DetailsMode + detailsModeCommandOverride: boolean + progress: AppLayoutProgressProps + sections?: SectionVisibility +} diff --git a/packages/cli/src/components/streamingMarkdown.tsx b/packages/cli/src/components/streamingMarkdown.tsx new file mode 100644 index 0000000..c4d1597 --- /dev/null +++ b/packages/cli/src/components/streamingMarkdown.tsx @@ -0,0 +1,175 @@ +import React from 'react' +// StreamingMd — incremental markdown renderer for in-flight assistant text. +// +// Naive approach (render ) re-tokenizes the entire message +// on every stream delta. At 20-char batches over a 3 KB response that's 150 +// full re-parses. +// +// This splits `text` at the last stable top-level block boundary (blank +// line outside a fenced code span) into: +// stablePrefix — passed to an inner , memoized on its exact text +// value. During the turn, the prefix only grows monotonically, +// so its memo key matches the previous render and React +// reuses the cached subtree — zero re-tokenization. +// unstableSuffix — the in-flight block(s). A separate re-parses just +// this tail on every delta (O(unstable length) vs. +// O(total length)). +// +// The boundary is stored in a ref so it only advances — idempotent under +// StrictMode double-render. Component unmounts between turns (isStreaming +// flips off → message moves to history and renders via directly), so +// the ref resets naturally. +// +// Layout: the two subtrees MUST render stacked (column). The parent +// container in messageLine.tsx is a default `flexDirection: 'row'` Box +// (Ink's default), so returning a bare Fragment of two siblings +// laid them out side-by-side — producing the "two jumbled columns while +// streaming" rendering bug. Wrapping in a flexDirection="column" Box +// here localizes the fix to the streaming path; the non-streaming +// already returns its own column Box, so its single-child case was never +// affected. + +import { Box } from '@coder/tui' +import { memo, useRef } from 'react' + +import type { Theme } from '../theme.js' + +import { Md } from './markdown.js' + +// Count ``` / ~~~ AND `$$` / `\[…\]` fence toggles in `s` up to `end`. Odd +// = currently inside a fenced block; splitting the prefix there would +// orphan the fence and let the unstable suffix re-render as broken +// markdown. Math fences only toggle when the code fence is closed so +// snippets like ` ```\n$$x$$\n``` ` (math example inside a code block) +// don't double-count. A `$$x$$` line that opens AND closes on its own +// produces zero net toggles; that's `len >= 4` plus `endsDollar`. +// +// NB: this is INTENTIONALLY more conservative than `markdown.tsx`'s +// parser, which falls back to paragraph rendering when an `$$` opener +// has no matching closer. The renderer can do that safely because it +// always sees the full text on every call. The streaming chunker +// cannot — once a chunk is committed to the monotonic stable prefix it +// is frozen, so prematurely deciding "this `$$` is just prose" would +// permanently commit a paragraph rendering that becomes wrong the +// instant the closer streams in. Treating any unmatched `$$` opener +// as still-open keeps the boundary parked behind it until the closer +// arrives (or the stream ends and the non-streaming `` takes over, +// at which point the renderer's fallback kicks in correctly). +const fenceOpenAt = (s: string, end: number) => { + let codeOpen = false + let mathOpen = false + let mathOpener: '$$' | '\\[' | null = null + let i = 0 + + while (i < end) { + const nl = s.indexOf('\n', i) + const lineEnd = nl < 0 || nl > end ? end : nl + const line = s.slice(i, lineEnd).trim() + + if (/^(?:`{3,}|~{3,})/.test(line)) { + codeOpen = !codeOpen + } else if (!codeOpen) { + if (!mathOpen && /^\$\$/.test(line)) { + const isSingleLine = line.length >= 4 && /\$\$$/.test(line) + + if (!isSingleLine) { + mathOpen = true + mathOpener = '$$' + } + } else if (!mathOpen && /^\\\[/.test(line)) { + const isSingleLine = /\\\]$/.test(line) + + if (!isSingleLine) { + mathOpen = true + mathOpener = '\\[' + } + } else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) { + mathOpen = false + mathOpener = null + } else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) { + mathOpen = false + mathOpener = null + } + } + + if (nl < 0 || nl >= end) { + break + } + + i = nl + 1 + } + + return codeOpen || mathOpen +} + +// Find the last "\n\n" boundary before `end` that is OUTSIDE a fenced code +// block. Returns the index AFTER the second newline (start of the next +// block), or -1 if no safe boundary exists yet. +export const findStableBoundary = (text: string) => { + let idx = text.length + + while (idx > 0) { + const boundary = text.lastIndexOf('\n\n', idx - 1) + + if (boundary < 0) { + return -1 + } + + // Boundary candidate: end of stable prefix is boundary + 2 (start of + // next block). Check fence balance up to that point. + const splitAt = boundary + 2 + + if (!fenceOpenAt(text, splitAt)) { + return splitAt + } + + idx = boundary + } + + return -1 +} + +export const StreamingMd = memo(function StreamingMd({ cols, compact, t, text }: StreamingMdProps) { + const stablePrefixRef = useRef('') + + // Reset if the text no longer starts with our recorded prefix (defensive; + // normally the component unmounts between turns so this shouldn't trigger). + if (!text.startsWith(stablePrefixRef.current)) { + stablePrefixRef.current = '' + } + + const boundary = findStableBoundary(text) + + // Only advance the prefix — never retreat. The boundary math looks at the + // FULL text each call; if it returns a larger index than before, we grow + // the cached prefix. Monotonic growth makes the memo key stable across + // deltas (identical string → same subtree → no re-render). + if (boundary > stablePrefixRef.current.length) { + stablePrefixRef.current = text.slice(0, boundary) + } + + const stablePrefix = stablePrefixRef.current + const unstableSuffix = text.slice(stablePrefix.length) + + if (!stablePrefix) { + return + } + + if (!unstableSuffix) { + return + } + + return ( + + + + + ) +}) + +interface StreamingMdProps { + cols?: number + compact?: boolean + t: Theme + text: string +} diff --git a/packages/cli/src/components/textInput.tsx b/packages/cli/src/components/textInput.tsx new file mode 100644 index 0000000..4422394 --- /dev/null +++ b/packages/cli/src/components/textInput.tsx @@ -0,0 +1,1328 @@ +import React from 'react' +import type { InputEvent, Key } from '@coder/tui' +import * as Ink from '@coder/tui' +import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' + +import { setInputSelection } from '../app/inputSelectionStore.js' +import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' +import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js' +import { + DEFAULT_VOICE_RECORD_KEY, + isActionMod, + isMac, + isMacActionFallback, + isVoiceToggleKey, + type ParsedVoiceRecordKey +} from '../lib/platform.js' +import { isTermuxTuiMode } from '../lib/termux.js' + +type InkExt = typeof Ink & { + stringWidth: (s: string) => number + useCursorAdvance: () => (dx: number, dy?: number) => void + useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void + useStdout: () => { stdout?: NodeJS.WriteStream } + useTerminalFocus: () => boolean +} + +const ink = Ink as unknown as InkExt +const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, useDeclaredCursor, useTerminalFocus } = ink + +const ESC = '\x1b' +const INV = `${ESC}[7m` +const INV_OFF = `${ESC}[27m` +const DIM = `${ESC}[2m` +const DIM_OFF = `${ESC}[22m` +const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) +const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ +const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') +const FRAME_BATCH_MS = 16 +const MULTI_CLICK_MS = 500 + +const invert = (s: string) => INV + s + INV_OFF +const dim = (s: string) => DIM + s + DIM_OFF + +let _seg: Intl.Segmenter | null = null +const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) +const STOP_CACHE_MAX = 32 +const stopCache = new Map() + +function graphemeStops(s: string) { + const hit = stopCache.get(s) + + if (hit) { + return hit + } + + const stops = [0] + + for (const { index } of seg().segment(s)) { + if (index > 0) { + stops.push(index) + } + } + + if (stops.at(-1) !== s.length) { + stops.push(s.length) + } + + stopCache.set(s, stops) + + if (stopCache.size > STOP_CACHE_MAX) { + const oldest = stopCache.keys().next().value + + if (oldest !== undefined) { + stopCache.delete(oldest) + } + } + + return stops +} + +function snapPos(s: string, p: number) { + const pos = Math.max(0, Math.min(p, s.length)) + let last = 0 + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + break + } + + last = stop + } + + return last +} + +export interface TextInsertResult { + cursor: number + value: string +} + +export function applyPrintableInsert( + value: string, + cursor: number, + text: string, + range?: { end: number; start: number } | null +): null | TextInsertResult { + if (!PRINTABLE.test(text)) { + return null + } + + if (range) { + return { + cursor: range.start + text.length, + value: value.slice(0, range.start) + text + value.slice(range.end) + } + } + + return { + cursor: cursor + text.length, + value: value.slice(0, cursor) + text + value.slice(cursor) + } +} + +export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n') + +function prevPos(s: string, p: number) { + const pos = snapPos(s, p) + let prev = 0 + + for (const stop of graphemeStops(s)) { + if (stop >= pos) { + return prev + } + + prev = stop + } + + return prev +} + +function nextPos(s: string, p: number) { + const pos = snapPos(s, p) + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + return stop + } + } + + return s.length +} + +function wordLeft(s: string, p: number) { + let i = snapPos(s, p) - 1 + + while (i > 0 && /\s/.test(s[i]!)) { + i-- + } + + while (i > 0 && !/\s/.test(s[i - 1]!)) { + i-- + } + + return Math.max(0, i) +} + +function wordRight(s: string, p: number) { + let i = snapPos(s, p) + + while (i < s.length && !/\s/.test(s[i]!)) { + i++ + } + + while (i < s.length && /\s/.test(s[i]!)) { + i++ + } + + return i +} + +/** + * Move cursor one logical line up or down inside `s` while preserving the + * column offset from the current line's start. Returns `null` when the cursor + * is already on the first line (up) or last line (down) — callers use that + * signal to fall through to history cycling instead of eating the arrow key. + */ +export function lineNav(s: string, p: number, dir: -1 | 1): null | number { + const pos = snapPos(s, p) + const curStart = s.lastIndexOf('\n', pos - 1) + 1 + const col = pos - curStart + + if (dir < 0) { + if (curStart === 0) { + return null + } + + const prevStart = s.lastIndexOf('\n', curStart - 2) + 1 + + return snapPos(s, Math.min(prevStart + col, curStart - 1)) + } + + const nextBreak = s.indexOf('\n', pos) + + if (nextBreak < 0) { + return null + } + + const nextEnd = s.indexOf('\n', nextBreak + 1) + const lineEnd = nextEnd < 0 ? s.length : nextEnd + + return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) +} + +export { offsetFromPosition } + +const ASCII_PRINTABLE_RE = /^[\x20-\x7e]+$/ + +/** + * Pure shape-only precondition for the fast-echo append path. + * + * The fast-echo path bypasses Ink's renderer and writes text directly to + * stdout, so the stored value, the rendered terminal cells, and the cursor + * column must all stay in sync without any layout work. We only allow it + * when the inserted text is pure printable ASCII so that: + * + * - `text.length` matches the number of grapheme clusters (no combining + * marks, no surrogate pairs, no precomposed CJK / Latin-Extended + * letters that an IME might still be holding open as a composition), + * - terminal width is exactly 1 cell per character (no East-Asian wide, + * no zero-width, no ambiguous-width fonts), + * - input methods (Vietnamese Telex, IME, dead-keys) cannot leak + * intermediate composition bytes through the bypass before the final + * commit arrives — those always go through the normal Ink render path + * and stay layout-accurate (closes #5221, #7443, #17602/#17603). + * + * We deliberately do NOT just check `stringWidth(text) === text.length`: + * Vietnamese precomposed letters like "ề" (U+1EC1) report width 1 and + * length 1 but are still produced by IME compositions and must not be + * fast-echoed. + */ +export function canFastAppendShape( + current: string, + cursor: number, + text: string, + columns: number, + currentLineWidth: number +): boolean { + if (cursor !== current.length) { + return false + } + + if (current.length === 0) { + return false + } + + if (current.includes('\n')) { + return false + } + + if (!ASCII_PRINTABLE_RE.test(text)) { + return false + } + + return currentLineWidth + text.length < Math.max(1, columns) +} + +/** + * Pure shape-only precondition for the fast-echo backspace path. + * + * Same reasoning as canFastAppendShape — only allow the direct + * "\b \b" stdout shortcut when the deleted grapheme is pure printable + * ASCII. Anything else (combining marks, IME compositions, wide chars, + * tabs, ANSI fragments) goes through the normal render path so Ink can + * recompute cell widths. + * + * When `columns` is supplied, ALSO rejects when the physical cursor + * sits at visual column 0 — i.e., right after a soft-wrap boundary. + * The "\b \b" sequence cannot move the cursor onto the previous visual + * row (terminals don't back-step across line wraps), so the physical + * cursor would stay put while the logical caret moves to the end of + * the previous visual line, desyncing both Ink's `displayCursor` model + * and the user-visible position. + * + * When `columns` is OMITTED, the wrap-boundary check is skipped + * entirely and the function reverts to the legacy non-wrap-aware + * contract — values like `'hello '` will return `true` even though + * they would be unsafe at a width of 6. Production callers (the + * composer's `canFastBackspace` helper) always pass `columns`; + * `columns` is optional only so unit tests of the pre-wrap shape + * contract can keep calling the helper without threading width + * through. Do NOT omit it from any new caller that relies on the + * wrap-boundary protection. + */ +export function canFastBackspaceShape(current: string, cursor: number, columns?: number): boolean { + if (cursor !== current.length) { + return false + } + + if (cursor <= 0) { + return false + } + + if (current.includes('\n')) { + return false + } + + // If we know the wrap width, reject at the soft-wrap boundary: the + // caret's physical column would be at (or past) the terminal's right + // edge, so the terminal has already auto-wrapped to the next row. + // "\b \b" can't represent the physical move back across that wrap. + // + // We check `column === 0` for the "wrap-ansi broke onto a new line" + // case AND `column >= columns` for the "exact-fill, terminal auto-wraps" + // case. Both manifest as the same physical state (cursor parked at + // col 0 of the next row) but cursorLayout reports them differently + // because it now mirrors wrap-ansi's break points exactly (see the + // cursor-drift-multiline fix in lib/inputMetrics.ts). + if (columns !== undefined) { + const layout = cursorLayout(current, cursor, columns) + + if (layout.column === 0 || layout.column >= columns) { + return false + } + } + + const removed = current.slice(prevPos(current, cursor), cursor) + + return ASCII_PRINTABLE_RE.test(removed) +} + +export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + // Terminal.app still shows paint/cursor artifacts under the fast-echo + // bypass path. Fall back to the normal Ink render path there. + if ((env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal') { + return false + } + + // Termux terminals are especially sensitive to bypass-path cursor drift and + // stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this + // off by default in Termux mode; allow explicit opt-in for local debugging. + if (isTermuxTuiMode(env)) { + const override = String(env.CODER_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase() + + if (override) { + return /^(?:1|true|yes|on)$/i.test(override) + } + + return false + } + + return true +} + +function renderWithCursor(value: string, cursor: number) { + const pos = Math.max(0, Math.min(cursor, value.length)) + + let out = '', + done = false + + for (const { segment, index } of seg().segment(value)) { + if (!done && index >= pos) { + out += invert(index === pos && segment !== '\n' ? segment : ' ') + done = true + + if (index === pos && segment !== '\n') { + continue + } + } + + out += segment + } + + return done ? out : out + invert(' ') +} + +function renderWithSelection(value: string, start: number, end: number) { + if (start >= end) { + return value + } + + return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end) +} + +function useFwdDelete(active: boolean) { + const ref = useRef(false) + const { inputEmitter: ee } = useStdin() + + useEffect(() => { + if (!active) { + return + } + + const h = (d: string) => { + ref.current = FWD_DEL_RE.test(d) + } + + ee.prependListener('input', h) + + return () => { + ee.removeListener('input', h) + } + }, [active, ee]) + + return ref +} + +type PasteResult = { cursor: number; value: string } | null + +const isPasteResultPromise = ( + value: PasteResult | Promise | null | undefined +): value is Promise => !!value && typeof (value as PromiseLike).then === 'function' + +export function TextInput({ + columns = 80, + value, + onChange, + onPaste, + onSubmit, + mask, + mouseApiRef, + voiceRecordKey = DEFAULT_VOICE_RECORD_KEY, + placeholder = '', + focus = true +}: TextInputProps) { + const [cur, setCur] = useState(value.length) + const [sel, setSel] = useState(null) + const fwdDel = useFwdDelete(focus) + const termFocus = useTerminalFocus() + const { stdout } = useStdout() + const noteCursorAdvance = useCursorAdvance() + + const curRef = useRef(cur) + const selRef = useRef(null) + const vRef = useRef(value) + const self = useRef(false) + const keyBurstTimer = useRef | null>(null) + const editVersionRef = useRef(0) + const parentChangeTimer = useRef | null>(null) + const pendingParentValue = useRef(null) + const localRenderTimer = useRef | null>(null) + const lineWidthRef = useRef(stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value)) + const mouseAnchorRef = useRef(null) + const lastClickRef = useRef<{ at: number; offset: number }>({ at: 0, offset: -1 }) + const undo = useRef<{ cursor: number; value: string }[]>([]) + const redo = useRef<{ cursor: number; value: string }[]>([]) + + const cbChange = useRef(onChange) + const cbSubmit = useRef(onSubmit) + const cbPaste = useRef(onPaste) + cbChange.current = onChange + cbSubmit.current = onSubmit + cbPaste.current = onPaste + + const raw = self.current ? vRef.current : value + const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw + + const selected = useMemo( + () => + sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null, + [sel] + ) + + // Read `curRef.current` (always up-to-date) rather than the `cur` + // React state. The fast-echo path defers the React `setCur` by 16ms + // to batch re-renders during heavy typing; if an unrelated render + // flushes this component during that window and we used the stale + // `cur` state here, the layout effect inside `useDeclaredCursor` + // would publish a stale cursor declaration and clobber the Ink-level + // bump from `noteCursorAdvance(...)`. `cur` is still in scope and + // referenced by setSel/setCur paths below, so React tracks the + // dependency naturally — we just don't use it as the source of truth + // for layout. The cursorLayout call is cheap (one wrap-text pass + // over a single-line string in the common case), so dropping useMemo + // is fine. + const layout = cursorLayout(display, curRef.current, columns) + + const boxRef = useDeclaredCursor({ + line: layout.line, + column: layout.column, + active: focus && termFocus && !selected + }) + + // Hide the hardware cursor while a selection is active (prevents + // auto-wrap onto the next row when inverted text fills the column + // exactly) or when the terminal loses focus (suppresses the hollow-rect + // ghost most terminals draw at the parked position). + const hideHardwareCursor = focus && !!stdout?.isTTY && (!!selected || !termFocus) + + useEffect(() => { + if (!hideHardwareCursor || !stdout) { + return + } + + stdout.write('\x1b[?25l') + + return () => { + stdout.write('\x1b[?25h') + } + }, [hideHardwareCursor, stdout]) + + const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY + + // Placeholder text is just a hint, not a selection — render it dim + // without inverse styling. In a TTY the hardware cursor parks at column + // 0 and visually marks the input start. Non-TTY surfaces still need the + // synthetic inverse first-char to draw a cursor at all. + const rendered = useMemo(() => { + if (!focus) { + return display || dim(placeholder) + } + + if (!display && placeholder) { + return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) + } + + if (selected) { + return renderWithSelection(display, selected.start, selected.end) + } + + return nativeCursor ? display || ' ' : renderWithCursor(display, cur) + }, [cur, display, focus, nativeCursor, placeholder, selected]) + + useEffect(() => { + if (self.current) { + self.current = false + } else { + setCur(value.length) + setSel(null) + curRef.current = value.length + selRef.current = null + vRef.current = value + lineWidthRef.current = stringWidth(value.includes('\n') ? value.slice(value.lastIndexOf('\n') + 1) : value) + undo.current = [] + redo.current = [] + } + }, [value]) + + useEffect(() => { + if (!focus) { + return + } + + const dropSel = () => { + if (!selRef.current) { + return + } + + selRef.current = null + setSel(null) + } + + setInputSelection({ + clear: dropSel, + collapseToEnd: () => { + dropSel() + setCur(vRef.current.length) + curRef.current = vRef.current.length + }, + end: selected?.end ?? curRef.current, + start: selected?.start ?? curRef.current, + value: vRef.current + }) + + return () => setInputSelection(null) + }, [cur, focus, selected]) + + useEffect( + () => () => { + if (keyBurstTimer.current) { + clearTimeout(keyBurstTimer.current) + } + + if (parentChangeTimer.current) { + clearTimeout(parentChangeTimer.current) + } + + if (localRenderTimer.current) { + clearTimeout(localRenderTimer.current) + } + }, + [] + ) + + const flushParentChange = () => { + if (parentChangeTimer.current) { + clearTimeout(parentChangeTimer.current) + parentChangeTimer.current = null + } + + const next = pendingParentValue.current + pendingParentValue.current = null + + if (next !== null) { + self.current = true + cbChange.current(next) + } + } + + const scheduleParentChange = (next: string) => { + pendingParentValue.current = next + + if (parentChangeTimer.current) { + return + } + + parentChangeTimer.current = setTimeout(flushParentChange, FRAME_BATCH_MS) + } + + const cancelLocalRender = () => { + if (localRenderTimer.current) { + clearTimeout(localRenderTimer.current) + localRenderTimer.current = null + } + } + + const scheduleLocalRender = () => { + if (localRenderTimer.current) { + return + } + + localRenderTimer.current = setTimeout(() => { + localRenderTimer.current = null + setCur(curRef.current) + }, FRAME_BATCH_MS) + } + + const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY + + const canFastAppend = (current: string, cursor: number, text: string) => + canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) + + const canFastBackspace = (current: string, cursor: number) => + canFastEchoBase() && canFastBackspaceShape(current, cursor, columns) + + const commit = ( + next: string, + nextCur: number, + track = true, + syncParent = true, + syncLocal = true, + nextLineWidth?: number + ) => { + const prev = vRef.current + const c = snapPos(next, nextCur) + editVersionRef.current += 1 + + if (selRef.current) { + selRef.current = null + setSel(null) + } + + if (track && next !== prev) { + undo.current.push({ cursor: curRef.current, value: prev }) + + if (undo.current.length > 200) { + undo.current.shift() + } + + redo.current = [] + } + + if (syncLocal) { + cancelLocalRender() + setCur(c) + } else { + scheduleLocalRender() + } + + curRef.current = c + vRef.current = next + lineWidthRef.current = + nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next) + + if (next !== prev) { + if (syncParent) { + flushParentChange() + self.current = true + cbChange.current(next) + } else { + self.current = true + scheduleParentChange(next) + } + } + } + + const swap = (from: typeof undo, to: typeof redo) => { + const entry = from.current.pop() + + if (!entry) { + return + } + + to.current.push({ cursor: curRef.current, value: vRef.current }) + commit(entry.value, entry.cursor, false) + } + + const emitPaste = (e: PasteEvent) => { + const startVersion = editVersionRef.current + const h = cbPaste.current?.(e) + + if (isPasteResultPromise(h)) { + const fallbackText = e.text + + void h + .then(result => { + if (result && editVersionRef.current === startVersion) { + commit(result.value, result.cursor) + } else if (result && fallbackText && PRINTABLE.test(fallbackText)) { + // User typed while async paste was in-flight — fall back to raw text insert + // so the pasted content is not silently lost. + const cur = curRef.current + const v = vRef.current + commit(v.slice(0, cur) + fallbackText + v.slice(cur), cur + fallbackText.length) + } + }) + .catch(() => {}) + + return true + } + + if (h) { + commit(h.value, h.cursor) + } + + return !!h + } + + const flushKeyBurst = () => { + if (keyBurstTimer.current) { + clearTimeout(keyBurstTimer.current) + keyBurstTimer.current = null + } + + flushParentChange() + } + + const scheduleKeyBurstCommit = (next: string, nextCur: number) => { + commit(next, nextCur, true, false, false) + + if (keyBurstTimer.current) { + return + } + + keyBurstTimer.current = setTimeout(() => { + keyBurstTimer.current = null + flushParentChange() + }, FRAME_BATCH_MS) + } + + const clearSel = () => { + if (!selRef.current) { + return + } + + selRef.current = null + setSel(null) + } + + const selectAll = () => { + const end = vRef.current.length + + if (!end) { + return + } + + const next = { end, start: 0 } + selRef.current = next + setSel(next) + setCur(end) + curRef.current = end + } + + const moveCursor = (next: number, extend = false) => { + const c = snapPos(vRef.current, next) + const anchor = selRef.current?.start ?? curRef.current + + if (!extend || anchor === c) { + clearSel() + } else { + const nextSel = { end: c, start: anchor } + selRef.current = nextSel + setSel(nextSel) + } + + setCur(c) + curRef.current = c + } + + const selRange = () => { + const range = selRef.current + + return range && range.start !== range.end + ? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) } + : null + } + + const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) + + const pastePlainText = (text: string) => { + const cleaned = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + if (!cleaned) { + return + } + + const range = selRange() + + const nextValue = range + ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end) + : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current) + + const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length + + commit(nextValue, nextCursor) + } + + const startMouseSelection = (next: number) => { + const c = snapPos(vRef.current, next) + + mouseAnchorRef.current = c + selRef.current = { end: c, start: c } + setSel(null) + setCur(c) + curRef.current = c + } + + const dragMouseSelection = (next: number) => { + if (mouseAnchorRef.current === null) { + return + } + + const c = snapPos(vRef.current, next) + const range = { end: c, start: mouseAnchorRef.current } + selRef.current = range + setSel(range.start === range.end ? null : range) + setCur(c) + curRef.current = c + } + + const endMouseSelection = () => { + mouseAnchorRef.current = null + + const range = selRef.current + + if (range && range.start === range.end) { + selRef.current = null + setSel(null) + + return + } + + const normalized = selRange() + + if (isMac && normalized) { + void writeClipboardText(vRef.current.slice(normalized.start, normalized.end)) + } + } + + const offsetAt = (e: { localCol?: number; localRow?: number }) => + offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + + const isMultiClickAt = (offset: number) => { + const now = Date.now() + const last = lastClickRef.current + lastClickRef.current = { at: now, offset } + + return now - last.at < MULTI_CLICK_MS && offset === last.offset + } + + if (mouseApiRef) { + mouseApiRef.current = { + dragAt: (row, col) => dragMouseSelection(offsetFromPosition(display, row, col, columns)), + end: endMouseSelection, + startAtBeginning: () => startMouseSelection(0) + } + } + + useInput( + (inp: string, k: Key, event: InputEvent) => { + const eventRaw = event.keypress.raw + + // Configured voice shortcut wins over composer-level defaults like + // paste/copy so users who bind voice to ctrl+v / alt+v / cmd+v + // actually get voice toggled instead of a paste (Copilot round-7 + // follow-up on #19835). The pass-through predicate is a no-op for + // ordinary typing and plain paste when voice is unbound to 'v'. + if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) { + flushKeyBurst() + + return + } + + if ( + eventRaw === '\x1bv' || + eventRaw === '\x1bV' || + eventRaw === '\x16' || + (isMac && isActionMod(k) && inp.toLowerCase() === 'v') + ) { + flushKeyBurst() + + if (cbPaste.current) { + return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + } + + if (isMac) { + void readClipboardText().then(text => { + if (text) { + pastePlainText(text) + } + }) + } + + return + } + + if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') { + flushKeyBurst() + + const range = selRange() + + if (range) { + const text = vRef.current.slice(range.start, range.end) + + void writeClipboardText(text) + } + + return + } + + if (k.upArrow || k.downArrow) { + flushKeyBurst() + + const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1) + + if (next !== null) { + moveCursor(next, k.shift) + + return + } + + return + } + + if (k.return) { + flushKeyBurst() + + if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) { + commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) + } else { + cbSubmit.current?.(vRef.current) + } + + return + } + + let c = curRef.current + let v = vRef.current + const mod = isActionMod(k) + const wordMod = mod || k.meta + const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a') + const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e') + const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u') + const actionKillToEnd = (mod && inp === 'k') || isMacActionFallback(k, inp, 'k') + const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w') + const range = selRange() + const delFwd = k.delete || fwdDel.current + const isPrintableInput = (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, '')) + + if (!isPrintableInput) { + flushKeyBurst() + } + + if (mod && inp === 'z') { + return swap(undo, redo) + } + + if ((mod && inp === 'y') || (mod && k.shift && inp === 'z')) { + return swap(redo, undo) + } + + if (isMac && mod && inp === 'a') { + return selectAll() + } + + if (actionHome) { + c = 0 + moveCursor(c, k.shift) + + return + } else if (actionEnd) { + c = v.length + moveCursor(c, k.shift) + + return + } else if (k.leftArrow) { + if (range && !wordMod && !k.shift) { + clearSel() + c = range.start + } else { + c = wordMod ? wordLeft(v, c) : prevPos(v, c) + } + + moveCursor(c, k.shift) + + return + } else if (k.rightArrow) { + if (range && !wordMod && !k.shift) { + clearSel() + c = range.end + } else { + c = wordMod ? wordRight(v, c) : nextPos(v, c) + } + + moveCursor(c, k.shift) + + return + } else if (wordMod && inp === 'b') { + clearSel() + c = wordLeft(v, c) + } else if (wordMod && inp === 'f') { + clearSel() + c = wordRight(v, c) + } else if (range && (k.backspace || delFwd)) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else if (k.backspace && c > 0) { + if (wordMod) { + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else if (canFastBackspace(v, c)) { + const t = prevPos(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + stdout!.write('\b \b') + // The "\b \b" sequence ends with the cursor one column to the + // LEFT of where Ink last parked it. Tell Ink so its `displayCursor` + // (and log-update's relative-move basis on the next frame) stays + // in sync — otherwise the cursor parks one cell to the right of + // the caret on the next unrelated re-render. + noteCursorAdvance(-1) + commit(v, c, true, false, false, Math.max(0, lineWidthRef.current - 1)) + + return + } else { + const t = prevPos(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } + } else if (delFwd && c < v.length) { + if (wordMod) { + const t = wordRight(v, c) + v = v.slice(0, c) + v.slice(t) + } else { + v = v.slice(0, c) + v.slice(nextPos(v, c)) + } + } else if (actionDeleteWord) { + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else if (c > 0) { + clearSel() + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + return + } + } else if (actionDeleteToStart) { + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else { + v = v.slice(c) + c = 0 + } + } else if (actionKillToEnd) { + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else { + v = v.slice(0, c) + } + } else if (event.keypress.isPasted || inp.length > 0) { + const bracketed = event.keypress.isPasted || inp.includes('[200~') + const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) { + return + } + + if (!text) { + return + } + + if (text === '\n') { + return commit(ins(v, c, '\n'), c + 1) + } + + if (text.length > 1 || text.includes('\n')) { + if (shouldRouteMultiCharInputAsPaste(text)) { + flushKeyBurst() + + if (!emitPaste({ cursor: c, text, value: v })) { + commit(ins(v, c, text), c + text.length) + } + + return + } + + const inserted = applyPrintableInsert(v, c, text, range) + + if (!inserted) { + return + } + + v = inserted.value + c = inserted.cursor + scheduleKeyBurstCommit(v, c) + + return + } + + { + const inserted = applyPrintableInsert(v, c, text, range) + + if (!inserted) { + return + } + + if (range) { + v = inserted.value + c = inserted.cursor + } else { + const simpleAppend = canFastAppend(v, c, text) + + v = inserted.value + c = inserted.cursor + + if (simpleAppend) { + stdout!.write(text) + // ASCII-printable text advances the physical cursor by exactly + // text.length cells (canFastAppendShape rejects non-ASCII, + // wide chars, newlines). Notify Ink so the cached displayCursor + // / log-update relative-move basis advances with it; otherwise + // any unrelated re-render that happens before the 16ms + // setCur/setParent flush parks the cursor text.length cells + // too far right (#cursor-drift). + noteCursorAdvance(text.length) + commit(v, c, true, false, false, lineWidthRef.current + stringWidth(text)) + + return + } + } + } + } else { + return + } + + commit(v, c) + }, + { isActive: focus } + ) + + return ( + { + if (!focus) { + return + } + + e.stopImmediatePropagation?.() + clearSel() + const next = offsetAt(e) + setCur(next) + curRef.current = next + }} + onMouseDown={(e: MouseEventLite) => { + if (!focus) { + return + } + + // Right-click → copy active selection if any, otherwise paste. + if (e.button === 2) { + e.stopImmediatePropagation?.() + const decision = decideRightClickAction(vRef.current, selRange()) + + if (decision.action === 'copy') { + void writeClipboardText(decision.text) + + return + } + + emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + + return + } + + if (e.button !== 0) { + return + } + + e.stopImmediatePropagation?.() + const offset = offsetAt(e) + + if (isMultiClickAt(offset)) { + mouseAnchorRef.current = null + selectAll() + + return + } + + startMouseSelection(offset) + }} + onMouseDrag={(e: MouseEventLite) => { + if (!focus || e.button !== 0 || mouseAnchorRef.current === null) { + return + } + + e.stopImmediatePropagation?.() + dragMouseSelection(offsetAt(e)) + }} + onMouseUp={(e: MouseEventLite) => { + e.stopImmediatePropagation?.() + endMouseSelection() + }} + ref={boxRef} + width={columns} + > + {rendered} + + ) +} + +type MouseEventLite = { + button?: number + localCol?: number + localRow?: number + stopImmediatePropagation?: () => void +} + +export interface PasteEvent { + bracketed?: boolean + cursor: number + hotkey?: boolean + text: string + value: string +} + +interface TextInputProps { + columns?: number + focus?: boolean + mask?: string + mouseApiRef?: MutableRefObject + onChange: (v: string) => void + onPaste?: ( + e: PasteEvent + ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null + onSubmit?: (v: string) => void + placeholder?: string + value: string + voiceRecordKey?: ParsedVoiceRecordKey +} + +export type RightClickDecision = + | { action: 'copy'; text: string } + | { action: 'paste' } + +/** + * Decide what right-click should do on the composer: + * - non-empty selection → copy that text to the clipboard + * - no selection (or empty/collapsed range) → fall through to paste + * + * Mirrors terminal-native behavior (xterm, iTerm, gnome-terminal) where + * right-click pastes only when there is nothing selected to copy. + * + * Callers pass the already-normalized range from `selRange()` (start <= end, + * or null when collapsed), so this helper does not need to re-normalize. + */ +export function decideRightClickAction( + value: string, + range: { end: number; start: number } | null +): RightClickDecision { + if (range && range.end > range.start) { + const text = value.slice(range.start, range.end) + + if (text) { + return { action: 'copy', text } + } + } + + return { action: 'paste' } +} + +/** + * When true, TextInput passes arrow keys and Enter through to the parent + * `useInput` handler instead of consuming them internally. Set by the + * slash-command popup so the user can navigate the command list with + * ↑↓ and confirm with Enter while the underlying composer remains live + * for typing the filter prefix. + */ +let slashPopupActive = false + +/** Call with `true` when the slash-command popup opens, `false` when it closes. */ +export function setTextInputSlashPopupActive(active: boolean): void { + slashPopupActive = active +} + +export const shouldPassThroughToGlobalHandler = ( + input: string, + key: Key, + voiceRecordKey: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY +): boolean => + (key.ctrl && input === 'c') || + (key.ctrl && input === 'x') || + key.tab || + (key.shift && key.tab) || + key.pageUp || + key.pageDown || + key.escape || + (slashPopupActive && (key.upArrow || key.downArrow || key.return)) || + isVoiceToggleKey(key, input, voiceRecordKey) + +export interface TextInputMouseApi { + dragAt: (row: number, col: number) => void + end: () => void + startAtBeginning: () => void +} diff --git a/packages/cli/src/components/themed.tsx b/packages/cli/src/components/themed.tsx new file mode 100644 index 0000000..3975a49 --- /dev/null +++ b/packages/cli/src/components/themed.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Text } from '@coder/tui' +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { $uiState } from '../app/uiStore.js' +import type { ThemeColors } from '../theme.js' + +export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { + const { theme } = useStore($uiState) + + return ( + + {children} + + ) +} + +export type ThemeColor = keyof ThemeColors + +export interface FgProps { + bold?: boolean + c?: ThemeColor + children?: ReactNode + dim?: boolean + italic?: boolean + literal?: string + strikethrough?: boolean + underline?: boolean + wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' +} diff --git a/packages/cli/src/components/thinking.tsx b/packages/cli/src/components/thinking.tsx new file mode 100644 index 0000000..a1198a2 --- /dev/null +++ b/packages/cli/src/components/thinking.tsx @@ -0,0 +1,1401 @@ +import React from 'react' +import { Box, NoSelect, Text } from '@coder/tui' +import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import spinners, { type BrailleSpinnerName } from 'unicode-animations' + +import { THINKING_COT_MAX } from '../config/limits.js' +import { sectionMode } from '../domain/details.js' +import { + buildSubagentTree, + fmtCost, + fmtTokens, + formatSummary as formatSpawnSummary, + hotnessBucket, + peakHotness, + sparkline, + treeTotals, + widthByDepth +} from '../lib/subagentTree.js' +import { + boundedLiveRenderText, + cleanThinkingText, + compactPreview, + estimateTokensRough, + fmtK, + formatToolCall, + parseToolArgs, + parseToolTrailResultLine, + pick, + splitToolDuration, + thinkingPreview, + toolTrailLabel +} from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { + ActiveTool, + ActivityItem, + DetailsMode, + SectionVisibility, + SubagentNode, + SubagentProgress, + ThinkingMode +} from '../types.js' + +const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] +const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] + +const fmtElapsed = (ms: number) => { + const sec = Math.max(0, ms) / 1000 + + return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` +} + +type TreeBranch = 'mid' | 'last' +type TreeRails = readonly boolean[] + +const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid'] + +const treeLead = (rails: TreeRails, branch: TreeBranch) => + `${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}` + +// ── Primitives ─────────────────────────────────────────────────────── + +function TreeRow({ + branch, + children, + rails = [], + stemColor, + stemDim = true, + t +}: { + branch: TreeBranch + children: ReactNode + rails?: TreeRails + stemColor?: string + stemDim?: boolean + t: Theme +}) { + const lead = treeLead(rails, branch) + + return ( + + + + {lead} + + + + {children} + + + ) +} + +function TreeTextRow({ + branch, + color, + content, + dimColor, + rails = [], + t, + wrap = 'wrap-trim' +}: { + branch: TreeBranch + color: string + content: ReactNode + dimColor?: boolean + rails?: TreeRails + t: Theme + wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' +}) { + const text = dimColor ? ( + + {content} + + ) : ( + + {content} + + ) + + return ( + + {text} + + ) +} + +function TreeNode({ + branch, + children, + header, + open, + rails = [], + stemColor, + stemDim, + t +}: { + branch: TreeBranch + children?: (rails: boolean[]) => ReactNode + header: ReactNode + open: boolean + rails?: TreeRails + stemColor?: string + stemDim?: boolean + t: Theme +}) { + return ( + + + {header} + + {open ? children?.(nextTreeRails(rails, branch)) : null} + + ) +} + +export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { + const spin = useMemo(() => { + const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] + + return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } + }, [variant]) + + const [frame, setFrame] = useState(0) + + useEffect(() => { + setFrame(0) + }, [spin]) + + useEffect(() => { + const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) + + return () => clearInterval(id) + }, [spin]) + + return {spin.frames[frame]} +} + +interface DetailRow { + color: string + content: ReactNode + dimColor?: boolean + key: string +} + +function Detail({ + branch = 'last', + color, + content, + dimColor, + rails = [], + t +}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) { + return +} + +const RESULT_PREVIEW_LINES = 4 + +function TruncatedResult({ + branch = 'last', + color, + content, + dimColor, + rails = [], + t +}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) { + const [expanded, setExpanded] = useState(false) + const text = typeof content === 'string' ? content : '' + const isReactNode = !text && content + const lines = useMemo(() => text.split('\n'), [text]) + const truncated = !isReactNode && lines.length > RESULT_PREVIEW_LINES + const visible = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES) + const hidden = lines.length - RESULT_PREVIEW_LINES + + if (isReactNode) { + return ( + + {content as ReactNode} + + ) + } + + return ( + + {visible.map((line, i) => ( + + ))} + {truncated && !expanded ? ( + + setExpanded(true)}> + ... +{hidden} lines (click to expand) + + + ) : null} + {expanded ? ( + + setExpanded(false)}> + ... collapse + + + ) : null} + + ) +} + +function StreamCursor({ + color, + dimColor, + streaming = false, + visible = false +}: { + color: string + dimColor?: boolean + streaming?: boolean + visible?: boolean +}) { + const [on, setOn] = useState(true) + + useEffect(() => { + if (!visible || !streaming) { + setOn(true) + + return + } + + const id = setInterval(() => setOn(v => !v), 420) + + return () => clearInterval(id) + }, [streaming, visible]) + + if (!visible) { + return null + } + + return dimColor ? ( + + {streaming && on ? '▍' : ' '} + + ) : ( + {streaming && on ? '▍' : ' '} + ) +} + +function Chevron({ + count, + onClick, + open, + suffix, + t, + title, + tone = 'dim' +}: { + count?: number + onClick: (deep?: boolean) => void + open: boolean + suffix?: string + t: Theme + title: string + tone?: 'dim' | 'error' | 'warn' +}) { + const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.text + + return ( + onClick(!!e?.shiftKey || !!e?.ctrlKey)}> + + {title} + {typeof count === 'number' ? ` (${count})` : ''} + {suffix ? ( + + {' '} + {suffix} + + ) : null} + + + ) +} + +function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined { + const palette = [theme.color.border, theme.color.accent, theme.color.primary, theme.color.warn, theme.color.error] + const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length) + + // Below the median bucket we keep the default dim stem so cool branches + // fade into the chrome — only "hot" branches draw the eye. + if (idx < 2) { + return undefined + } + + return palette[idx] +} + +function SubagentAccordion({ + branch, + expanded, + node, + peak, + rails = [], + t +}: { + branch: TreeBranch + expanded: boolean + node: SubagentNode + peak: number + rails?: TreeRails + t: Theme +}) { + const [open, setOpen] = useState(expanded) + const [deep, setDeep] = useState(expanded) + const [openThinking, setOpenThinking] = useState(expanded) + const [openTools, setOpenTools] = useState(expanded) + const [openNotes, setOpenNotes] = useState(expanded) + const [openKids, setOpenKids] = useState(expanded) + + useEffect(() => { + if (!expanded) { + return + } + + setOpen(true) + setDeep(true) + setOpenThinking(true) + setOpenTools(true) + setOpenNotes(true) + setOpenKids(true) + }, [expanded]) + + const expandAll = () => { + setOpen(true) + setDeep(true) + setOpenThinking(true) + setOpenTools(true) + setOpenNotes(true) + setOpenKids(true) + } + + const item = node.item + const children = node.children + const aggregate = node.aggregate + + const statusTone: 'dim' | 'error' | 'warn' = + item.status === 'error' || item.status === 'failed' + ? 'error' + : item.status === 'interrupted' || item.status === 'timeout' + ? 'warn' + : 'dim' + + const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' + const goalLabel = item.goal || `Subagent ${item.index + 1}` + const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` + const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) + + // Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost. + // Emphasises the numbers the user can't easily eyeball from a flat list. + const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status) + + const rollupBits: string[] = [statusLabel] + + if (item.durationSeconds) { + rollupBits.push(fmtElapsed(item.durationSeconds * 1000)) + } + + const localTools = item.toolCount ?? 0 + const subtreeTools = aggregate.totalTools - localTools + + if (localTools > 0) { + rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`) + } + + const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0) + + if (localTokens > 0) { + rollupBits.push(`${fmtTokens(localTokens)} tok`) + } + + const localCost = item.costUsd ?? 0 + + if (localCost > 0) { + rollupBits.push(fmtCost(localCost)) + } + + const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0) + + if (filesLocal > 0) { + rollupBits.push(`⎘${filesLocal}`) + } + + if (children.length > 0) { + rollupBits.push(`${aggregate.descendantCount}↓`) + + if (subtreeTools > 0) { + rollupBits.push(`+${subtreeTools}t sub`) + } + + const subCost = aggregate.costUsd - localCost + + if (subCost >= 0.01) { + rollupBits.push(`+${fmtCost(subCost)} sub`) + } + + if (aggregate.activeCount > 0 && item.status !== 'running') { + rollupBits.push(`⚡${aggregate.activeCount}`) + } + } + + const suffix = rollupBits.join(' · ') + + const thinkingText = item.thinking.join('\n') + const hasThinking = Boolean(thinkingText) + const hasTools = item.tools.length > 0 + const noteRows = [...(summary ? [summary] : []), ...item.notes] + const hasNotes = noteRows.length > 0 + const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.muted + + const sections: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + open={openThinking} + t={t} + title="Thought" + /> + ), + key: 'thinking', + open: openThinking, + render: childRails => ( + + ) + }) + } + + if (hasTools) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={openTools} + t={t} + title="Tool calls" + /> + ), + key: 'tools', + open: openTools, + render: childRails => ( + + {item.tools.map((line, index) => ( + + + {line} + + } + key={`${item.id}-tool-${index}`} + rails={childRails} + t={t} + /> + ))} + + ) + }) + } + + if (hasNotes) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenNotes(v => !v) + } + }} + open={openNotes} + t={t} + title="Progress" + tone={statusTone} + /> + ), + key: 'notes', + open: openNotes, + render: childRails => ( + + {noteRows.map((line, index) => ( + + ))} + + ) + }) + } + + if (children.length > 0) { + // Nested grandchildren — rendered recursively via SubagentAccordion, + // sharing the same keybindings / expand semantics as top-level nodes. + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenKids(v => !v) + } + }} + open={openKids} + suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`} + t={t} + title="Spawned" + /> + ), + key: 'subagents', + open: openKids, + render: childRails => ( + + {children.map((child, i) => ( + + ))} + + ) + }) + } + + // Heatmap: amber→error gradient on the stem when this branch is "hot" + // (high tools/sec) relative to the whole tree's peak. + const stem = heatColor(node, peak, t) + + return ( + { + if (shift) { + expandAll() + + return + } + + setOpen(v => { + if (!v) { + setDeep(false) + } + + return !v + }) + }} + open={open} + suffix={suffix} + t={t} + title={title} + tone={statusTone} + /> + } + open={open} + rails={rails} + stemColor={stem} + stemDim={stem == null} + t={t} + > + {childRails => ( + + {sections.map((section, index) => ( + + {section.render} + + ))} + + )} + + ) +} + +// ── Thinking ───────────────────────────────────────────────────────── + +export const THOUGHT_PREVIEW_LINES = 2 + +export const Thinking = memo(function Thinking({ + active = false, + branch = 'last', + mode = 'truncated', + rails = [], + reasoning, + streaming = false, + t +}: { + active?: boolean + branch?: TreeBranch + mode?: ThinkingMode + rails?: TreeRails + reasoning: string + streaming?: boolean + t: Theme +}) { + const preview = useMemo(() => { + const raw = thinkingPreview(reasoning, mode, THINKING_COT_MAX) + + return mode === 'full' ? boundedLiveRenderText(raw) : raw + }, [mode, reasoning]) + + const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) + + if (!preview && !active) { + return null + } + + return ( + + + {preview ? ( + mode === 'full' ? ( + lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} + + )) + ) : ( + + {preview} + + + ) + ) : ( + + + + )} + + + ) +}) + +// ── ToolTrail ──────────────────────────────────────────────────────── + +interface Group { + color: string + content: ReactNode + details: DetailRow[] + key: string + label: string +} + +export const ToolTrail = memo(function ToolTrail({ + busy = false, + commandOverride = false, + detailsMode = 'collapsed', + outcome = '', + reasoningActive = false, + reasoning = '', + reasoningTokens, + reasoningStreaming = false, + sections, + subagents = [], + t, + tools = [], + toolTokens, + trail = [], + activity = [] +}: { + busy?: boolean + commandOverride?: boolean + detailsMode?: DetailsMode + outcome?: string + reasoningActive?: boolean + reasoning?: string + reasoningTokens?: number + reasoningStreaming?: boolean + sections?: SectionVisibility + subagents?: SubagentProgress[] + t: Theme + tools?: ActiveTool[] + toolTokens?: number + trail?: string[] + activity?: ActivityItem[] +}) { + const visible = useMemo( + () => ({ + thinking: sectionMode('thinking', detailsMode, sections, commandOverride), + tools: sectionMode('tools', detailsMode, sections, commandOverride), + subagents: sectionMode('subagents', detailsMode, sections, commandOverride), + activity: sectionMode('activity', detailsMode, sections, commandOverride) + }), + [commandOverride, detailsMode, sections] + ) + + const [now, setNow] = useState(() => Date.now()) + // Local toggles own the open state once mounted. Init from the resolved + // section visibility so default-expanded sections (thinking/tools) render + // open on first paint; the useEffect below re-syncs when the user mutates + // visibility at runtime via /details. NEVER OR these against + // `visible.X === 'expanded'` at render time — that locks the panel open + // and silently breaks manual chevron clicks for default-expanded + // sections (regression caught after #14968). + const [openThinking, setOpenThinking] = useState(visible.thinking === 'expanded') + const thinkingUserToggledRef = useRef(false) + const [openTools, setOpenTools] = useState(true) + const toolsUserToggledRef = useRef(false) + const [openSubagents, setOpenSubagents] = useState(visible.subagents === 'expanded') + const [deepSubagents, setDeepSubagents] = useState(visible.subagents === 'expanded') + const [openMeta, setOpenMeta] = useState(visible.activity === 'expanded') + + useEffect(() => { + if (!tools.length || (visible.tools !== 'expanded' && !openTools)) { + return + } + + const id = setInterval(() => setNow(Date.now()), 500) + + return () => clearInterval(id) + }, [openTools, tools.length, visible.tools]) + + useEffect(() => { + if (!thinkingUserToggledRef.current) { + setOpenThinking(visible.thinking === 'expanded') + } + if (!toolsUserToggledRef.current) { + setOpenTools(visible.tools === 'expanded') + } + setOpenSubagents(visible.subagents === 'expanded') + setOpenMeta(visible.activity === 'expanded') + }, [visible.thinking, visible.tools, visible.subagents, visible.activity]) + + const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) + + // Spawn-tree derivations must live above any early return so React's + // rules-of-hooks sees a stable call order. Cheap O(N) builds memoised + // by subagent-list identity. + const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents]) + const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree]) + const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree]) + const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree]) + const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths]) + const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals]) + + if ( + !busy && + !trail.length && + !tools.length && + !subagents.length && + !activity.length && + !cot && + !reasoningActive && + !outcome + ) { + return null + } + + // ── Build groups + meta ──────────────────────────────────────── + + const groups: Group[] = [] + const meta: DetailRow[] = [] + const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row) + + for (const [i, line] of trail.entries()) { + const parsed = parseToolTrailResultLine(line) + + if (parsed) { + groups.push({ + color: parsed.mark === '✗' ? t.color.error : t.color.text, + content: parsed.call, + details: [], + key: `tr-${i}`, + label: parsed.call + }) + + if (parsed.detail) { + pushDetail({ + color: parsed.mark === '✗' ? t.color.error : t.color.muted, + content: parsed.detail, + dimColor: parsed.mark !== '✗', + key: `tr-${i}-d` + }) + } + + continue + } + + if (line.startsWith('drafting ')) { + const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) + + groups.push({ + color: t.color.text, + content: label, + details: [{ color: t.color.muted, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], + key: `tr-${i}`, + label + }) + + continue + } + + if (line === 'analyzing tool output…') { + pushDetail({ + color: t.color.muted, + dimColor: true, + key: `tr-${i}`, + content: groups.length ? ( + <> + {line} + + ) : ( + line + ) + }) + + continue + } + + meta.push({ color: t.color.muted, content: line, dimColor: true, key: `tr-${i}` }) + } + + for (const tool of tools) { + const args = parseToolArgs(tool.verboseArgs ?? '') + const label = args?.description ?? formatToolCall(tool.name, tool.verboseArgs || tool.context || '') + const hasDescription = Boolean(args?.description) + const details: DetailRow[] = [] + if (args?.command) { + if (hasDescription) { + // Header shows the description — detail shows the tool call. + const toolLine = formatToolCall(tool.name, args.command) + details.push({ + color: t.color.muted, + content: toolLine, + dimColor: true, + key: `${tool.id}-cmd` + }) + } + // Without a description the header already shows the tool call — + // no need to duplicate it in the detail. + } else if (tool.verboseArgs) { + if (hasDescription) { + const toolLine = formatToolCall(tool.name, tool.verboseArgs || tool.context || '') + details.push({ + color: t.color.muted, + content: `${toolLine}\n${boundedLiveRenderText(tool.verboseArgs)}`, + dimColor: true, + key: `${tool.id}-args` + }) + } + // Without a description, header = tool call, skip the detail. + } + + groups.push({ + color: t.color.text, + key: tool.id, + label, + details, + content: ( + <> + {label} + {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} + + ) + }) + } + + for (const item of activity.slice(-4)) { + const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' + const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.muted + meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) + } + + // ── Derived ──────────────────────────────────────────────────── + + const hasTools = groups.length > 0 + const hasSubagents = subagents.length > 0 + const hasMeta = meta.length > 0 + const hasThinking = !!cot || reasoningActive || reasoningStreaming + const thinkingLive = reasoningActive || reasoningStreaming + const terminalCols = process.stdout.columns || 80 + const thoughtPreviewLines = useMemo(() => { + const raw = cleanThinkingText(reasoning) + const sourceLines = raw.split('\n').filter(Boolean) + // Collect source lines until they fill THOUGHT_PREVIEW_LINES visual lines + const result: string[] = [] + let visualLines = 0 + for (const line of sourceLines) { + result.push(line) + visualLines += Math.max(1, Math.ceil(line.length / terminalCols)) + if (visualLines >= THOUGHT_PREVIEW_LINES) break + } + return result + }, [reasoning, terminalCols]) + const totalThoughtLines = useMemo(() => { + const raw = cleanThinkingText(reasoning) + const sourceLines = raw.split('\n').filter(Boolean) + return sourceLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / terminalCols)), 0) + }, [reasoning, terminalCols]) + + // Auto-expand Thinking during streaming, revert to user preference when done. + // User clicks take precedence for the remainder of the turn. + // Collapse to preview once reasoning exceeds THOUGHT_PREVIEW_LINES to avoid + // flooding the screen during long streaming output. + useEffect(() => { + if (thinkingLive) { + thinkingUserToggledRef.current = false + if (totalThoughtLines > THOUGHT_PREVIEW_LINES) { + setOpenThinking(false) + } else { + setOpenThinking(true) + } + } else if (!thinkingUserToggledRef.current) { + setOpenThinking(visible.thinking === 'expanded') + } + }, [thinkingLive, visible.thinking, totalThoughtLines]) + + // Auto-expand Tool calls while tools are running, revert to user preference when done. + // Same logic as Thinking: user clicks take precedence for the remainder of the turn. + const toolsActive = tools.length > 0 + + useEffect(() => { + if (toolsActive) { + toolsUserToggledRef.current = false + setOpenTools(true) + } else if (!toolsUserToggledRef.current && hasTools) { + setOpenTools(true) + } + }, [toolsActive, hasTools, visible.tools]) + + const tokenCount = + reasoningTokens && reasoningTokens > 0 ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 + + const toolTokenCount = toolTokens ?? 0 + const totalTokenCount = tokenCount + toolTokenCount + const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null + + const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined + + const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null + const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) + const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null + + // ── Backstop: floating alerts when every panel is hidden ───────── + // + // Per-section overrides win over the global details_mode (they're computed + // by sectionMode), so we only collapse to nothing when EVERY section is + // resolved to hidden — that way `details_mode: hidden` + `sections.tools: + // expanded` still renders the tools panel. When all panels are hidden + // AND ambient errors/warnings exist, surface them as a compact inline + // backstop so quiet-mode users aren't blind to failures. + + const allHidden = + visible.thinking === 'hidden' && + visible.tools === 'hidden' && + visible.subagents === 'hidden' && + visible.activity === 'hidden' + + if (allHidden) { + const alerts = activity.filter(i => i.tone !== 'info').slice(-2) + + return alerts.length ? ( + + {alerts.map(i => ( + + {i.tone === 'error' ? '✗' : '!'} {i.text} + + ))} + + ) : null + } + + // ── Tree render fragments ────────────────────────────────────── + // + // Shift+click on any chevron expands every NON-hidden section at once — + // hidden sections stay hidden so the override is honoured. + + const expandAll = () => { + // Treat shift+click as a manual user action so auto-collapse won't + // override it for the remainder of the turn. + thinkingUserToggledRef.current = true + toolsUserToggledRef.current = true + + if (visible.thinking !== 'hidden') { + setOpenThinking(true) + } + + if (visible.tools !== 'hidden') { + setOpenTools(true) + } + + if (visible.subagents !== 'hidden') { + setOpenSubagents(true) + setDeepSubagents(true) + } + + if (visible.activity !== 'hidden') { + setOpenMeta(true) + } + } + + const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') + ? 'error' + : activity.some(i => i.tone === 'warn') + ? 'warn' + : 'dim' + + const renderSubagentList = (rails: boolean[]) => ( + + {spawnTree.map((node, index) => ( + + ))} + + ) + + const panels: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking && visible.thinking !== 'hidden') { + const showPreview = !openThinking && thoughtPreviewLines.length > 0 + panels.push({ + header: ( + { + thinkingUserToggledRef.current = true + if (e?.shiftKey || e?.ctrlKey) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + > + + + {thinkingLive ? ( + + Thinking + + ) : ( + + Thought + + )} + {thinkingTokensLabel ? ( + + {' '} + {thinkingTokensLabel} + + ) : null} + + + {showPreview + ? ( + <> + {thoughtPreviewLines.map((line, i) => ( + + {line || ' '} + + ))} + {totalThoughtLines > THOUGHT_PREVIEW_LINES ? ( + + ... +{totalThoughtLines - THOUGHT_PREVIEW_LINES} lines (click to expand) + + ) : null} + + ) + : null} + + ), + key: 'thinking', + open: openThinking, + render: rails => ( + + THOUGHT_PREVIEW_LINES ? 'mid' : 'last'} + mode="full" + rails={rails} + reasoning={busy ? reasoning : cot} + streaming={busy && reasoningStreaming} + t={t} + /> + {!thinkingLive && totalThoughtLines > THOUGHT_PREVIEW_LINES ? ( + + setOpenThinking(false)}> + ... collapse + + + ) : null} + + ) + }) + } + + if (hasTools && visible.tools !== 'hidden') { + groups.forEach((group, groupIndex) => { + const { duration, label: toolHeaderLabel } = splitToolDuration(group.label) + const tone: 'dim' | 'error' | 'warn' = group.color === t.color.error ? 'error' : 'dim' + const isLastGroup = groupIndex === groups.length - 1 + const suffix = [duration, isLastGroup ? toolTokensLabel : undefined] + .filter(Boolean).join(' ') || undefined + + panels.push({ + header: ( + { + toolsUserToggledRef.current = true + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={openTools} + suffix={suffix} + t={t} + title={toolHeaderLabel} + tone={tone} + /> + ), + key: `tool-${group.key}`, + open: openTools, + render: rails => { + const hasInlineSubagents = inlineDelegateKey === group.key + const childRails = nextTreeRails(rails, 'mid') + + return ( + + {group.details.map((detail, detailIndex) => { + const detailBranch: TreeBranch = + detailIndex === group.details.length - 1 && !hasInlineSubagents ? 'last' : 'mid' + const text = typeof detail.content === 'string' ? detail.content : '' + const nl = text.indexOf('\n') + // Bold the tool name in "Bash(command)" first line + const toolCallMatch = nl > 0 ? text.slice(0, nl).match(/^(\w+)\((.+)\)$/) : null + + if (toolCallMatch) { + const toolName = toolCallMatch[1]! + const toolArg = toolCallMatch[2]! + const rest = text.slice(nl + 1) + return ( + + + {toolName} + ({toolArg}) + + } + rails={rails} + t={t} + /> + + + ) + } + + return ( + + ) + })} + {hasInlineSubagents ? renderSubagentList(childRails) : null} + + ) + } + }) + }) + } + + if (hasSubagents && !inlineDelegateKey && visible.subagents !== 'hidden') { + // Spark + summary give a one-line read on the branch shape before + // opening the subtree. `/agents` opens the full-screen audit overlay. + const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)` + + panels.push({ + header: ( + { + if (shift) { + expandAll() + setDeepSubagents(true) + } else { + setOpenSubagents(v => !v) + setDeepSubagents(false) + } + }} + open={openSubagents} + suffix={suffix} + t={t} + title="Spawn tree" + /> + ), + key: 'subagents', + open: openSubagents, + render: renderSubagentList + }) + } + + if (hasMeta && visible.activity !== 'hidden') { + panels.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenMeta(v => !v) + } + }} + open={openMeta} + t={t} + title="Activity" + tone={metaTone} + /> + ), + key: 'meta', + open: openMeta, + render: rails => ( + + {meta.map((row, index) => ( + + ))} + + ) + }) + } + + const topCount = panels.length + (totalTokensLabel ? 1 : 0) + + return ( + + {panels.map((panel, index) => ( + + {panel.render} + + ))} + {totalTokensLabel ? ( + + Σ + {totalTokensLabel} + + } + dimColor + t={t} + /> + ) : null} + {outcome ? ( + + + · {outcome} + + + ) : null} + + ) +}) diff --git a/packages/cli/src/components/todoPanel.tsx b/packages/cli/src/components/todoPanel.tsx new file mode 100644 index 0000000..637379b --- /dev/null +++ b/packages/cli/src/components/todoPanel.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { Box, Text } from '@coder/tui' +import { memo, useState } from 'react' + +import { countPendingTodos } from '../lib/liveProgress.js' +import { todoGlyph, todoTone } from '../lib/todo.js' +import type { Theme } from '../theme.js' +import type { TodoItem } from '../types.js' + +const rowColor = (t: Theme, status: TodoItem['status']) => { + const tone = todoTone(status) + + return tone === 'active' ? t.color.text : tone === 'body' ? t.color.statusFg : t.color.muted +} + +export const TodoPanel = memo(function TodoPanel({ + collapsed, + defaultCollapsed = false, + incomplete = false, + onToggle, + t, + todos +}: { + collapsed?: boolean + defaultCollapsed?: boolean + incomplete?: boolean + onToggle?: () => void + t: Theme + todos: TodoItem[] +}) { + // Fallback local state for archived todos in transcript where there's no + // external controller. Live TodoPanel passes collapsed+onToggle from the + // turn store so clicks still work there. + const [localCollapsed, setLocalCollapsed] = useState(defaultCollapsed) + const isControlled = typeof collapsed === 'boolean' + const effectiveCollapsed = isControlled ? collapsed : localCollapsed + + const handleToggle = () => { + if (onToggle) { + onToggle() + + return + } + + if (!isControlled) { + setLocalCollapsed(v => !v) + } + } + + if (!todos.length) { + return null + } + + const done = todos.filter(todo => todo.status === 'completed').length + const pending = countPendingTodos(todos) + + return ( + + + + {effectiveCollapsed ? '▸ ' : '▾ '} + + Todo + {' '} + + ({done}/{todos.length}) + + {incomplete && pending > 0 && ( + + {' '} + · incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'} + + )} + + + + {!effectiveCollapsed && ( + + {todos.map(todo => { + const tone = todoTone(todo.status) + const color = rowColor(t, todo.status) + + return ( + + {todoGlyph(todo.status)} + {todo.content} + + ) + })} + + )} + + ) +}) diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts new file mode 100644 index 0000000..5208385 --- /dev/null +++ b/packages/cli/src/config/env.ts @@ -0,0 +1,60 @@ +import type { MouseTrackingMode } from '@coder/tui' +import { isTermuxTuiMode } from '../lib/termux.js' + +const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) +const falsy = (v?: string) => /^(?:0|false|no|off)$/i.test((v ?? '').trim()) + +const parseToggle = (v?: string): boolean | null => { + const raw = (v ?? '').trim() + + if (!raw) { + return null + } + + if (truthy(raw)) { + return true + } + + if (falsy(raw)) { + return false + } + + return null +} + +export const TERMUX_TUI_MODE = isTermuxTuiMode() + +export const STARTUP_RESUME_ID = (process.env.CODER_TUI_RESUME ?? '').trim() +export const STARTUP_QUERY = (process.env.CODER_TUI_QUERY ?? '').trim() +export const STARTUP_IMAGE = (process.env.CODER_TUI_IMAGE ?? '').trim() + +// Mouse tracking mode resolution at startup. Per-mode selection (off|wheel| +// buttons|all) lives in display.mouse_tracking in settings.json — these env +// vars only set the boot-time default before that config is applied. +// +// Precedence (highest first): +// +// - CODER_TUI_MOUSE_TRACKING (truthy/falsy) explicitly overrides everything. +// This is the "force a value" knob and intentionally beats the legacy +// kill-switch and the Termux default. +// - CODER_TUI_DISABLE_MOUSE=1 forces mouse off — the legacy kill switch. +// - On Termux the default is mouse off so touch selection isn't intercepted +// by terminal mouse protocols. Desktop defaults to 'all' to preserve prior +// behavior. +const mouseTrackingOverride = parseToggle(process.env.CODER_TUI_MOUSE_TRACKING) +const mouseTrackingDisabledLegacy = truthy(process.env.CODER_TUI_DISABLE_MOUSE) +const resolvedBootMouseEnabled = + mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy) +export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off' + +export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.CODER_TUI_NO_CONFIRM) + +// INLINE_MODE is always true — the TUI now always renders into the +// primary buffer so the host terminal's native scrollback captures +// history content. Kept as a constant for backward compatibility +// with code that still references it. +export const INLINE_MODE = true + +// Live FPS counter overlay, fed by ink's onFrame (real render rate, not a +// synthetic timer). +export const SHOW_FPS = truthy(process.env.CODER_TUI_FPS) diff --git a/packages/cli/src/config/limits.ts b/packages/cli/src/config/limits.ts new file mode 100644 index 0000000..31b062b --- /dev/null +++ b/packages/cli/src/config/limits.ts @@ -0,0 +1,13 @@ +export const LARGE_PASTE = { lines: 5 } + +export const LIVE_RENDER_MAX_CHARS = 16_000 +export const LIVE_RENDER_MAX_LINES = 240 + +export const LONG_MSG = 300 +export const MAX_HISTORY = 800 +export const THINKING_COT_MAX = 160 + +// Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live +// (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts +// ramps this on sustained scrolls. +export const WHEEL_SCROLL_STEP = 1 diff --git a/packages/cli/src/config/timing.ts b/packages/cli/src/config/timing.ts new file mode 100644 index 0000000..e1811e8 --- /dev/null +++ b/packages/cli/src/config/timing.ts @@ -0,0 +1,6 @@ +export const STREAM_BATCH_MS = 16 +export const STREAM_IDLE_BATCH_MS = 16 +export const STREAM_SCROLL_BATCH_MS = 96 +export const STREAM_TYPING_BATCH_MS = 80 +export const TYPING_IDLE_MS = 250 +export const REASONING_PULSE_MS = 700 diff --git a/packages/cli/src/content/charms.ts b/packages/cli/src/content/charms.ts new file mode 100644 index 0000000..546e44d --- /dev/null +++ b/packages/cli/src/content/charms.ts @@ -0,0 +1 @@ +export const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] diff --git a/packages/cli/src/content/faces.ts b/packages/cli/src/content/faces.ts new file mode 100644 index 0000000..1bb64de --- /dev/null +++ b/packages/cli/src/content/faces.ts @@ -0,0 +1,17 @@ +export const FACES = [ + '(。•́︿•̀。)', + '(◔_◔)', + '(¬‿¬)', + '( •_•)>⌐■-■', + '(⌐■_■)', + '(´・_・`)', + '◉_◉', + '(°ロ°)', + '( ˘⌣˘)♡', + 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', + '(⊙_⊙)', + '(¬_¬)', + '( ͡° ͜ʖ ͡°)', + 'ಠ_ಠ' +] diff --git a/packages/cli/src/content/fortunes.ts b/packages/cli/src/content/fortunes.ts new file mode 100644 index 0000000..87943f9 --- /dev/null +++ b/packages/cli/src/content/fortunes.ts @@ -0,0 +1,30 @@ +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0 + +const fromScore = (n: number) => { + const rare = n % 20 === 0 + const bag = rare ? LEGENDARY : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}` +} + +export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) +export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) diff --git a/packages/cli/src/content/hotkeys.ts b/packages/cli/src/content/hotkeys.ts new file mode 100644 index 0000000..c1a4553 --- /dev/null +++ b/packages/cli/src/content/hotkeys.ts @@ -0,0 +1,37 @@ +import { isMac, isRemoteShell } from '../lib/platform.js' + +const action = isMac ? 'Cmd' : 'Ctrl' +const paste = isMac ? 'Cmd' : 'Alt' + +const copyHotkeys: [string, string][] = isMac + ? [ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] + : isRemoteShell() + ? [ + ['Cmd+C', 'copy selection when forwarded by the terminal'], + ['Ctrl+C', 'copy selection / interrupt / clear draft / exit'] + ] + : [['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] + +export const HOTKEYS: [string, string][] = [ + ...copyHotkeys, + [action + '+D', 'exit'], + [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], + [action + '+L', 'redraw / repaint'], + [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], + ['Tab', 'apply completion'], + ['↑/↓', 'completions / queue edit / history'], + ['Ctrl+X', 'open live session switcher (deletes queued message while editing)'], + [action + '+A/E', 'home / end of line'], + [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], + [action + '+W', 'delete word'], + [action + '+U/K', 'delete to start / end'], + [action + '+←/→', 'jump word'], + ['Home/End', 'start / end of line'], + ['Shift+Enter / Alt+Enter', 'insert newline'], + ['\\+Enter', 'multi-line continuation (fallback)'], + ['!', 'run a shell command (e.g. !ls, !git status)'], + ['{!}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")'] +] diff --git a/packages/cli/src/content/placeholders.ts b/packages/cli/src/content/placeholders.ts new file mode 100644 index 0000000..3d97eec --- /dev/null +++ b/packages/cli/src/content/placeholders.ts @@ -0,0 +1,13 @@ +import { pick } from '../lib/text.js' + +export const PLACEHOLDERS = [ + 'Ask me anything…', + 'Try "explain this codebase"', + 'Try "write a test for…"', + 'Try "refactor the auth module"', + 'Try "/help" for commands', + 'Try "fix the lint errors"', + 'Try "how does the config loader work?"' +] + +export const PLACEHOLDER = pick(PLACEHOLDERS) diff --git a/packages/cli/src/content/setup.ts b/packages/cli/src/content/setup.ts new file mode 100644 index 0000000..f90aa4d --- /dev/null +++ b/packages/cli/src/content/setup.ts @@ -0,0 +1,17 @@ +import type { PanelSection } from '../types.js' + +export const SETUP_REQUIRED_TITLE = 'Setup Required' + +export const buildSetupRequiredSections = (): PanelSection[] => [ + { + text: 'Coder Agent needs a model provider before the TUI can start a session.' + }, + { + rows: [ + ['/model', 'configure provider + model in-place'], + ['/setup', 'run full first-time setup wizard in-place'], + ['Ctrl+C', 'exit and run `coder setup` manually'] + ], + title: 'Actions' + } +] diff --git a/packages/cli/src/content/verbs.ts b/packages/cli/src/content/verbs.ts new file mode 100644 index 0000000..b42d99e --- /dev/null +++ b/packages/cli/src/content/verbs.ts @@ -0,0 +1,106 @@ +export const TOOL_VERBS: Record = { + browser: 'browsing', + clarify: 'asking', + create_file: 'creating', + delegate_task: 'delegating', + delete_file: 'deleting', + execute_code: 'executing', + image_generate: 'generating', + list_files: 'listing', + memory: 'remembering', + patch: 'patching', + read_file: 'reading', + run_command: 'running', + search_code: 'searching', + search_files: 'searching', + terminal: 'terminal', + web_extract: 'extracting', + web_search: 'searching', + write_file: 'writing' +} + +/** + * @deprecated FaceTicker no longer rotates through random verbs. + * The status bar now shows actual LLM state from the bridge + * ('Thinking…', 'Generating…', 'Running Bash…', etc.). + * Kept for VERB_PAD_LEN padding calculation in appChrome.tsx and + * for THINKING_STATUS_RE / THINKING_STATUS_CHUNK_RE in text.ts. + */ +export const VERBS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] + +// --------------------------------------------------------------------------- +// Deterministic status verb — replaces random verb cycling in FaceTicker. +// --------------------------------------------------------------------------- + +export interface StatusVerbState { + busy: boolean + status: string +} + +/** + * Generic / transitional statuses that carry no specific semantic meaning. + * When the current status matches one of these we fall back to "Thinking" + * so the user always sees an informative label rather than "running…" or + * a stale "ready" from a previous turn. + */ +const GENERIC_STATUSES = new Set([ + 'running…', + 'ready', + 'interrupted', + 'summoning Coder…', + 'forging session…', + 'resuming…', + 'resuming most recent…', + 'starting agent…' +]) + +/** + * Maximum length for a status label displayed in the FaceTicker. + * Labels longer than this (e.g. raw LLM chain-of-thought text leaking + * from a misrouted thinking_delta event) are treated as generic and + * fall back to "Thinking". Legitimate status labels produced by the + * bridge are always short: "Thinking", "Generating", "Running Bash", + * "Used 2 tool(s): Read, Write". + */ +const MAX_STATUS_LABEL_LEN = 50 + +/** + * Return a human-readable verb for the FaceTicker status segment. + * + * - Not busy → "Ready" + * - Busy, specific → status text with trailing "…" stripped + * - Busy, generic → "Thinking" (fallback) + * - Status too long → "Thinking" (defense-in-depth against raw text leak) + */ +export function getStatusVerb(state: StatusVerbState): string { + if (!state.busy) return 'Ready' + + const s = (state.status || '').replace(/…$/, '').trim() + + // Defense-in-depth: if the status text is suspiciously long it is + // almost certainly raw model output that leaked through a misrouted + // thinking_delta event. Fall back to a clean label. + if (s.length > MAX_STATUS_LABEL_LEN) return 'Thinking' + + if (s && !GENERIC_STATUSES.has(state.status) && !GENERIC_STATUSES.has(`${s}…`)) { + return s + } + + return 'Thinking' +} diff --git a/packages/cli/src/demo.tsx b/packages/cli/src/demo.tsx new file mode 100644 index 0000000..b3a99c9 --- /dev/null +++ b/packages/cli/src/demo.tsx @@ -0,0 +1,58 @@ +#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc +import React from 'react' +// Standalone demo entry point for coder-tui using MockGatewayClient. +// No Python backend required. + +import './lib/forceTruecolor.js' + +import type { FrameEvent } from '@coder/tui' + +import { MockGatewayClient } from './gateway/mock-client.js' +import { setupGracefulExit } from './lib/gracefulExit.js' +import { openExternalUrl } from './lib/openExternalUrl.js' +import { resetTerminalModes } from './lib/terminalModes.js' +import { TERMUX_TUI_MODE } from './config/env.js' + +if (!process.stdin.isTTY) { + console.log('coder-tui: no TTY') + process.exit(0) +} + +resetTerminalModes() + +if (TERMUX_TUI_MODE) { + process.stdout.write('\n') +} else { + process.stdout.write('\x1b[2J\x1b[H\x1b[3J') +} + +const gw = new MockGatewayClient() + +setupGracefulExit({ + cleanups: [ + () => { + resetTerminalModes() + return gw.kill('graceful-exit-cleanup') + }, + ], + onError: (scope, err) => { + const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) + process.stderr.write(`coder-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + }, + onSignal: (signal) => { + resetTerminalModes() + process.stderr.write(`coder-tui lifecycle: received ${signal}\n`) + }, +}) + +const [ink, { App }] = await Promise.all([ + import('@coder/tui'), + import('./app.js'), +]) + +ink.render(, { + exitOnCtrlC: false, + onHyperlinkClick: (url: string) => { + openExternalUrl(url) + }, +} as any) diff --git a/packages/cli/src/domain/details.ts b/packages/cli/src/domain/details.ts new file mode 100644 index 0000000..87e34c8 --- /dev/null +++ b/packages/cli/src/domain/details.ts @@ -0,0 +1,76 @@ +import type { DetailsMode, SectionName, SectionVisibility } from '../types.js' + +const MODES = ['hidden', 'collapsed', 'expanded'] as const + +export const SECTION_NAMES = ['thinking', 'tools', 'subagents', 'activity'] as const + +// Out-of-the-box per-section defaults — applied when the user hasn't pinned +// an explicit override and layered ABOVE the global details_mode: +// +// - thinking / tools: expanded — stream open so the turn reads like a +// live transcript (reasoning + tool calls side by side) instead of a +// wall of chevrons the user has to click every turn. +// - activity: hidden — ambient meta (gateway hints, terminal-parity +// nudges, background notifications) is noise for typical use. Tool +// failures still render inline on the failing tool row, and ambient +// errors/warnings surface via the floating-alert backstop when every +// panel resolves to hidden. +// - subagents: not set — falls through to the global details_mode so +// Spawn trees stay under a chevron until a delegation actually happens. +// +// Opt out of any of these with `display.sections.` in settings.json +// or at runtime via `/details collapsed|hidden`. +const SECTION_DEFAULTS: SectionVisibility = { + thinking: 'collapsed', + tools: 'collapsed', + activity: 'hidden' +} + +const THINKING_FALLBACK: Record = { + collapsed: 'collapsed', + full: 'expanded', + truncated: 'collapsed' +} + +const norm = (v: unknown) => + String(v ?? '') + .trim() + .toLowerCase() + +export const parseDetailsMode = (v: unknown): DetailsMode | null => MODES.find(m => m === norm(v)) ?? null + +export const isSectionName = (v: unknown): v is SectionName => + typeof v === 'string' && (SECTION_NAMES as readonly string[]).includes(v) + +export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => + parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[norm(d?.thinking_mode)] ?? 'collapsed' + +// Build SectionVisibility from a free-form blob. Unknown section names and +// invalid modes are dropped silently — partial overrides are intentional, so +// missing keys fall through to SECTION_DEFAULTS / global at lookup time. +export const resolveSections = (raw: unknown): SectionVisibility => + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (Object.fromEntries( + Object.entries(raw as Record) + .map(([k, v]) => [k, parseDetailsMode(v)] as const) + .filter(([k, m]) => !!m && isSectionName(k)) + ) as SectionVisibility) + : {} + +// Effective mode for one section: explicit override → global command mode → +// built-in live-stream defaults → global config mode. +// +// The `commandOverride` flag is set for in-session `/details ` changes. +// That command should immediately apply to every section, including sections +// with built-in defaults like thinking/tools=expanded and activity=hidden. On +// startup/config sync we keep those defaults layered above the persisted global +// config so the TUI still opens live reasoning/tools by default unless the user +// pins explicit per-section overrides. +export const sectionMode = ( + name: SectionName, + global: DetailsMode, + sections?: SectionVisibility, + commandOverride = false +): DetailsMode => sections?.[name] ?? (commandOverride ? global : (SECTION_DEFAULTS[name] ?? global)) + +export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/packages/cli/src/domain/messages.ts b/packages/cli/src/domain/messages.ts new file mode 100644 index 0000000..73f86c3 --- /dev/null +++ b/packages/cli/src/domain/messages.ts @@ -0,0 +1,91 @@ +import { LONG_MSG } from '../config/limits.js' +import { buildToolTrailLine, fmtK } from '../lib/text.js' +import type { Msg, SessionInfo } from '../types.js' + +export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) + +export const imageTokenMeta = (info?: ImageMeta | null) => { + const { width, height, token_estimate: t } = info ?? {} + + return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : ''] + .filter(Boolean) + .join(' · ') +} + +export const attachedImageNotice = (info?: ({ name?: string } & ImageMeta) | null) => { + const meta = imageTokenMeta(info) + const label = info?.name ? `📎 Attached image: ${info.name}` : '📎 Attached image' + + return `${label}${meta ? ` · ${meta}` : ''}` +} + +export const userDisplay = (text: string) => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} + +export const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + const out: Msg[] = [] + let pending: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') { + continue + } + + const { context, name, role, text } = row as TranscriptRow + + if (role === 'tool') { + pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + + continue + } + + if (typeof text !== 'string' || !text.trim()) { + continue + } + + if (role === 'assistant') { + out.push({ role, text, ...(pending.length && { tools: pending }) }) + pending = [] + } else if (role === 'user' || role === 'system') { + out.push({ role, text }) + pending = [] + } + } + + return out +} + +export const fmtDuration = (ms: number) => { + const t = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(t / 3600) + const m = Math.floor((t % 3600) / 60) + const s = t % 60 + + return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s` +} + +interface ImageMeta { + height?: number + token_estimate?: number + width?: number +} + +interface TranscriptRow { + context?: string + name?: string + role?: string + text?: string +} diff --git a/packages/cli/src/domain/paths.ts b/packages/cli/src/domain/paths.ts new file mode 100644 index 0000000..43c023b --- /dev/null +++ b/packages/cli/src/domain/paths.ts @@ -0,0 +1,16 @@ +export const shortCwd = (cwd: string, max = 28) => { + const h = process.env.HOME + const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` +} + +export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => { + if (!branch) { + return shortCwd(cwd, max) + } + + const tag = ` (${branch.length > 16 ? `…${branch.slice(-15)}` : branch})` + + return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}` +} diff --git a/packages/cli/src/domain/providers.ts b/packages/cli/src/domain/providers.ts new file mode 100644 index 0000000..83ac016 --- /dev/null +++ b/packages/cli/src/domain/providers.ts @@ -0,0 +1,11 @@ +export const providerDisplayNames = (providers: readonly { name: string; slug: string }[]): string[] => { + const counts = new Map() + + for (const p of providers) { + counts.set(p.name, (counts.get(p.name) ?? 0) + 1) + } + + return providers.map(p => + (counts.get(p.name) ?? 0) > 1 && p.slug && p.slug !== p.name ? `${p.name} (${p.slug})` : p.name + ) +} diff --git a/packages/cli/src/domain/roles.ts b/packages/cli/src/domain/roles.ts new file mode 100644 index 0000000..9e33aa0 --- /dev/null +++ b/packages/cli/src/domain/roles.ts @@ -0,0 +1,9 @@ +import type { Theme } from '../theme.js' +import type { Role } from '../types.js' + +export const ROLE: Record { body: string; glyph: string; prefix: string }> = { + assistant: t => ({ body: t.color.text, glyph: t.brand.tool, prefix: t.color.border }), + system: t => ({ body: '', glyph: '·', prefix: t.color.muted }), + tool: t => ({ body: t.color.muted, glyph: '⚡', prefix: t.color.muted }), + user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) +} diff --git a/packages/cli/src/domain/slash.ts b/packages/cli/src/domain/slash.ts new file mode 100644 index 0000000..8090f60 --- /dev/null +++ b/packages/cli/src/domain/slash.ts @@ -0,0 +1,10 @@ +/** Appended to `/model` args from the TUI picker for session scope; stripped in `session` slash before `config.set`. */ +export const TUI_SESSION_MODEL_FLAG = '--tui-session' + +export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text) + +export const parseSlashCommand = (cmd: string) => { + const [name = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { arg: rest.join(' '), cmd, name: name.toLowerCase() } +} diff --git a/packages/cli/src/domain/usage.ts b/packages/cli/src/domain/usage.ts new file mode 100644 index 0000000..508195f --- /dev/null +++ b/packages/cli/src/domain/usage.ts @@ -0,0 +1,3 @@ +import type { Usage } from '../types.js' + +export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/packages/cli/src/domain/viewport.ts b/packages/cli/src/domain/viewport.ts new file mode 100644 index 0000000..4fdbfcc --- /dev/null +++ b/packages/cli/src/domain/viewport.ts @@ -0,0 +1,51 @@ +import type { Msg } from '../types.js' + +import { userDisplay } from './messages.js' + +const upperBound = (offsets: ArrayLike, target: number) => { + let lo = 0 + let hi = offsets.length + + while (lo < hi) { + const mid = (lo + hi) >> 1 + + offsets[mid]! <= target ? (lo = mid + 1) : (hi = mid) + } + + return lo +} + +export const stickyPromptFromViewport = ( + messages: readonly Msg[], + offsets: ArrayLike, + top: number, + bottom: number, + sticky: boolean +) => { + if (sticky || !messages.length) { + return '' + } + + const first = Math.max(0, upperBound(offsets, top) - 1) + const last = Math.max(first, upperBound(offsets, bottom) - 1) + const visibleStart = Math.min(messages.length, first) + const visibleEnd = Math.min(messages.length - 1, last) + + for (let i = visibleStart; i <= visibleEnd; i++) { + if (messages[i]?.role === 'user') { + return '' + } + } + + for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) { + if (messages[i]?.role !== 'user') { + continue + } + + return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top + ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + : '' + } + + return '' +} diff --git a/packages/cli/src/entry.tsx b/packages/cli/src/entry.tsx new file mode 100644 index 0000000..fcbdeac --- /dev/null +++ b/packages/cli/src/entry.tsx @@ -0,0 +1,830 @@ +#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc +// Must be first import. If the user explicitly opts into truecolor, this +// nudges chalk / supports-color before either package is initialized. +import './lib/forceTruecolor.js' +import React from 'react' + +import type { FrameEvent } from '@coder/tui' + +import { CoderGatewayClient } from './gateway/coder-client.js' +import { setupGracefulExit } from './lib/gracefulExit.js' +import { openExternalUrl } from './lib/openExternalUrl.js' +import { resetTerminalModes } from './lib/terminalModes.js' + +// --------------------------------------------------------------------------- +// CLI args — Coordinator / Worker mode + Model / Provider +// --------------------------------------------------------------------------- + +interface CliArgs { + help: boolean + /** Print version information and exit */ + version: boolean + coordinator: boolean + team?: string + workers: number + worker: boolean + /** Print last session summary and exit */ + print?: boolean + /** Resume a specific session by ID */ + resume?: string + /** Continue the most recently updated session */ + continueLatest?: boolean + /** Fork from a specific session ID */ + forkSession?: string + /** Turn number to fork from (used with --fork-session) */ + forkTurn?: number + /** Enable extended thinking mode */ + thinking?: boolean + /** Extended thinking budget in tokens */ + thinkingBudget?: number + /** Model name (e.g. "deepseek-v4-pro", "gpt-4o", "claude-sonnet-4-6") */ + model?: string + /** Provider name (e.g. "anthropic", "openai", "deepseek", "auto") */ + provider?: string + /** Append to system prompt (added after base instructions) */ + systemPrompt?: string + /** Launch interactive first-time setup wizard */ + setup?: boolean +} + +function parseCliArgs(argv: string[]): CliArgs { + const args: CliArgs = { help: false, version: false, coordinator: false, workers: 3, worker: false } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]! + switch (arg) { + case '--version': + case '-V': + args.version = true + break + case '--help': + case '-h': + args.help = true + break + case '--coordinator': + case '-C': + args.coordinator = true + break + case '--team': + case '-T': + args.team = argv[i + 1] ?? undefined + if (args.team && !args.team.startsWith('-')) i++ + else args.team = undefined + break + case '--workers': + case '-W': + args.workers = parseInt(argv[i + 1] ?? '3', 10) || 3 + i++ + break + case '--worker': + args.worker = true + break + case '--print': + args.print = true + break + case '--resume': + args.resume = argv[i + 1] ?? undefined + if (args.resume && !args.resume.startsWith('-')) i++ + else args.resume = undefined + break + case '--continue': + args.continueLatest = true + break + case '--fork-session': + args.forkSession = argv[i + 1] ?? undefined + if (args.forkSession && !args.forkSession.startsWith('-')) i++ + else args.forkSession = undefined + break + case '--fork-turn': + args.forkTurn = parseInt(argv[i + 1] ?? '0', 10) || 0 + i++ + break + case '--thinking': + args.thinking = true + break + case '--thinking-budget': + args.thinkingBudget = parseInt(argv[i + 1] ?? '1024', 10) || 1024 + i++ + break + case '--model': + case '-m': + args.model = argv[i + 1] + i++ + break + case '--provider': + case '-p': + args.provider = argv[i + 1] + i++ + break + case '--system-prompt': + args.systemPrompt = argv[i + 1] + i++ + break + case 'setup': + case '--setup': + args.setup = true + break + default: + break + } + } + return args +} + +const cliArgs = parseCliArgs(process.argv.slice(2)) + +// Sync CLI args to env vars so child processes and engine-factory can read them +if (cliArgs.coordinator) { + process.env.CODER_COORDINATOR_MODE = 'true' +} +if (cliArgs.team) { + process.env.CODER_TEAM_ID = cliArgs.team +} +if (cliArgs.worker) { + process.env.CODER_WORKER_MODE = 'true' +} +if (cliArgs.thinking) { + process.env.CODER_THINKING_MODE = 'true' +} +if (cliArgs.thinkingBudget != null) { + process.env.CODER_THINKING_BUDGET = String(cliArgs.thinkingBudget) +} +if (cliArgs.model) { + process.env.CODER_MODEL = cliArgs.model +} +if (cliArgs.provider) { + process.env.CODER_PROVIDER = cliArgs.provider +} +if (cliArgs.systemPrompt) { + process.env.CODER_APPEND_SYSTEM_PROMPT = cliArgs.systemPrompt +} + +// --help: print usage and exit (non-TUI mode) +if (cliArgs.help) { + console.log(`Usage: coder [options] + +Options: + --help, -h Show this help message and exit + --version, -V Print version information and exit + --print Print last session summary and exit (no TUI required) + --resume Resume a specific session by ID + --continue Continue the most recently updated session + --fork-session Fork from a specific session ID + --fork-turn Turn number to fork from (used with --fork-session) + --coordinator, -C Start in Coordinator mode (Agent Teams) + --team, -T Team identifier for Coordinator ↔ Worker routing + --workers, -W Number of workers in Coordinator mode (default: 3) + --worker Start in Worker mode + --model, -m Model name (e.g. "deepseek-v4-pro", "gpt-4o") + --provider, -p Provider name: "anthropic" | "openai" | "deepseek" | "auto" + --thinking Enable extended thinking mode + --thinking-budget Extended thinking budget in tokens (default: 1024) + --system-prompt Append additional system prompt text + --setup Launch interactive first-time setup wizard +`); + process.exit(0); +} + +// --print: print last session summary and exit (non-TUI mode) +if (cliArgs.print) { + const { SessionManager } = await import('@coder/core'); + const sm = new SessionManager(); + const sessions = sm.listSessions(10); + if (sessions.length === 0) { + console.log('No sessions found.'); + } else { + console.log('Recent sessions:'); + for (const s of sessions) { + console.log(` ${s.id.slice(0, 8)} ${s.status.padEnd(10)} ${s.title.padEnd(40)} ${s.createdAt}`); + } + } + process.exit(0); +} + +// --version: print version info and exit (non-TUI mode, works without terminal) +if (cliArgs.version) { + const { readFileSync } = await import('node:fs'); + const { dirname, join } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + console.log(`coder-agent ${pkg.version}`); + console.log(`node ${process.version}`); + console.log(`${process.platform} ${process.arch}`); + process.exit(0); +} + +// ── radioSelect helper (shared by --model and --setup) ────────────────────── +function radioSelect( + options: string[], + activeIndex: number, + title: string, + stdin: typeof process.stdin, + stdout: typeof process.stdout, +): Promise { + return new Promise((resolve) => { + if (!stdin.isTTY) { + console.log(`${title}\n (non-TTY mode, using default)\n`); + resolve(activeIndex); + return; + } + + let selected = activeIndex; + let firstRender = true; + const totalLines = options.length + 2; + const rawModeWas = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdout.write('\x1B[?25l'); + + function render() { + if (!firstRender) { + stdout.write(`\x1B[${totalLines}A\r`); + } + firstRender = false; + + stdout.write(`\x1B[K${title}\n`); + options.forEach((opt, i) => { + const marker = i === selected ? '\x1B[1m●\x1B[0m' : '○'; + stdout.write(`\x1B[K ${marker} ${opt}\n`); + }); + stdout.write(`\x1B[K\n\x1B[K \x1B[2m↑↓ to navigate, Enter to confirm\x1B[0m`); + } + + render(); + + function onData(data: Buffer) { + const key = data[0]; + if (key === 13) { + cleanup(); + resolve(selected); + return; + } + if (key === 3) { + cleanup(); + stdout.write('\n'); + process.exit(0); + } + if (key === 27 && data.length >= 3) { + if (data[1] === 91) { + if (data[2] === 65) { + selected = (selected - 1 + options.length) % options.length; + render(); + } else if (data[2] === 66) { + selected = (selected + 1) % options.length; + render(); + } + } + } + } + + function cleanup() { + stdin.setRawMode(rawModeWas); + stdin.pause(); + stdin.removeListener('data', onData); + stdout.write('\x1B[?25h\n'); + } + + stdin.on('data', onData); + }); +} + +// ── Reusable interactive model setup (shared by --model and --setup) ──────── +// Returns true if the user completed setup (selected a model), false if they +// skipped (no changes made). +async function runInteractiveModelSetup( + settings: any, + modelList: Array<{model: string[]; base_url?: string; auth_token_env?: string; provider: string; proxy?: string | null; price?: any}>, + settingsPath: string, + stdin: typeof process.stdin, + stdout: typeof process.stdout, + writeFileSync: (path: string, data: string) => void, +): Promise { + const defaultModel = settings.default_model ?? ''; + const defaultProvider = defaultModel ? defaultModel.split('/')[0] : 'deepseek'; + let selectedProvider: any; + let providerDone = false; + while (!providerDone) { + + let providerActiveIdx = modelList.findIndex(m => m.provider === defaultProvider); + if (providerActiveIdx < 0) providerActiveIdx = 0; + + const providerOptions = modelList.map(m => { + const firstModel = m.model[0] ?? 'unknown'; + const isActive = m.provider === defaultProvider; + return `${m.provider} (${firstModel}...)${isActive ? ' <- currently active' : ''}`; + }); + providerOptions.push('Custom new provider'); + providerOptions.push('Remove provider'); + providerOptions.push('Skip'); + + const selectedProviderIdx = await radioSelect( + providerOptions, + providerActiveIdx, + 'Available providers:', + stdin, + stdout, + ); + + if (selectedProviderIdx === modelList.length) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const name = await new Promise(resolve => rl.question('Enter provider name (e.g. myprovider): ', resolve)); + const url = await new Promise(resolve => rl.question('Enter base URL (e.g. https://api.example.com/v1): ', resolve)); + const key = await new Promise(resolve => rl.question('Enter API key (or press Enter to skip): ', resolve)); + const proxy = await new Promise(resolve => rl.question('Enter proxy URL (e.g. http://127.0.0.1:7890, or press Enter to skip): ', resolve)); + rl.close(); + selectedProvider = { + provider: name.trim(), + model: [], + base_url: url.trim() || undefined, + auth_token_env: key.trim() || `YOUR_${name.trim().toUpperCase()}_API_KEY`, + proxy: proxy.trim() || null, + price: { input: 0, output: 0, currency: 'USD', unit: '1M tokens' } + }; + modelList.push(selectedProvider); + providerDone = true; + } else if (selectedProviderIdx === modelList.length + 1) { + const removeProviderOptions = modelList.map(m => m.provider); + const removeIdx = await radioSelect( + removeProviderOptions, + 0, + 'Select provider to remove:', + stdin, + stdout, + ); + const targetProvider = modelList[removeIdx]!.provider; + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const confirm = await new Promise(resolve => rl.question( + `Remove provider "${targetProvider}" from settings? (y/N): `, + resolve + )); + rl.close(); + if (confirm.trim().toLowerCase() === 'y') { + settings.model_list = (settings.model_list ?? []).filter( + (m: any) => m.provider !== targetProvider + ); + if (settings.default_model?.startsWith(targetProvider + '/')) { + settings.default_model = ''; + } + const mdlIdx = modelList.findIndex((m: any) => m.provider === targetProvider); + if (mdlIdx >= 0) modelList.splice(mdlIdx, 1); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`Provider "${targetProvider}" removed. Returning to provider selection...\n`); + continue; + } else { + console.log('Cancelled. Returning to provider selection...\n'); + continue; + } + } else if (selectedProviderIdx === modelList.length + 2) { + // Skip - exit without changes + console.log('Skipped. No changes made.'); + return false; + } else { + selectedProvider = modelList[selectedProviderIdx]!; + providerDone = true; + } + } // end while + console.log(`Selected provider: ${selectedProvider.provider}\n`); + + // ── Provider config: base_url + api_key + proxy ─────────────────────────── + // URL + { + const currentUrl = selectedProvider.base_url ?? '(not set)'; + console.log(`Base URL: ${currentUrl}`); + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const newUrl = await new Promise(resolve => rl.question('Press Enter to keep, or type a new URL: ', resolve)); + rl.close(); + if (newUrl.trim()) { + selectedProvider.base_url = newUrl.trim(); + console.log(' URL updated.\n'); + } else { + console.log(' Keeping current URL.\n'); + } + } + // API Key + { + const currentToken = selectedProvider.auth_token_env ?? ''; + let displayToken: string; + if (!currentToken) { + displayToken = '(not set)'; + } else if (currentToken.startsWith('YOUR_') || currentToken === 'LOCAL_NO_KEY') { + displayToken = currentToken; + } else if (currentToken.length > 12) { + displayToken = `${currentToken.slice(0, 6)}******${currentToken.slice(-6)}`; + } else { + displayToken = currentToken; + } + console.log(`API Key (auth_token_env): ${displayToken}`); + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const newToken = await new Promise(resolve => rl.question('Press Enter to keep, or type a new key: ', resolve)); + rl.close(); + if (newToken.trim()) { + selectedProvider.auth_token_env = newToken.trim(); + console.log(' Token updated.\n'); + } else { + console.log(' Keeping current token.\n'); + } + } + // Proxy + { + const currentProxy = selectedProvider.proxy ?? 'None (no proxy)'; + console.log(`Proxy: ${currentProxy}`); + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const newProxy = await new Promise(resolve => rl.question('Press Enter to keep, or type a proxy URL (or "none" to disable): ', resolve)); + rl.close(); + if (newProxy.trim().toLowerCase() === 'none') { + selectedProvider.proxy = undefined; + console.log(' Proxy disabled.\n'); + } else if (newProxy.trim()) { + selectedProvider.proxy = newProxy.trim(); + console.log(' Proxy updated.\n'); + } else { + console.log(' Keeping current proxy.\n'); + } + } + + // ── Step 2: Model selection ─────────────────────────────────────────────── + const currentDefaultModel = defaultModel.split('/')[1] ?? selectedProvider.model[0] ?? ''; + let selectedModel = ''; + let modelDone = false; + while (!modelDone) { + let modelActiveIdx = selectedProvider.model.findIndex((m: string) => m === currentDefaultModel); + if (modelActiveIdx < 0) modelActiveIdx = 0; + + const modelOptions = selectedProvider.model.map((m: string) => { + const isActive = m === currentDefaultModel; + return `${m}${isActive ? ' <- currently active' : ''}`; + }); + modelOptions.push('Custom model name'); + modelOptions.push('Remove model'); + modelOptions.push('Skip'); + + const selectedModelIdx = await radioSelect( + modelOptions, + modelActiveIdx, + `Available models for ${selectedProvider.provider}:`, + stdin, + stdout, + ); + + if (selectedModelIdx === selectedProvider.model.length) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const name = await new Promise(resolve => rl.question('Enter model name (e.g. my-model-v1): ', resolve)); + rl.close(); + const modelName = name.trim(); + selectedProvider.model.push(modelName); + selectedModel = modelName; + } else if (selectedModelIdx === selectedProvider.model.length + 1) { + const removeModelOptions = selectedProvider.model.map((m: string) => m); + const removeIdx = await radioSelect( + removeModelOptions, + 0, + `Select model to remove from ${selectedProvider.provider}:`, + stdin, + stdout, + ); + const modelToRemove = selectedProvider.model[removeIdx]!; + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: stdin, output: stdout }); + const confirm = await new Promise(resolve => rl.question( + `Remove model "${modelToRemove}" from ${selectedProvider.provider}? (y/N): `, + resolve + )); + rl.close(); + if (confirm.trim().toLowerCase() === 'y') { + selectedProvider.model = selectedProvider.model.filter((m: string) => m !== modelToRemove); + if (selectedProvider.model.length === 0) { + console.log(`Model "${modelToRemove}" removed. No models left.\n`); + } else { + console.log(`Model "${modelToRemove}" removed.\n`); + } + continue; + } else { + console.log('Cancelled.\n'); + continue; + } + } else if (selectedModelIdx === selectedProvider.model.length + 2) { + selectedModel = selectedProvider.model[0] ?? ''; + modelDone = true; + if (!selectedModel) { + console.log('No model selected. You can configure one later.\n'); + } + } else { + selectedModel = selectedProvider.model[selectedModelIdx]!; + modelDone = true; + } + } // end while + console.log(`Selected model: ${selectedModel}\n`); + + // ── Update settings.json ────────────────────────────────────────────────── + settings.default_model = `${selectedProvider.provider}/${selectedModel}`; + settings.env = settings.env ?? {}; + settings.env.CODER_MODEL = selectedModel; + if (selectedProvider.base_url) settings.env.CODER_BASE_URL = selectedProvider.base_url; + if (selectedProvider.auth_token_env) settings.env.CODER_AUTH_TOKEN = selectedProvider.auth_token_env; + + settings.model_list = settings.model_list ?? []; + { + const existingIdx = settings.model_list.findIndex( + (m: any) => m.provider === selectedProvider.provider + ); + if (existingIdx >= 0) { + settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], ...selectedProvider }; + } else { + settings.model_list.push(selectedProvider); + } + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`Default model set to: ${selectedProvider.provider}/${selectedModel}`); + return true; +} + +// --model: interactive model selection (non-TUI mode) +if (cliArgs.model || process.argv.includes('--model')) { + const { readFileSync, writeFileSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + + const settingsPath = join(homedir(), '.coder', 'settings.json'); + let settings: any = {}; + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch {} + + const modelList: Array<{model: string[]; base_url?: string; auth_token_env?: string; provider: string; proxy?: string | null}> = + settings.model_list ?? []; + + if (modelList.length === 0) { + console.log('No models configured. Add models to ~/.coder/settings.json model_list.'); + process.exit(0); + } + + const targetModel = cliArgs.model; + if (targetModel) { + // Non-interactive: parse "provider/model-name" format + const slashIdx = targetModel.indexOf('/'); + let providerName: string; + let modelName: string; + if (slashIdx >= 0) { + providerName = targetModel.slice(0, slashIdx); + modelName = targetModel.slice(slashIdx + 1); + } else { + // Fallback: treat as model name, find first matching provider + const found = modelList.find(m => m.model.includes(targetModel)); + if (found) { + providerName = found.provider; + modelName = targetModel; + } else { + console.log(`Model "${targetModel}" not found in model_list.`); + process.exit(0); + } + } + + const providerEntry = modelList.find(m => m.provider === providerName); + if (!providerEntry || !providerEntry.model.includes(modelName)) { + console.log(`Model "${targetModel}" not found in model_list.`); + process.exit(0); + } + + settings.default_model = `${providerEntry.provider}/${modelName}`; + settings.env = settings.env ?? {}; + settings.env.CODER_MODEL = modelName; + if (providerEntry.base_url) settings.env.CODER_BASE_URL = providerEntry.base_url; + if (providerEntry.proxy) settings.env.CODER_PROXY = providerEntry.proxy; + if (providerEntry.auth_token_env) settings.env.CODER_AUTH_TOKEN = providerEntry.auth_token_env; + + // Smart merge into model_list (match by provider) + settings.model_list = settings.model_list ?? []; + const existingIdx = settings.model_list.findIndex( + (m: any) => m.provider === providerEntry.provider + ); + if (existingIdx >= 0) { + settings.model_list[existingIdx] = { ...settings.model_list[existingIdx], ...providerEntry }; + } else { + settings.model_list.push(providerEntry); + } + + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`Default model set to: ${providerEntry.provider}/${modelName}`); + process.exit(0); + } + + // Interactive: delegate to shared function + await runInteractiveModelSetup(settings, modelList, settingsPath, process.stdin, process.stdout, writeFileSync); + process.exit(0); +} + +// --setup: interactive first-time setup wizard +if (cliArgs.setup || process.argv.includes('setup')) { + const { readFileSync, writeFileSync } = await import('node:fs'); + const { homedir } = await import('node:os'); + const { join } = await import('node:path'); + const readline = await import('node:readline'); + const stdin = process.stdin; + const stdout = process.stdout; + + const settingsPath = join(homedir(), '.coder', 'settings.json'); + let settings: any = {}; + try { + settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + } catch {} + settings.model_list = settings.model_list ?? []; + + console.log('\n🔧 Coder Agent — First Time Setup\n'); + + // ── Step 1: Theme ────────────────────────────────────────────────────────── + { + const themeIdx = await radioSelect( + ['dark', 'light'], + 0, + 'Choose theme:', + stdin, + stdout, + ); + const theme = themeIdx === 1 ? 'light' : 'dark'; + settings.theme = theme; + // Apply immediately for the current TUI session + process.env.CODER_TUI_THEME = theme; + console.log(` Theme: ${theme}\n`); + } + + // ── Step 2: max_tokens ───────────────────────────────────────────────────── + { + const rl = readline.createInterface({ input: stdin, output: stdout }); + const maxTokens = await new Promise(resolve => { + rl.question('Max output tokens [32768]: ', answer => { + const trimmed = answer.trim(); + resolve(trimmed ? parseInt(trimmed, 10) || 32768 : 32768); + }); + }); + settings.max_tokens = maxTokens; + console.log(` Max tokens: ${maxTokens}\n`); + rl.close(); + } + + // ── Step 3: Provider + Model (reuses --model interactive flow) ──────────── + const modelList: Array = settings.model_list; + + console.log('Now let us configure your AI provider and model.\n'); + await runInteractiveModelSetup(settings, modelList, settingsPath, process.stdin, process.stdout, writeFileSync); + + // ── Save and launch TUI ─────────────────────────────────────────────────── + // runInteractiveModelSetup already saved settings.json, but ensure theme + // and max_tokens are persisted (they were set on the settings object above). + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + + console.log('✅ Setup complete! Settings saved to ~/.coder/settings.json\n'); + console.log('Starting Coder Agent...\n'); + + // Fall through to TUI init +} + +// TTY check — skip for non-interactive flags handled above +const isNonInteractive = cliArgs.help || cliArgs.version || cliArgs.print || cliArgs.model || process.argv.includes('--model') || process.argv.includes('-m') +if (isNonInteractive) { + // Exit gracefully — the --model handler above uses readline callback to exit + if (!cliArgs.model && !process.argv.includes('--model') && !process.argv.includes('-m')) { + process.exit(0) + } + // For --model: don't fall through to TUI init; readline callback handles exit + // Use a no-op wait so the process stays alive for readline + await new Promise(() => {}) +} + +// TTY check for interactive mode +if (!process.stdin.isTTY) { + console.log('coder-tui: no TTY (use --help, --print, --version, or --model for non-TTY usage)') + process.exit(0) +} + +// Start from a clean slate. If a previous TUI crashed or was kill -9'd, the +// terminal tab can still have mouse/focus/paste modes enabled. +resetTerminalModes() + +// Main-screen mode: keep prior terminal output intact so users can review +// earlier content via native scrollback. Just start on a fresh line. +process.stdout.write('\n') + +const gw = new CoderGatewayClient({ + coordinatorMode: cliArgs.coordinator || cliArgs.worker, + teamId: cliArgs.team, + workerMode: cliArgs.worker, + maxWorkers: cliArgs.workers, + thinkingMode: cliArgs.thinking, + thinkingBudget: cliArgs.thinkingBudget, + forkSessionId: cliArgs.forkSession, + forkTurn: cliArgs.forkTurn, +}) + +gw.start() + +setupGracefulExit({ + cleanups: [ + () => { + resetTerminalModes() + + return gw.kill('graceful-exit-cleanup') + } + ], + onError: (scope, err) => { + const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) + + process.stderr.write(`coder-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + }, + onSignal: signal => { + resetTerminalModes() + process.stderr.write(`coder-tui lifecycle: received ${signal}\n`) + } +}) + +// Defer memory monitoring and heap dump to after the TUI's first paint. +// Memory pressure detection is important but non-critical for startup (<500ms). +const stopMemoryMonitor = (() => { + let stop: (() => void) | null = null; + setImmediate(async () => { + const [{ formatBytes }, { startMemoryMonitor: start }] = await Promise.all([ + import('./lib/memory.js'), + import('./lib/memoryMonitor.js'), + ]); + const dumpNotice = (snap: { level: string; heapUsed: number }, dump: { heapPath?: string } | null) => + `coder-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n`; + stop = start({ + onCritical: (snap, dump) => { + resetTerminalModes(); + process.stderr.write(`coder-tui lifecycle: memory critical exit heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}\n`); + process.stderr.write(dumpNotice(snap, dump)); + process.stderr.write('coder-tui: exiting to avoid OOM; restart to recover\n'); + process.exit(137); + }, + onHigh: (snap, dump) => process.stderr.write(dumpNotice(snap, dump)), + }); + }); + return () => stop?.(); +})(); + +if (process.env.CODER_HEAPDUMP_ON_START === '1') { + setImmediate(() => { + import('./lib/memory.js').then(m => void m.performHeapDump('manual')); + }); +} + +process.on('beforeExit', () => stopMemoryMonitor()) + +// Apply user's theme preference from settings.json before TUI initializes. +// This overrides terminal auto-detection (detectLightMode) when the user +// has explicitly chosen a theme via `coder setup` or `coder --model`. +if (!process.env.CODER_TUI_THEME) { + try { + const settingsTheme = JSON.parse( + (await import('node:fs')).readFileSync( + (await import('node:path')).join((await import('node:os')).homedir(), '.coder', 'settings.json'), + 'utf-8', + ) + )?.theme + if (settingsTheme === 'light' || settingsTheme === 'dark') { + process.env.CODER_TUI_THEME = settingsTheme + } + } catch {} +} + +const [ink, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([ + import('@coder/tui'), + import('./app.js'), + import('./lib/perfPane.js'), + import('./lib/fpsStore.js') +]) + +// Both consumers are undefined when their env flags are off; only attach +// onFrame when at least one is on so ink skips timing in the default case. +const onFrame = + logFrameEvent || trackFrame + ? (event: FrameEvent) => { + logFrameEvent?.(event) + trackFrame?.(event.durationMs) + } + : undefined + +try { + await ink.render(, { + exitOnCtrlC: false, + onFrame, + // Open URLs in the user's default browser when a link cell is clicked. + // The TUI's mouse tracking captures click events before Terminal.app's + // own URL detection can fire, so without this hook clicks on `` + // do nothing in any terminal where mouseTracking is on. + onHyperlinkClick: url => { + openExternalUrl(url) + } + } as any) +} catch (err) { + resetTerminalModes() + const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) + process.stderr.write(`coder-tui: render failed — ${message.slice(0, 2000)}\n`) + process.exit(1) +} diff --git a/packages/cli/src/gateway/__tests__/e2e-submit-message.test.ts b/packages/cli/src/gateway/__tests__/e2e-submit-message.test.ts new file mode 100644 index 0000000..ca6a95f --- /dev/null +++ b/packages/cli/src/gateway/__tests__/e2e-submit-message.test.ts @@ -0,0 +1,250 @@ +/** + * e2e-submit-message.test.ts — E2E: full QueryEngine.submitMessage() chain + * + * Verifies the complete Agent Loop does NOT deadlock when processing a + * simple user message ("你好"). Uses a mock callModel to avoid requiring + * API keys, and enforces a hard 5-second timeout to catch hangs. + * + * Bug reference: Sprint 7 introduced a `continue` (now fixed to `return`) + * in query.ts:314 that could cause infinite looping when PreMessage hooks + * blocked. This test ensures the generator terminates under normal + * conditions. + */ + +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import type { + StreamEvent, + AssistantMessage, + CompletionUsage, +} from '@coder/shared'; +import { + QueryEngine, + ToolRegistry, + SessionManager, + CheckpointManager, +} from '@coder/core'; +import type { CallModelParams } from '@coder/core'; +import type { QueryEngineEvent } from '@coder/core'; + +// --------------------------------------------------------------------------- +// Mock callModel — simple text response (no tool calls) +// --------------------------------------------------------------------------- + +function makeUsage(overrides?: Partial): CompletionUsage { + return { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...overrides, + }; +} + +async function* mockCallModel( + _params: CallModelParams, +): AsyncGenerator { + yield { + type: 'message_start', + message: { model: 'mock', usage: makeUsage({ input_tokens: 0, output_tokens: 0 }) }, + }; + yield { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }; + const text = '你好!有什么可以帮助你的吗?'; + yield { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }; + yield { type: 'content_block_stop', index: 0 }; + yield { + type: 'message_delta', + delta: { stop_reason: 'end_turn', usage: makeUsage() }, + } as StreamEvent; + // Final AssistantMessage so the bridge can emit message.complete + yield { + role: 'assistant' as const, + content: [{ type: 'text' as const, text }], + stopReason: 'end_turn' as const, + usage: makeUsage(), + model: 'mock', + toolUseBlocks: [], + } as unknown as AssistantMessage; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createTestEngine() { + const toolRegistry = new ToolRegistry(); + const sessionManager = new SessionManager(); + sessionManager.create({ cwd: '/tmp/test', title: 'E2E test session' }); + + const engine = new QueryEngine({ + cwd: '/tmp/test', + toolRegistry, + sessionManager, + maxTurns: 10, + callModel: mockCallModel, + model: 'mock', + }); + + return { engine, sessionManager }; +} + +/** + * Collect all QueryEngineEvents within a hard timeout. + * Returns the events and whether the generator completed (did not hang). + */ +async function collectEventsWithTimeout( + generator: AsyncGenerator, + timeoutMs: number, +): Promise<{ events: QueryEngineEvent[]; completed: boolean }> { + const events: QueryEngineEvent[] = []; + let completed = false; + + const timeoutPromise = new Promise<'timeout'>((resolve) => { + setTimeout(() => resolve('timeout'), timeoutMs); + }); + + const collectPromise = (async () => { + try { + for await (const event of generator) { + events.push(event); + } + completed = true; + } catch { + completed = false; + } + })(); + + await Promise.race([collectPromise, timeoutPromise]); + + return { events, completed }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('E2E: submitMessage("你好")', () => { + it('should complete within 5 seconds (no deadlock)', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + const generator = engine.submitMessage('你好'); + const { events, completed } = await collectEventsWithTimeout(generator, 5000); + + expect(completed).toBe(true); + expect(events.length).toBeGreaterThan(0); + }); + + it('should produce at least one assistant message', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + const generator = engine.submitMessage('你好'); + const { events } = await collectEventsWithTimeout(generator, 5000); + + const assistantEvents = events.filter( + (e) => + e.type === 'message' && + typeof e.data === 'object' && + e.data !== null && + (e.data as { type?: string }).type === 'assistant', + ); + expect(assistantEvents.length).toBeGreaterThanOrEqual(1); + }); + + it('should produce a done event at the end', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + const generator = engine.submitMessage('你好'); + const { events, completed } = await collectEventsWithTimeout(generator, 5000); + + expect(completed).toBe(true); + + const doneEvents = events.filter((e) => e.type === 'done'); + expect(doneEvents.length).toBe(1); + }); + + it('should NOT produce HOOK_BLOCKED errors', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + const generator = engine.submitMessage('你好'); + const { events } = await collectEventsWithTimeout(generator, 5000); + + const errors = events.filter((e) => e.type === 'error'); + for (const err of errors) { + const data = err.data as { code?: string } | undefined; + expect(data?.code).not.toBe('HOOK_BLOCKED'); + } + }); + + it('should update session messages after completion', async () => { + const { engine, sessionManager } = createTestEngine(); + + await engine.init(); + + const sessionBefore = sessionManager.getActive(); + const msgCountBefore = sessionBefore.messages.length; + + const generator = engine.submitMessage('你好'); + const { events, completed } = await collectEventsWithTimeout(generator, 5000); + + expect(completed).toBe(true); + + const sessionAfter = sessionManager.getActive(); + // There should be more messages after submitMessage completes + const msgCountAfter = sessionAfter.messages.length; + expect(msgCountAfter).toBeGreaterThan(msgCountBefore); + }); + + it('should handle multiple sequential submitMessage calls', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + // First interaction + { + const generator = engine.submitMessage('你好'); + const { events, completed } = await collectEventsWithTimeout(generator, 5000); + expect(completed).toBe(true); + expect(events.length).toBeGreaterThan(0); + } + + // Second interaction — should also complete + { + const generator = engine.submitMessage('帮我看看项目'); + const { events, completed } = await collectEventsWithTimeout(generator, 5000); + expect(completed).toBe(true); + expect(events.length).toBeGreaterThan(0); + } + }); + + it('should be interruptible via engine.interrupt()', async () => { + const { engine } = createTestEngine(); + + await engine.init(); + + const generator = engine.submitMessage('你好'); + + // Interrupt immediately + engine.interrupt(); + + const { events } = await collectEventsWithTimeout(generator, 5000); + + // Should complete (either normally or via error) — must not hang + expect(events.length).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/cli/src/gateway/__tests__/integration.test.ts b/packages/cli/src/gateway/__tests__/integration.test.ts new file mode 100644 index 0000000..4c6fcd0 --- /dev/null +++ b/packages/cli/src/gateway/__tests__/integration.test.ts @@ -0,0 +1,557 @@ +/** + * integration.test.ts — TUI ↔ Core integration tests + * + * Tests the full chain: QueryEngine → query-bridge → GatewayEvent, + * plus the deferred permission resolution flow. + */ + +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import type { + QueryMessage, + StreamEvent, + AssistantMessage, + CompletionUsage, +} from '@coder/shared'; +import { + createBridgeState, + bridgeQueryToGateway, + resetTurnState, + resolveApproval, +} from '../query-bridge.js'; +import type { BridgeState } from '../query-bridge.js'; +import { createDeferredPermission, resolvePermission, getPendingPermissions } from '../deferred.js'; +import { createQueryEngine, hasApiKey, getConfiguredModel } from '../engine-factory.js'; +import type { EngineFactoryResult } from '../engine-factory.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeUsage(overrides?: Partial): CompletionUsage { + return { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...overrides, + }; +} + +function makeStreamEvent(event: StreamEvent): QueryMessage { + return { type: 'stream_event', event }; +} + +function makeAssistantMessage(overrides?: Partial): QueryMessage { + return { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello, I can help with that.' }], + stopReason: 'end_turn', + usage: makeUsage(), + ...overrides, + }, + }; +} + +function makeProgressMessage( + status: 'started' | 'running' | 'completed' | 'error', + toolUseId: string, + toolName: string, +): QueryMessage { + return { + type: 'system', + subtype: 'progress', + data: { + toolUseId, + toolName, + status, + message: `${status} ${toolName}`, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests: BridgeState + bridgeQueryToGateway +// --------------------------------------------------------------------------- + +describe('query-bridge: BridgeState', () => { + it('createBridgeState should initialise with defaults', () => { + const state = createBridgeState('test-session-1'); + expect(state.sessionId).toBe('test-session-1'); + expect(state.accumulatedText).toBe(''); + expect(state.activeTools.size).toBe(0); + expect(state.totalCost).toBe(0); + expect(state.usage.inputTokens).toBe(0); + expect(state.usage.outputTokens).toBe(0); + expect(state.turnCount).toBe(0); + expect(state.pendingApprovals).toEqual([]); + }); + + it('resetTurnState should clear per-turn fields', () => { + const state = createBridgeState('test-session-1'); + state.accumulatedText = 'some text'; + state.activeTools.set('tool-1', { id: 'tool-1', name: 'Bash', startTime: Date.now(), status: 'started' }); + state.currentTurnToolCount = 3; + state.pendingApprovals.push({ + toolUseId: 'tool-1', + toolName: 'Bash', + command: 'ls', + description: 'List files', + deferred: {} as any, + }); + state.usage.inputTokens = 500; + + resetTurnState(state); + + expect(state.accumulatedText).toBe(''); + expect(state.activeTools.size).toBe(0); + expect(state.currentTurnToolCount).toBe(0); + expect(state.pendingApprovals).toEqual([]); + expect(state.usage.inputTokens).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: bridgeQueryToGateway — stream_event mappings +// --------------------------------------------------------------------------- + +describe('query-bridge: stream_event → GatewayEvent', () => { + it('message_start → message.start', () => { + const state = createBridgeState('sid-1'); + const msg = makeStreamEvent({ + type: 'message_start', + message: { model: 'deepseek-v4-pro', usage: { input_tokens: 0, output_tokens: 0 } }, + }); + + const events = bridgeQueryToGateway(msg, state); + expect(events.length).toBeGreaterThanOrEqual(1); + expect(events[0]!.type).toBe('message.start'); + }); + + it('content_block_delta (text) → message.delta', () => { + const state = createBridgeState('sid-1'); + const msg = makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }); + + const events = bridgeQueryToGateway(msg, state); + expect(events.some((e) => e.type === 'message.delta')).toBe(true); + expect(state.accumulatedText).toContain('Hello'); + }); + + it('content_block_delta (input_json) → tool.input_delta', () => { + const state = createBridgeState('sid-1'); + // Set up the tool block index mapping first + bridgeQueryToGateway( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool_001', name: 'Bash', input: {} }, + }), + state, + ); + const msg = makeStreamEvent({ + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"command"' }, + }); + + const events = bridgeQueryToGateway(msg, state); + expect(events.some((e) => e.type === 'tool.input_delta')).toBe(true); + expect(events.some((e) => e.type === 'thinking.delta')).toBe(false); + }); + + it('content_block_start (tool_use) → tool.start', () => { + const state = createBridgeState('sid-1'); + const msg = makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool_001', name: 'Bash', input: { command: 'ls' } }, + }); + + const events = bridgeQueryToGateway(msg, state); + const toolStart = events.find((e) => e.type === 'tool.start'); + expect(toolStart).toBeDefined(); + expect(toolStart!.payload?.tool_id).toBe('tool_001'); + expect(toolStart!.payload?.name).toBe('Bash'); + expect(state.currentTurnToolCount).toBe(1); + }); + + it('message_delta (usage) accumulates usage', () => { + const state = createBridgeState('sid-1'); + const msg = makeStreamEvent({ + type: 'message_delta', + delta: { + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 200, output_tokens: 100 }, + }, + } as StreamEvent); + + bridgeQueryToGateway(msg, state); + expect(state.usage.inputTokens).toBe(200); + expect(state.usage.outputTokens).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: bridgeQueryToGateway — assistant message +// --------------------------------------------------------------------------- + +describe('query-bridge: assistant → message.complete', () => { + it('should emit message.complete with usage', () => { + const state = createBridgeState('sid-1'); + state.accumulatedText = 'I can help.'; + + const msg = makeAssistantMessage({ + content: [{ type: 'text', text: 'I can help.' }], + usage: makeUsage({ input_tokens: 150, output_tokens: 80 }), + }); + + const events = bridgeQueryToGateway(msg, state); + const complete = events.find((e) => e.type === 'message.complete'); + expect(complete).toBeDefined(); + expect(complete!.payload?.text).toBe('I can help.'); + expect(state.turnCount).toBe(1); + }); + + it('should reset turn state after assistant message', () => { + const state = createBridgeState('sid-1'); + state.accumulatedText = 'Done.'; + state.currentTurnToolCount = 2; + + const msg = makeAssistantMessage(); + bridgeQueryToGateway(msg, state); + + // Turn state should be reset + expect(state.accumulatedText).toBe(''); + expect(state.currentTurnToolCount).toBe(0); + expect(state.activeTools.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: bridgeQueryToGateway — system messages +// --------------------------------------------------------------------------- + +describe('query-bridge: system messages', () => { + it('progress (started) → status.update', () => { + const state = createBridgeState('sid-1'); + const msg = makeProgressMessage('started', 'tool_001', 'Bash'); + + const events = bridgeQueryToGateway(msg, state); + expect(events.some((e) => e.type === 'status.update')).toBe(true); + expect(state.activeTools.has('tool_001')).toBe(true); + }); + + it('progress (completed) → tool.complete', () => { + const state = createBridgeState('sid-1'); + state.activeTools.set('tool_001', { + id: 'tool_001', + name: 'Bash', + startTime: Date.now() - 1000, + status: 'running', + }); + + const msg = makeProgressMessage('completed', 'tool_001', 'Bash'); + const events = bridgeQueryToGateway(msg, state); + + const toolComplete = events.find((e) => e.type === 'tool.complete'); + expect(toolComplete).toBeDefined(); + expect(toolComplete!.payload?.tool_id).toBe('tool_001'); + expect(toolComplete!.payload?.name).toBe('Bash'); + // Tool should be cleaned up from activeTools + expect(state.activeTools.has('tool_001')).toBe(false); + }); + + it('compact_boundary → status.update', () => { + const state = createBridgeState('sid-1'); + const msg: QueryMessage = { + type: 'system', + subtype: 'compact_boundary', + compactMetadata: { beforeTokens: 50000, afterTokens: 20000, strategy: 'auto' }, + }; + + const events = bridgeQueryToGateway(msg, state); + expect(events.some((e) => e.type === 'status.update')).toBe(true); + }); + + it('error → error + status.update', () => { + const state = createBridgeState('sid-1'); + const msg: QueryMessage = { + type: 'system', + subtype: 'error', + error: { code: 'TOOL_ERROR', message: 'Something went wrong', retryable: false, timestamp: new Date() }, + }; + + const events = bridgeQueryToGateway(msg, state); + expect(events.some((e) => e.type === 'error')).toBe(true); + expect(events.some((e) => e.type === 'status.update' && e.payload?.kind === 'error')).toBe(true); + }); + + it('permission_required → approval.request', () => { + const state = createBridgeState('sid-1'); + const deferred = createDeferredPermission('Bash', 'rm -rf /', 'Dangerous delete command', 'tool_approve_1'); + + const msg: QueryMessage = { + type: 'system', + subtype: 'permission_required', + deferred, + }; + + const events = bridgeQueryToGateway(msg, state); + const approvalEvent = events.find((e) => e.type === 'approval.request'); + expect(approvalEvent).toBeDefined(); + expect(approvalEvent!.payload?.command).toBe('rm -rf /'); + expect(approvalEvent!.payload?.request_id).toBe('tool_approve_1'); + expect(state.pendingApprovals.length).toBe(1); + expect(state.pendingApprovals[0]!.toolUseId).toBe('tool_approve_1'); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: resolveApproval +// --------------------------------------------------------------------------- + +describe('resolveApproval', () => { + it('should resolve pending approval and call deferred.resolve', async () => { + const state = createBridgeState('sid-1'); + const deferred = createDeferredPermission('Bash', 'rm -rf /', 'Dangerous command', 'tool_001'); + + state.pendingApprovals.push({ + toolUseId: 'tool_001', + toolName: 'Bash', + command: 'rm -rf /', + description: 'Dangerous command', + deferred, + }); + + // Resolve as approved + resolveApproval(state, 'tool_001', true); + + // pendingApprovals should be cleared + expect(state.pendingApprovals.length).toBe(0); + + // The promise should resolve to true (approved) + const result = await deferred.promise; + expect(result).toBe(true); + }); + + it('should resolve as denied', async () => { + const state = createBridgeState('sid-1'); + const deferred = createDeferredPermission('Bash', 'rm -rf /', 'Dangerous command', 'tool_001'); + + state.pendingApprovals.push({ + toolUseId: 'tool_001', + toolName: 'Bash', + command: 'rm -rf /', + description: 'Dangerous command', + deferred, + }); + + resolveApproval(state, 'tool_001', false); + + const result = await deferred.promise; + expect(result).toBe(false); + }); + + it('should return null for unknown toolUseId', () => { + const state = createBridgeState('sid-1'); + const result = resolveApproval(state, 'nonexistent', true); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Deferred Permission +// --------------------------------------------------------------------------- + +describe('DeferredPermission', () => { + it('createDeferredPermission should create and register', () => { + const deferred = createDeferredPermission('Bash', 'ls', 'List files', 'tool_ls_1'); + expect(deferred.toolName).toBe('Bash'); + expect(deferred.command).toBe('ls'); + expect(deferred.toolUseId).toBe('tool_ls_1'); + expect(getPendingPermissions().has('tool_ls_1')).toBe(true); + }); + + it('resolvePermission should resolve the promise', async () => { + const deferred = createDeferredPermission('Write', 'write file', 'Write to file', 'tool_write_1'); + + // Resolve in background + setTimeout(() => resolvePermission('tool_write_1', true), 10); + + const result = await deferred.promise; + expect(result).toBe(true); + expect(getPendingPermissions().has('tool_write_1')).toBe(false); + }); + + it('should auto-deny after timeout', async () => { + const deferred = createDeferredPermission('Bash', 'cmd', 'desc', 'tool_timeout_1', 50); + + const result = await deferred.promise; + expect(result).toBe(false); + expect(getPendingPermissions().has('tool_timeout_1')).toBe(false); + }, 10000); +}); + +// --------------------------------------------------------------------------- +// Tests: Engine Factory +// --------------------------------------------------------------------------- + +describe('engine-factory', () => { + it('createQueryEngine should return engine, interrupt, sessionId', () => { + const result = createQueryEngine('/tmp/test-project'); + + expect(result.engine).toBeDefined(); + expect(typeof result.interrupt).toBe('function'); + expect(typeof result.sessionId).toBe('string'); + expect(result.sessionId.length).toBeGreaterThan(0); + }); + + it('createQueryEngine should accept options object', () => { + const result = createQueryEngine({ + cwd: '/tmp/test-project', + model: 'deepseek-v4-pro', + maxTurns: 50, + }); + + expect(result.engine).toBeDefined(); + expect(result.sessionId).toBeDefined(); + }); + + it('hasApiKey should return false when no key is set', () => { + // In test env, there's no API key by default + const hasKey = hasApiKey(); + expect(typeof hasKey).toBe('boolean'); + }); + + it('getConfiguredModel should return default model', () => { + const model = getConfiguredModel(); + expect(typeof model).toBe('string'); + expect(model.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Full bridge integration (multiple messages in sequence) +// --------------------------------------------------------------------------- + +describe('Full bridge integration', () => { + it('should handle a complete tool-use turn sequence', () => { + const state = createBridgeState('int-test-1'); + const allEvents: { type: string }[] = []; + + // 1. message_start + allEvents.push( + ...bridgeQueryToGateway( + makeStreamEvent({ + type: 'message_start', + message: { model: 'deepseek-v4-pro', usage: { input_tokens: 0, output_tokens: 0 } }, + }), + state, + ), + ); + + // 2. tool_use content_block_start + allEvents.push( + ...bridgeQueryToGateway( + makeStreamEvent({ + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool_abc', name: 'Read', input: { file_path: '/tmp/test.txt' } }, + }), + state, + ), + ); + + // 3. tool progress started + allEvents.push( + ...bridgeQueryToGateway(makeProgressMessage('started', 'tool_abc', 'Read'), state), + ); + + // 4. tool progress completed + allEvents.push( + ...bridgeQueryToGateway(makeProgressMessage('completed', 'tool_abc', 'Read'), state), + ); + + // 5. text content_block_delta + allEvents.push( + ...bridgeQueryToGateway( + makeStreamEvent({ + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'The file contains...' }, + }), + state, + ), + ); + + // 6. message_delta with usage + allEvents.push( + ...bridgeQueryToGateway( + makeStreamEvent({ + type: 'message_delta', + delta: { + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 300, output_tokens: 100 }, + }, + } as StreamEvent), + state, + ), + ); + + // Verify accumulated text BEFORE assistant message (which resets turn state) + expect(state.accumulatedText).toBe('The file contains...'); + + // 7. assistant message (turn complete — resets turn state) + allEvents.push(...bridgeQueryToGateway(makeAssistantMessage(), state)); + + // Verify key event types appeared + const eventTypes = allEvents.map((e) => e.type); + expect(eventTypes).toContain('message.start'); + expect(eventTypes).toContain('tool.start'); + expect(eventTypes).toContain('tool.complete'); + expect(eventTypes).toContain('message.delta'); + expect(eventTypes).toContain('message.complete'); + + // After assistant message, turn state is reset + expect(state.accumulatedText).toBe(''); + expect(state.currentTurnToolCount).toBe(0); + expect(state.activeTools.size).toBe(0); + expect(state.turnCount).toBe(1); + }); + + it('should handle the permission_required → resolve flow', async () => { + const state = createBridgeState('int-test-2'); + + // Create deferred permission + const deferred = createDeferredPermission('Write', 'write /etc/config', 'Modify system config', 'tool_write_sys'); + + // Bridge the permission_required message + const msg: QueryMessage = { + type: 'system', + subtype: 'permission_required', + deferred, + }; + const events = bridgeQueryToGateway(msg, state); + + // Should emit approval.request with request_id + const approvalEvent = events.find((e) => e.type === 'approval.request'); + expect(approvalEvent).toBeDefined(); + expect(approvalEvent!.payload?.request_id).toBe('tool_write_sys'); + + // Simulate user approving + resolveApproval(state, 'tool_write_sys', true); + + // The deferred promise should resolve + const result = await deferred.promise; + expect(result).toBe(true); + expect(state.pendingApprovals.length).toBe(0); + }); +}); diff --git a/packages/cli/src/gateway/client.ts b/packages/cli/src/gateway/client.ts new file mode 100644 index 0000000..63bff3f --- /dev/null +++ b/packages/cli/src/gateway/client.ts @@ -0,0 +1,745 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { existsSync } from 'node:fs' +import { delimiter, resolve } from 'node:path' +import { createInterface } from 'node:readline' + +import type { GatewayEvent } from './types.js' +import { CircularBuffer } from '../lib/circularBuffer.js' + +const MAX_GATEWAY_LOG_LINES = 200 +const MAX_LOG_LINE_BYTES = 4096 +const MAX_BUFFERED_EVENTS = 2000 +const MAX_LOG_PREVIEW = 240 +const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.CODER_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000) +const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.CODER_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) +const WS_CONNECTING = 0 +const WS_OPEN = 1 +const WS_CLOSING = 2 +const WS_CLOSED = 3 + +const truncateLine = (line: string) => + line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line + +const describeChild = (proc: ChildProcess | null) => { + if (!proc) { + return 'pid=none' + } + + return `pid=${proc.pid ?? 'unknown'} killed=${proc.killed} exitCode=${proc.exitCode ?? 'null'} signal=${proc.signalCode ?? 'null'}` +} + +const resolveGatewayAttachUrl = () => { + const raw = process.env.CODER_TUI_GATEWAY_URL?.trim() + + return raw ? raw : null +} + +const resolveSidecarUrl = () => { + const raw = process.env.CODER_TUI_SIDECAR_URL?.trim() + + return raw ? raw : null +} + +const resolvePython = (root: string) => { + const configured = process.env.CODER_PYTHON?.trim() || process.env.PYTHON?.trim() + + if (configured) { + return configured + } + + const venv = process.env.VIRTUAL_ENV?.trim() + + const hit = [ + venv && resolve(venv, 'bin/python'), + venv && resolve(venv, 'Scripts/python.exe'), + resolve(root, '.venv/bin/python'), + resolve(root, '.venv/bin/python3'), + resolve(root, 'venv/bin/python'), + resolve(root, 'venv/bin/python3') + ].find(p => p && existsSync(p)) + + return hit || (process.platform === 'win32' ? 'python' : 'python3') +} + +const asGatewayEvent = (value: unknown): GatewayEvent | null => + value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string' + ? (value as GatewayEvent) + : null + +// Hoisted decoder: attach mode can drive high-frequency binary frames +// (tool deltas, reasoning streams) and constructing a fresh TextDecoder +// per message creates avoidable GC pressure. One module-level instance +// is fine because UTF-8 is stateless and we always pass entire frames. +const _wireDecoder = new TextDecoder() + +const asWireText = (raw: unknown): string | null => { + if (typeof raw === 'string') { + return raw + } + + if (raw instanceof ArrayBuffer) { + return _wireDecoder.decode(raw) + } + + if (ArrayBuffer.isView(raw)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return _wireDecoder.decode(raw as any) + } + + return null +} + +// Matches `://user:pass@host…` style user-info segments in +// otherwise-malformed URLs that the WHATWG `URL` parser can't accept. +// Used by the `redactUrl` fallback so embedded credentials are +// scrubbed from log lines even when the URL is unparseable. +const _USERINFO_FALLBACK_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i + +// Connection URLs (gateway, sidecar) often carry bearer tokens in the query +// string. We surface them in user-facing log lines and the +// `gateway.start_timeout` payload, so always strip the query string and any +// embedded user-info before logging. +const redactUrl = (raw: string): string => { + if (!raw) { + return raw + } + + try { + const url = new URL(raw) + const userInfo = url.username || url.password ? '***@' : '' + const query = url.search ? '?***' : '' + + return `${url.protocol}//${userInfo}${url.host}${url.pathname}${query}` + } catch { + // WHATWG URL rejected the input. Best-effort: strip an embedded + // `user:pass@` segment AND the query string so a malformed token + // bearer can never escape into the log tail. + const noUserInfo = raw.replace(_USERINFO_FALLBACK_RE, '$1***@') + const queryIdx = noUserInfo.indexOf('?') + + return queryIdx >= 0 ? `${noUserInfo.slice(0, queryIdx)}?***` : noUserInfo + } +} + +interface Pending { + id: string + method: string + reject: (e: Error) => void + resolve: (v: unknown) => void + timeout: ReturnType +} + +export interface IGatewayClient { + start(): void + kill(reason?: string): void + drain(): void + getLogTail(limit?: number): string + request(method: string, params?: Record): Promise + on(event: 'event', handler: (ev: GatewayEvent) => void): this + on(event: 'exit', handler: (code: number | null) => void): this + off(event: 'event', handler: (ev: GatewayEvent) => void): this + off(event: 'exit', handler: (code: number | null) => void): this + emit(event: 'event', ev: GatewayEvent): boolean + emit(event: 'exit', code: number | null): boolean +} + +export class GatewayClient extends EventEmitter implements IGatewayClient { + private proc: ChildProcess | null = null + private ws: WebSocket | null = null + private wsConnectPromise: Promise | null = null + private sidecarWs: WebSocket | null = null + private attachUrl: null | string = null + private sidecarUrl: null | string = null + private reqId = 0 + private logs = new CircularBuffer(MAX_GATEWAY_LOG_LINES) + private pending = new Map() + private bufferedEvents = new CircularBuffer(MAX_BUFFERED_EVENTS) + private pendingExit: number | null | undefined + private ready = false + private readyTimer: ReturnType | null = null + private subscribed = false + private stdoutRl: ReturnType | null = null + private stderrRl: ReturnType | null = null + + constructor() { + super() + // useInput / createGatewayEventHandler can legitimately attach many + // listeners. Default 10-cap triggers spurious warnings. + this.setMaxListeners(0) + } + + private publish(ev: GatewayEvent) { + if (ev.type === 'gateway.ready') { + this.ready = true + + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + if (this.subscribed) { + return void this.emit('event', ev) + } + + this.bufferedEvents.push(ev) + } + + private clearReadyTimer() { + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + private closeSidecarSocket() { + try { + this.sidecarWs?.close() + } catch { + // best effort + } finally { + this.sidecarWs = null + } + } + + private closeGatewaySocket() { + // Null the active reference BEFORE invoking close(): real WebSocket + // implementations dispatch the 'close' event after a microtask hop, + // so by the time the handler runs `this.ws` should already be null + // and the identity guard will correctly classify the close as + // belonging to a discarded socket. (Test fakes emit synchronously, + // so doing the swap up front is also what makes the identity guard + // match real timing in tests.) + const ws = this.ws + this.ws = null + this.wsConnectPromise = null + + try { + ws?.close() + } catch { + // best effort + } + } + + private resetStartupState() { + // Reject any in-flight RPCs left over from the previous transport + // before we swap. Otherwise the old transport's stale exit/close + // handlers (now identity-gated to ignore unrelated transports) + // never fire `rejectPending`, leaving callers hanging on promises + // attached to a discarded child / socket. + this.rejectPending(new Error('gateway restarting')) + this.ready = false + this.bufferedEvents.clear() + this.pendingExit = undefined + this.stdoutRl?.close() + this.stderrRl?.close() + this.stdoutRl = null + this.stderrRl = null + this.clearReadyTimer() + } + + private startReadyTimer(python: string, cwd: string) { + this.readyTimer = setTimeout(() => { + if (this.ready) { + return + } + + // Append the most recent gateway stderr/log lines to the timeout + // event so users can tell apart "wrong python", "missing dep", + // and "config parse failure" from one glance instead of having + // to dig through `/logs`. Capped to keep the activity feed + // readable on slow boots. + const stderrTail = this.getLogTail(20) + + this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`) + this.publish({ + type: 'gateway.start_timeout', + payload: { cwd, python, stderr_tail: stderrTail } + }) + }, STARTUP_TIMEOUT_MS) + } + + private handleTransportExit(code: null | number, reason?: string) { + this.clearReadyTimer() + this.closeSidecarSocket() + this.pushLog(`[lifecycle] transport exit code=${code ?? 'null'} reason=${reason ?? 'none'}`) + this.rejectPending(new Error(reason || `gateway exited${code === null ? '' : ` (${code})`}`)) + + if (this.subscribed) { + this.emit('exit', code) + } else { + this.pendingExit = code + } + } + + private connectSidecarMirror() { + this.closeSidecarSocket() + + if (!this.sidecarUrl) { + return + } + + if (typeof WebSocket === 'undefined') { + this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`) + + return + } + + try { + const ws = new WebSocket(this.sidecarUrl) + + this.sidecarWs = ws + ws.addEventListener('close', () => { + if (this.sidecarWs === ws) { + this.sidecarWs = null + } + }) + ws.addEventListener('error', () => { + this.pushLog('[sidecar] mirror connection error') + }) + } catch (err) { + this.pushLog(`[sidecar] failed to connect ${redactUrl(this.sidecarUrl)} (constructor error)`) + this.sidecarWs = null + } + } + + private mirrorEventToSidecar(rawFrame: string) { + const ws = this.sidecarWs + + if (!ws || ws.readyState !== WS_OPEN) { + return + } + + try { + ws.send(rawFrame) + } catch { + // best effort + } + } + + private handleWebSocketFrame(raw: unknown) { + const text = asWireText(raw) + + if (!text) { + return + } + + try { + const frame = JSON.parse(text) as Record + + if (frame.method === 'event') { + this.mirrorEventToSidecar(text) + } + + this.dispatch(frame) + } catch { + const preview = text.trim().slice(0, MAX_LOG_PREVIEW) || '(empty frame)' + + this.pushLog(`[protocol] malformed websocket frame: ${preview}`) + this.publish({ type: 'gateway.protocol_error', payload: { preview } }) + } + } + + private startSpawnedGateway(root: string) { + const python = resolvePython(root) + const cwd = process.env.CODER_CWD || root + const env = { ...process.env } + const pyPath = env.PYTHONPATH?.trim() + + env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root + this.startReadyTimer(python, cwd) + this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) + this.pushLog(`[lifecycle] spawned gateway child ${describeChild(this.proc)} python=${python} cwd=${cwd}`) + + this.stdoutRl = createInterface({ input: this.proc.stdout! }) + this.stdoutRl.on('line', raw => { + try { + this.dispatch(JSON.parse(raw)) + } catch { + const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' + + this.pushLog(`[protocol] malformed stdout: ${preview}`) + this.publish({ type: 'gateway.protocol_error', payload: { preview } }) + } + }) + + this.stderrRl = createInterface({ input: this.proc.stderr! }) + this.stderrRl.on('line', raw => { + const line = truncateLine(raw.trim()) + + if (!line) { + return + } + + this.pushLog(line) + this.publish({ type: 'gateway.stderr', payload: { line } }) + }) + + const ownedProc = this.proc + this.proc.on('error', err => { + // Skip stale errors on an already-replaced child. + if (this.proc !== ownedProc) { + this.pushLog(`[lifecycle] stale child error ignored ${describeChild(ownedProc)} message=${err.message}`) + + return + } + + const line = `[spawn] ${err.message}` + + this.pushLog(`[lifecycle] child error ${describeChild(ownedProc)} message=${err.message}`) + this.pushLog(line) + this.publish({ type: 'gateway.stderr', payload: { line } }) + // Detach the reference up front so the late `exit` event for + // this same child is identity-skipped (we don't want to emit + // 'exit' twice). Then run the full teardown — clears the + // startup timer so we don't fire a misleading + // `gateway.start_timeout`, rejects pending RPCs, and emits or + // queues a single `exit`. + this.proc = null + this.handleTransportExit(1, `gateway error: ${err.message}`) + }) + this.proc.on('exit', (code, signal) => { + // start() can replace `this.proc` while an old child is still + // tearing down. Skip stale exits so we don't clear the new + // startup timer or reject newly-issued pending requests. + if (this.proc !== ownedProc) { + this.pushLog( + `[lifecycle] stale child exit ignored ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}` + ) + + return + } + + this.pushLog(`[lifecycle] child exit ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}`) + this.handleTransportExit(code) + }) + } + + private startAttachedGateway(attachUrl: string) { + const safeAttachUrl = redactUrl(attachUrl) + this.startReadyTimer('websocket', safeAttachUrl) + + if (typeof WebSocket === 'undefined') { + const line = `[startup] WebSocket API unavailable; cannot attach to ${safeAttachUrl}` + + this.pushLog(line) + this.publish({ type: 'gateway.stderr', payload: { line } }) + this.handleTransportExit(1, 'gateway websocket unavailable') + + return + } + + try { + const ws = new WebSocket(attachUrl) + let settled = false + + this.ws = ws + + const connectPromise = new Promise((resolve, reject) => { + ws.addEventListener( + 'open', + () => { + if (!settled) { + settled = true + resolve() + } + + this.connectSidecarMirror() + }, + { once: true } + ) + + ws.addEventListener( + 'error', + () => { + if (!settled) { + this.pushLog('[startup] gateway websocket connect error') + settled = true + reject(new Error('gateway websocket connection failed')) + } + }, + { once: true } + ) + ws.addEventListener( + 'close', + ev => { + if (!settled) { + settled = true + reject(new Error(`gateway websocket closed (${ev.code}) during connect`)) + } + }, + { once: true } + ) + }) + + // The connect promise is only awaited by RPCs that arrive while + // the socket is still connecting. If no request races the open + // (or a teardown drops the reference before anyone observes it), + // a connect-error / early-close rejection would surface as an + // unhandled promise rejection in Node. Attach a no-op handler to + // ensure the rejection is always observed. + connectPromise.catch(() => {}) + this.wsConnectPromise = connectPromise + + ws.addEventListener('message', ev => this.handleWebSocketFrame(ev.data)) + ws.addEventListener('close', ev => { + // Skip close events from sockets that have already been + // replaced — start() / closeGatewaySocket() can swap `this.ws` + // before an in-flight close lands, and we must not clear the + // new ready timer or reject the new pending requests on behalf + // of a stale socket. + if (this.ws !== ws) { + this.pushLog(`[lifecycle] stale websocket close ignored code=${ev.code}`) + + return + } + + this.pushLog(`[lifecycle] websocket close code=${ev.code}`) + this.ws = null + this.wsConnectPromise = null + this.handleTransportExit(ev.code, `gateway websocket closed${ev.code ? ` (${ev.code})` : ''}`) + }) + ws.addEventListener('error', () => { + const line = '[gateway] websocket transport error' + + this.pushLog(line) + this.publish({ type: 'gateway.stderr', payload: { line } }) + }) + } catch (err) { + this.pushLog(`[startup] failed to connect websocket gateway ${safeAttachUrl} (constructor error)`) + this.handleTransportExit(1, 'gateway websocket startup failed') + } + } + + start() { + const root = process.env.CODER_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') + const attachUrl = resolveGatewayAttachUrl() + const sidecarUrl = resolveSidecarUrl() + + this.attachUrl = attachUrl + this.sidecarUrl = sidecarUrl + this.resetStartupState() + + if (this.proc && !this.proc.killed && this.proc.exitCode === null) { + this.pushLog(`[lifecycle] replacing live gateway child ${describeChild(this.proc)}`) + this.proc.kill() + } + + this.proc = null + this.closeGatewaySocket() + this.closeSidecarSocket() + + if (attachUrl) { + this.startAttachedGateway(attachUrl) + + return + } + + this.startSpawnedGateway(root) + } + + private dispatch(msg: Record) { + const id = msg.id as string | undefined + const p = id ? this.pending.get(id) : undefined + + if (p) { + this.settle(p, msg.error ? this.toError(msg.error) : null, msg.result) + + return + } + + if (msg.method === 'event') { + const ev = asGatewayEvent(msg.params) + + if (ev) { + this.publish(ev) + } + } + } + + private toError(raw: unknown): Error { + const err = raw as { message?: unknown } | null | undefined + + return new Error(typeof err?.message === 'string' ? err.message : 'request failed') + } + + private settle(p: Pending, err: Error | null, result: unknown) { + clearTimeout(p.timeout) + this.pending.delete(p.id) + + if (err) { + p.reject(err) + } else { + p.resolve(result) + } + } + + private pushLog(line: string) { + this.logs.push(truncateLine(line)) + } + + private rejectPending(err: Error) { + for (const p of this.pending.values()) { + clearTimeout(p.timeout) + p.reject(err) + } + + this.pending.clear() + } + + // Arrow class-field — stable identity, so `setTimeout(this.onTimeout, …, id)` + // doesn't allocate a bound function per request. + private onTimeout = (id: string) => { + const p = this.pending.get(id) + + if (p) { + this.pending.delete(id) + p.reject(new Error(`timeout: ${p.method}`)) + } + } + + drain() { + this.subscribed = true + + for (const ev of this.bufferedEvents.drain()) { + this.emit('event', ev) + } + + if (this.pendingExit !== undefined) { + const code = this.pendingExit + + this.pendingExit = undefined + this.emit('exit', code) + } + } + + getLogTail(limit = 20): string { + return this.logs.tail(Math.max(1, limit)).join('\n') + } + + private async ensureAttachedWebSocket(method: string): Promise { + if (!this.attachUrl) { + throw new Error('gateway not running') + } + + if (!this.ws || this.ws.readyState === WS_CLOSED || this.ws.readyState === WS_CLOSING) { + this.start() + } + + if (this.ws?.readyState === WS_CONNECTING) { + try { + await this.wsConnectPromise + } catch (err) { + throw err instanceof Error ? err : new Error(String(err)) + } + } + + if (!this.ws || this.ws.readyState !== WS_OPEN) { + throw new Error(`gateway not connected: ${method}`) + } + + return this.ws + } + + private requestOverWebSocket(method: string, params: Record = {}): Promise { + return this.ensureAttachedWebSocket(method).then( + ws => + new Promise((resolve, reject) => { + const id = `r${++this.reqId}` + const timeout = setTimeout(this.onTimeout, REQUEST_TIMEOUT_MS, id) + + timeout.unref?.() + this.pending.set(id, { + id, + method, + reject, + resolve: v => resolve(v as T), + timeout + }) + + try { + ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params })) + } catch (e) { + const pending = this.pending.get(id) + + if (pending) { + clearTimeout(pending.timeout) + this.pending.delete(id) + } + + reject(e instanceof Error ? e : new Error(String(e))) + } + }) + ) + } + + request(method: string, params: Record = {}): Promise { + const attachUrl = resolveGatewayAttachUrl() + + if (attachUrl) { + if (this.attachUrl !== attachUrl) { + // The env var rotated at runtime — restart the transport so + // switching from spawned-gateway mode to attach mode also + // tears down the old Python child. Merely closing `this.ws` + // would leave a previously spawned gateway process alive. + this.rejectPending(new Error('gateway attach url changed')) + this.start() + } + + return this.requestOverWebSocket(method, params) + } + + if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) { + this.start() + } + + if (!this.proc?.stdin) { + return Promise.reject(new Error('gateway not running')) + } + + const id = `r${++this.reqId}` + + return new Promise((resolve, reject) => { + const timeout = setTimeout(this.onTimeout, REQUEST_TIMEOUT_MS, id) + + timeout.unref?.() + + this.pending.set(id, { + id, + method, + reject, + resolve: v => resolve(v as T), + timeout + }) + + try { + this.proc!.stdin!.write(JSON.stringify({ id, jsonrpc: '2.0', method, params }) + '\n') + } catch (e) { + const pending = this.pending.get(id) + + if (pending) { + clearTimeout(pending.timeout) + this.pending.delete(id) + } + + reject(e instanceof Error ? e : new Error(String(e))) + } + }) + } + + kill(reason = 'requested') { + const proc = this.proc + const killed = proc?.kill() + + this.pushLog(`[lifecycle] GatewayClient.kill reason=${reason} ${describeChild(proc)} killResult=${killed ?? 'none'}`) + this.closeGatewaySocket() + this.closeSidecarSocket() + this.clearReadyTimer() + // The ws 'close' handler is identity-gated on `this.ws === ws` + // and we just nulled `this.ws`, so it will short-circuit and + // skip handleTransportExit. Reject pending RPCs explicitly so + // attach-mode promises do not hang after an intentional kill. + this.rejectPending(new Error('gateway closed')) + } +} diff --git a/packages/cli/src/gateway/coder-client.ts b/packages/cli/src/gateway/coder-client.ts new file mode 100644 index 0000000..156365c --- /dev/null +++ b/packages/cli/src/gateway/coder-client.ts @@ -0,0 +1,1100 @@ +/** + * coder-client.ts — Coder Gateway Adapter + * + * Implements IGatewayClient by bridging to coder-agent's QueryEngine + * (Agent Loop) and translating QueryMessage → GatewayEvent via + * query-bridge.ts. + * + * Config is read from ~/.coder/settings.json. + */ + +import { EventEmitter } from 'node:events' +import { execFile } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import type { QueryMessage } from '@coder/shared' +import { getSubagentBus } from '@coder/shared' + +import type { IGatewayClient } from './client.js' +import type { GatewayEvent } from './types.js' +import { createQueryEngine } from './engine-factory.js' +import type { EngineFactoryResult } from './engine-factory.js' +import { + createBridgeState, + bridgeQueryToGateway, + resetTurnState, +} from './query-bridge.js' +import type { BridgeState } from './query-bridge.js' +import { resolvePermission, getPendingPermissions } from './deferred.js' +import { + createSession, + resumeSession, + listSessions, + getSessionManager, + getCheckpointManager, +} from '../services/session-service.js' +import type { SessionManager } from '@coder/core' +import { Compactor } from '@coder/core' + +const execFileAsync = promisify(execFile) + +// --------------------------------------------------------------------------- +// Config from ~/.coder/settings.json +// --------------------------------------------------------------------------- + +interface ModelEntry { + model: string[] // List of model IDs available for this provider + base_url?: string // Provider endpoint URL + auth_token_env?: string // API key / auth token + proxy?: string // HTTP/HTTPS proxy URL for this provider (e.g. "http://127.0.0.1:7890") + max_tokens?: number // Maximum output tokens for this model (default: 32768) + provider?: string // e.g. "anthropic", "deepseek", "openai" + price?: { + input: number + output: number + currency: string + unit: string + } +} + +function inferProvider(name: string): string { + const lower = name.toLowerCase() + if (lower.includes('deepseek')) return 'deepseek' + if (lower.includes('openai') || lower.includes('gpt') || lower.includes('o1') || lower.includes('o3') || lower.includes('o4')) return 'openai' + return 'anthropic' +} + +function upsertModelList(list: ModelEntry[], newEntry: ModelEntry): void { + const idx = list.findIndex(m => m.provider === newEntry.provider) + if (idx >= 0) { + list[idx] = { ...list[idx], ...newEntry } + } else { + list.push(newEntry) + } +} + +interface ClaudeSettings { + env?: Record + theme?: string + model_list?: ModelEntry[] + default_model?: string + max_tokens?: number // Global max output tokens for all providers (default: 32768) + display?: { + tui_auto_resume_recent?: boolean + } +} + +function loadClaudeSettings(): ClaudeSettings { + try { + const raw = readFileSync(join(homedir(), '.coder', 'settings.json'), 'utf-8') + return JSON.parse(raw) + } catch { + return {} + } +} + +function resolveModelConfig(settings: ClaudeSettings, fallbackModel: string): { + model: string + baseUrl?: string + apiKey?: string + proxy?: string + maxTokens?: number + name: string + provider: string +} { + // Helper: parse "provider/model-name" format + const parseDefaultModel = (raw: string): { providerName: string; modelName: string | undefined } => { + const parts = raw.split('/') + return { + providerName: parts[0], + modelName: parts.length > 1 ? parts[1] : undefined, + } + } + + // Helper: resolve a model from a model_list entry + const resolveFromEntry = (entry: ModelEntry, preferredModel?: string) => { + const selectedModel = preferredModel + ? (entry.model.find(m => m === preferredModel) ?? entry.model[0]) + : entry.model[0] + return { + model: selectedModel, + baseUrl: entry.base_url, + apiKey: entry.auth_token_env, + proxy: entry.proxy, + maxTokens: entry.max_tokens, + name: selectedModel, + provider: entry.provider ?? inferProvider(selectedModel), + } + } + + // 0. CODER_MODEL env var — highest priority (overrides all other sources) + const coderModel = process.env.CODER_MODEL + if (coderModel) { + if (settings.model_list) { + // Try parsing as "provider/model-name" + const { providerName, modelName } = parseDefaultModel(coderModel) + if (providerName) { + const entry = settings.model_list.find(m => m.provider === providerName) + if (entry && entry.model.length > 0) { + return resolveFromEntry(entry, modelName) + } + } + // Fallback: try matching any model name in any entry's model array + for (const entry of settings.model_list) { + const matched = entry.model.find(m => m === coderModel) + if (matched) { + return resolveFromEntry(entry, matched) + } + } + } + // Not in model_list — use directly + return { + model: coderModel, + name: coderModel, + provider: inferProvider(coderModel), + } + } + + // 1. Find default model from model_list using "provider/model-name" format + const defaultName = settings.default_model + if (defaultName && settings.model_list) { + const { providerName, modelName } = parseDefaultModel(defaultName) + const entry = settings.model_list.find(m => m.provider === providerName) + if (entry && entry.model.length > 0) { + return resolveFromEntry(entry, modelName) + } + } + // 2. Fall back to first model in list + if (settings.model_list && settings.model_list.length > 0) { + const entry = settings.model_list[0]! + if (entry.model.length > 0) { + return resolveFromEntry(entry) + } + } + // 3. Legacy env fallback + const env = settings.env ?? {} + return { + model: env.CODER_MODEL ?? fallbackModel, + baseUrl: env.CODER_BASE_URL, + apiKey: env.CODER_AUTH_TOKEN, + name: fallbackModel, + provider: 'anthropic', + } +} + +// --------------------------------------------------------------------------- +// CoderGatewayClient constructor options +// --------------------------------------------------------------------------- + +export interface CoderGatewayClientOptions { + /** Enable Coordinator or Worker mode (default: false) */ + coordinatorMode?: boolean + /** Team identifier for routing */ + teamId?: string + /** Worker-only mode (default: false) */ + workerMode?: boolean + /** Maximum concurrent workers in Coordinator mode (default: 3) */ + maxWorkers?: number + /** Enable extended thinking mode (default: false) */ + thinkingMode?: boolean + /** Extended thinking budget in tokens (default: 1024) */ + thinkingBudget?: number + /** Fork from a specific session ID */ + forkSessionId?: string + /** Turn number to fork from (used with forkSessionId) */ + forkTurn?: number +} + +// --------------------------------------------------------------------------- +// CoderGatewayClient +// --------------------------------------------------------------------------- + +export class CoderGatewayClient extends EventEmitter implements IGatewayClient { + private ready = false + private subscribed = false + private bufferedEvents: GatewayEvent[] = [] + private logLines: string[] = [] + private model: string + private conversationMessages: { role: 'user' | 'assistant'; content: string }[] = [] + + // ── Coordinator / Worker state ───────────────────────────────────── + private coordinatorMode: boolean + private teamId?: string + private workerMode: boolean + private maxWorkers: number + + // ── Thinking config ──────────────────────────────────────────────── + private thinkingMode: boolean + private thinkingBudget: number + + // ── Model config ──────────────────────────────────────────────────── + private modelConfig: { model: string; baseUrl?: string; apiKey?: string; proxy?: string; maxTokens?: number; name: string; provider: string } | null = null + + // ── Session fork config ───────────────────────────────────────────── + private forkSessionId?: string + private forkTurn?: number + + // ── QueryEngine + Bridge state ────────────────────────────────────── + private engineResult: EngineFactoryResult | null = null + private bridgeState: BridgeState | null = null + private lastInfoEmitMs = 0 + /** Gateway session ID from session.create RPC — must match engine's sessionId */ + private gatewaySessionId: string | null = null + + constructor(options: CoderGatewayClientOptions = {}) { + super() + this.setMaxListeners(0) + + const settings = loadClaudeSettings() + + // CODER_MODEL env var — highest-priority model override. + // Check before resolveModelConfig so the env var wins over settings.json. + const coderModel = process.env.CODER_MODEL + let resolved: { model: string; baseUrl?: string; apiKey?: string; proxy?: string; maxTokens?: number; name: string; provider: string } + if (coderModel) { + // Helper: resolve from a model_list entry + const resolveEntry = (entry: ModelEntry, preferredModel?: string) => { + const selectedModel = preferredModel + ? (entry.model.find(m => m === preferredModel) ?? entry.model[0]) + : entry.model[0] + return { + model: selectedModel, + baseUrl: entry.base_url, + apiKey: entry.auth_token_env, + proxy: entry.proxy, + maxTokens: entry.max_tokens, + name: selectedModel, + provider: entry.provider ?? inferProvider(selectedModel), + } + } + + // Try parsing as "provider/model-name" + const parts = coderModel.split('/') + const providerName = parts.length > 1 ? parts[0] : undefined + const modelName = parts.length > 1 ? parts[1] : parts[0] + + // Default fallback + let entryResult: ReturnType | null = null + + if (settings.model_list && settings.model_list.length > 0) { + // Try matching by provider first + const entry = providerName + ? settings.model_list.find(m => m.provider === providerName) + : undefined + if (entry && entry.model.length > 0) { + entryResult = resolveEntry(entry, modelName) + } else { + // Fallback: try matching any model name in any model array + for (const e of settings.model_list) { + const matched = e.model.find(m => m === modelName) + if (matched) { + entryResult = resolveEntry(e, matched) + break + } + } + } + } + + resolved = entryResult ?? { model: modelName, name: modelName, provider: providerName ?? inferProvider(modelName) } + } else { + resolved = resolveModelConfig(settings, 'claude-sonnet-4-6') + } + + this.model = resolved.model + this.modelConfig = resolved + + // ── Coordinator / Worker mode ─────────────────────────────────── + this.coordinatorMode = + options.coordinatorMode === true || + process.env.CODER_COORDINATOR_MODE === 'true' + this.workerMode = + options.workerMode === true || + process.env.CODER_WORKER_MODE === 'true' + this.teamId = options.teamId ?? process.env.CODER_TEAM_ID + this.maxWorkers = options.maxWorkers ?? 3 + this.thinkingMode = + options.thinkingMode === true || + process.env.CODER_THINKING_MODE === 'true' + this.thinkingBudget = + options.thinkingBudget ?? + (process.env.CODER_THINKING_BUDGET ? parseInt(process.env.CODER_THINKING_BUDGET, 10) : undefined) ?? + 1024 + this.forkSessionId = options.forkSessionId + this.forkTurn = options.forkTurn + + // Engine is created lazily on first prompt submission so that + // cwd / config are settled by the time the user types. + } + + // ── IGatewayClient impl ────────────────────────────────────────── + + start(): void { + this.ready = true + const modeTag = this.coordinatorMode ? ' coordinator' : this.workerMode ? ' worker' : '' + this.log(`gateway started (coder TypeScript backend, model=${this.model}${modeTag})`) + this.publish({ type: 'gateway.ready' }) + } + + kill(_reason?: string): void { + this.ready = false + this.subscribed = false + this.bufferedEvents.length = 0 + if (this.engineResult) { + this.engineResult.interrupt() + this.engineResult = null + this.bridgeState = null + } + } + + drain(): void { + this.subscribed = true + for (const ev of this.bufferedEvents) { + this.emit('event', ev) + } + this.bufferedEvents.length = 0 + } + + getLogTail(limit = 20): string { + return this.logLines.slice(-Math.max(1, limit)).join('\n') + } + + async request(method: string, params?: Record): Promise { + try { + switch (method) { + // ── Session ────────────────────────────────────────── + case 'session.create': { + const { session, checkpoint } = await createSession({ + cwd: (params?.cwd as string) ?? process.cwd(), + model: (params?.model as string) ?? this.model, + }) + // Store the gateway session ID so the engine uses the same ID. + // Without this, the TUI filters out all bridge events because + // the engine generates a different random UUID. + this.gatewaySessionId = session.id + const sm = getSessionManager() + const info = sm.get(session.id) + return { + session_id: session.id, + info: info ? { id: info.id, title: info.title, status: info.status, turnCount: info.turnCount, cwd: info.cwd, model: info.model } : null, + messages: [], + } as unknown as T + } + + case 'session.resume': { + const sessionId = (params?.session_id as string) ?? '' + const { session } = await resumeSession(sessionId) + // Sync the gateway session ID so the engine uses the same ID. + // Without this, the engine generates a different UUID and the + // TUI event filter drops all bridge events (silent model). + this.gatewaySessionId = session.id + const sm = getSessionManager() + const info = sm.get(session.id) + return { + session_id: session.id, + info: info ? { id: info.id, title: info.title, status: info.status, turnCount: info.turnCount, cwd: info.cwd, model: info.model } : null, + messages: session.messages.map(m => ({ + role: m.role, + text: typeof m.content === 'string' ? m.content : '', + context: JSON.stringify(m), + })), + } as unknown as T + } + + case 'session.list': { + const sessions = listSessions(20) + return sessions.map(s => ({ + id: s.id, + title: s.title, + message_count: s.turnCount, + preview: '', + started_at: s.createdAt, + })) as unknown as T + } + + case 'session.active_list': + return [] as unknown as T + + case 'session.most_recent': { + const sessions = listSessions(1) + if (sessions.length > 0) { + const s = sessions[0]! + return { session_id: s.id, title: s.title, started_at: s.createdAt } as unknown as T + } + return { session_id: null } as unknown as T + } + + case 'session.interrupt': { + // Forward interrupt to the QueryEngine's AbortController. + // This aborts the in-progress Agent Loop turn without + // destroying the engine (unlike kill() which nulls it). + if (this.engineResult) { + // Resolve all pending deferred permissions with false so + // the Agent Loop doesn't hang forever waiting for approval. + for (const [toolUseId] of getPendingPermissions()) { + resolvePermission(toolUseId, false) + } + this.engineResult.interrupt() + this.log('session interrupted by user') + } + return { interrupted: true } as unknown as T + } + + case 'session.activate': + case 'session.title': + case 'session.save': + case 'session.close': + case 'session.delete': + case 'session.undo': + case 'session.branch': + case 'session.steer': + case 'session.status': + case 'session.usage': + return null as unknown as T + + case 'session.compress': { + const sessionId = (params?.session_id as string) || this.gatewaySessionId + if (!sessionId) { + return { removed: 0, after_messages: 0, before_messages: 0 } as unknown as T + } + const session = getSessionManager().get(sessionId) + if (!session || session.messages.length === 0) { + return { removed: 0, after_messages: 0, before_messages: 0 } as unknown as T + } + const beforeMessages = session.messages.length + this.publish({ + type: 'status.update', + payload: { text: 'Compressing conversation…', kind: 'info' }, + } as GatewayEvent) + const compactor = new Compactor({ summarizeEnabled: false }) + const contextBudget = 180_000 + const budget = compactor.computeBudget(session.messages, contextBudget) + if (!compactor.needsCompaction(session.messages, contextBudget)) { + this.publish({ + type: 'status.update', + payload: { text: 'Context is within budget — no compaction needed', kind: 'info' }, + } as GatewayEvent) + return { removed: 0, before_messages: beforeMessages, after_messages: beforeMessages, before_tokens: budget.current, after_tokens: budget.current } as unknown as T + } + try { + const result = await compactor.compact(session.messages, contextBudget) + session.messages = result.messages + this.publish({ + type: 'status.update', + payload: { text: `Compacted: ${result.messagesRemoved} messages removed, ${result.beforeTokens.toLocaleString()} → ${result.afterTokens.toLocaleString()} tokens`, kind: 'info' }, + } as GatewayEvent) + return { removed: result.messagesRemoved, before_messages: beforeMessages, after_messages: result.messages.length, before_tokens: result.beforeTokens, after_tokens: result.afterTokens, summary: result.summary ? { note: result.summary } : undefined } as unknown as T + } catch { + return { removed: 0, after_messages: beforeMessages, before_messages: beforeMessages } as unknown as T + } + } + + // ── Config ──────────────────────────────────────────── + case 'config.full': { + const settings = loadClaudeSettings() + const resolved = resolveModelConfig(settings, 'claude-sonnet-4-6') + return { + config: { + display: { + bell_on_complete: false, + busy_input_mode: 'interrupt', + details_mode: 'auto', + inline_diffs: true, + mouse_tracking: true, + sections: {}, + show_cost: true, + show_reasoning: true, + streaming: true, + thinking_mode: 'full', + tui_auto_resume_recent: settings.display?.tui_auto_resume_recent ?? false, + tui_compact: false, + tui_status_indicator: 'kaomoji', + tui_statusbar: 'bottom', + }, + model: resolved.model, + base_url: resolved.baseUrl ?? '', + }, + } as unknown as T + } + + case 'config.get': { + const key = (params?.key as string) ?? '' + switch (key) { + case 'full': { + const settings = loadClaudeSettings() + const resolved = resolveModelConfig(settings, 'claude-sonnet-4-6') + return { + config: { + display: { + bell_on_complete: false, + busy_input_mode: 'interrupt', + details_mode: 'auto', + inline_diffs: true, + mouse_tracking: true, + sections: {}, + show_cost: true, + show_reasoning: true, + streaming: true, + thinking_mode: 'full', + tui_auto_resume_recent: settings.display?.tui_auto_resume_recent ?? false, + tui_compact: false, + tui_status_indicator: 'kaomoji', + tui_statusbar: 'bottom', + }, + model: resolved.model, + base_url: resolved.baseUrl ?? '', + }, + } as unknown as T + } + case 'mtime': { + return { mtime: Date.now() } as unknown as T + } + default: { + const settings = loadClaudeSettings() + const env = settings.env ?? {} + return { value: env[key] ?? '' } as unknown as T + } + } + } + + case 'config.mtime': + return { mtime: Date.now() } as unknown as T + case 'config.get_value': { + const gkey = (params?.key as string) ?? '' + const settings3 = loadClaudeSettings() + const env3 = settings3.env ?? {} + return { value: env3[gkey] ?? '' } as unknown as T + } + case 'config.set': { + const key = (params?.key as string) ?? '' + const value = (params?.value as string) ?? '' + const settings = loadClaudeSettings() + + if (key === 'model' && value) { + // default_model format: "provider/model-name" (e.g., "deepseek/deepseek-v4-pro") + const parts = value.split('/') + const providerName = parts[0] + const modelName = parts.length > 1 ? parts[1] : undefined + + const modelList = settings.model_list ?? [] + const entry = modelList.find(m => m.provider === providerName) + + if (entry && entry.model.length > 0) { + const selectedModel = modelName + ? (entry.model.find(m => m === modelName) ?? entry.model[0]) + : entry.model[0] + + // Update env vars in settings to match the selected model entry + settings.env = settings.env ?? {} + settings.env.CODER_MODEL = selectedModel + if (entry.base_url) settings.env.CODER_BASE_URL = entry.base_url + if (entry.auth_token_env) settings.env.CODER_AUTH_TOKEN = entry.auth_token_env + settings.default_model = value + + // Smart merge into model_list (match by provider) + upsertModelList(modelList, entry) + settings.model_list = modelList + + // Persist to disk + writeFileSync( + join(homedir(), '.coder', 'settings.json'), + JSON.stringify(settings, null, 2), + ) + + // Update runtime state + this.model = selectedModel + this.modelConfig = { + model: selectedModel, + baseUrl: entry.base_url, + apiKey: entry.auth_token_env, + name: selectedModel, + provider: entry.provider ?? inferProvider(selectedModel), + } + this.log(`config.set model=${value} -> ${selectedModel} (provider=${this.modelConfig.provider})`) + } + } else if (key === 'CODER_MODEL' && value) { + // Direct default_model set: format as "provider/model-name" + const parts = value.split('/') + const providerName = parts[0] + const modelName = parts.length > 1 ? parts[1] : value + + settings.env = settings.env ?? {} + settings.env.CODER_MODEL = value + settings.model_list = settings.model_list ?? [] + const existingIdx = settings.model_list.findIndex( + (m: ModelEntry) => m.provider === providerName + ) + if (existingIdx >= 0) { + const existing = settings.model_list[existingIdx] + if (!existing.model.includes(modelName)) { + existing.model = [modelName, ...existing.model] + } + } else { + settings.model_list.push({ model: [modelName], provider: providerName }) + } + settings.default_model = value + writeFileSync( + join(homedir(), '.coder', 'settings.json'), + JSON.stringify(settings, null, 2), + ) + this.log(`config.set CODER_MODEL=${value}`) + } else { + // Generic key: update env[key] in settings and persist + settings.env = settings.env ?? {} + settings.env[key] = value + // Also update the current default model entry in model_list + if (settings.model_list && settings.default_model) { + const parts = settings.default_model.split('/') + const providerName = parts[0] + const defEntry = settings.model_list.find((m: ModelEntry) => m.provider === providerName) + if (defEntry) { + if (key === 'CODER_BASE_URL') defEntry.base_url = value + if (key === 'CODER_AUTH_TOKEN') defEntry.auth_token_env = value + } + } + writeFileSync( + join(homedir(), '.coder', 'settings.json'), + JSON.stringify(settings, null, 2), + ) + this.log(`config.set ${key}=${value}`) + } + + return { key, value } as unknown as T + } + + // ── Commands ─────────────────────────────────────────── + case 'commands.catalog': + return { categories: [], pairs: [], skill_count: 0 } as unknown as T + + case 'completion': + return { items: [] } as unknown as T + + case 'slash.exec': + case 'command.dispatch': + return null as unknown as T + + // ── Shell ────────────────────────────────────────────── + case 'shell.exec': { + const cmd = (params?.command as string) ?? '' + try { + const { stdout, stderr } = await execFileAsync('sh', ['-c', cmd], { + timeout: 30000, + maxBuffer: 1024 * 1024, + }) + return { code: 0, stdout, stderr } as unknown as T + } catch (err: unknown) { + const e = err as { code?: number; stdout?: string; stderr?: string } + return { code: e.code ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' } as unknown as T + } + } + + // ── Prompt ───────────────────────────────────────────── + case 'prompt.submit': { + const userText = (params?.text as string) ?? '' + void this.submitPrompt(userText) + return { accepted: true } as unknown as T + } + + // ── Approval ─────────────────────────────────────────── + case 'approval.respond': { + const choice = (params?.choice as string) ?? 'deny' + // TUI sends 'once' / 'session' / 'always' / 'deny' (see prompts.tsx OPTS). + // Any choice other than 'deny' means the user approved the tool. + const allowed = choice !== 'deny' + const requestId = params?.request_id as string | undefined + + // Resolve via bridgeState.pendingApprovals which holds the actual + // DeferredPermission object from the Agent Loop. We must resolve + // the deferred here directly because query.ts creates DeferredPermission + // inline (System A) WITHOUT registering in deferred.ts's global + // pendingPermissions Map (System B). Calling resolvePermission() + // would look up an empty Map → silent no-op → Agent Loop hangs forever. + const approval = requestId + ? this.bridgeState?.pendingApprovals.find((a) => a.toolUseId === requestId) + : this.bridgeState?.pendingApprovals[0]; + + if (approval) { + // Resolve the Agent Loop's inline DeferredPermission directly + approval.deferred.resolve(allowed); + // Also try the global Map for other consumers + resolvePermission(approval.toolUseId, allowed); + } + + return { resolved: true, allowed } as unknown as T + } + + // ── Model ────────────────────────────────────────────── + case 'model.options': + return { model: this.model, providers: [] } as unknown as T + + // ── Tools / MCP ──────────────────────────────────────── + case 'tools.configure': + case 'mcp.reload': + case 'env.reload': + case 'process.stop': + case 'browser.manage': + return null as unknown as T + + // ── Delegation ───────────────────────────────────────── + case 'delegation.status': + return { max_spawn_depth: 3, max_concurrent_children: 5, paused: false } as unknown as T + + case 'delegation.pause': + case 'subagent.interrupt': + return null as unknown as T + + // ── Spawn tree ───────────────────────────────────────── + case 'spawn-tree.list': + return [] as unknown as T + + case 'spawn-tree.load': + return null as unknown as T + + // ── Setup ────────────────────────────────────────────── + case 'setup.status': + return { provider_configured: this.hasProvider() } as unknown as T + + // ── Rollback ─────────────────────────────────────────── + case 'rollback.list': + return [] as unknown as T + + case 'rollback.diff': + case 'rollback.restore': + return null as unknown as T + + // ── Voice ────────────────────────────────────────────── + case 'voice.toggle': + case 'voice.record': + return null as unknown as T + + // ── Coordinator ───────────────────────────────────────── + case 'coordinator.mode': { + return { + enabled: this.coordinatorMode, + team_id: this.teamId ?? null, + worker_mode: this.workerMode, + max_workers: this.maxWorkers, + env_coordinator: process.env.CODER_COORDINATOR_MODE === 'true', + env_team_id: process.env.CODER_TEAM_ID ?? null, + } as unknown as T + } + + case 'coordinator.tasks': { + const bus = getSubagentBus() + const runningIds = bus.getRunningIds() + const tasks = runningIds.map((id) => { + const entry = bus.get(id) + return { + id, + description: entry?.description ?? '', + status: entry?.status ?? 'unknown', + subagent_type: entry?.subagentType ?? 'general-purpose', + } + }) + return { + tasks, + running_count: bus.runningCount, + total_tracked: bus.listAll().length, + } as unknown as T + } + + // ── Misc ─────────────────────────────────────────────── + case 'terminal.resize': + case 'clipboard.paste': + case 'input.detect_drop': + case 'image.attach': + return null as unknown as T + + default: + this.log(`unhandled RPC: ${method}`) + return null as unknown as T + } + } catch (err) { + this.log(`RPC error: ${method} — ${err instanceof Error ? err.message : String(err)}`) + throw err + } + } + + // ── Engine initialisation ───────────────────────────────────────── + + private ensureEngine(): void { + if (this.engineResult) return + + const settings = loadClaudeSettings() + const env = settings.env ?? {} + + // Resolve API key: env var, model_list entry, or env CODER_AUTH_TOKEN + const modelCfg = this.modelConfig + const apiKey = + process.env.CODER_AUTH_TOKEN ?? + modelCfg?.apiKey ?? + env.CODER_AUTH_TOKEN ?? + '' + + const baseUrl = + modelCfg?.baseUrl ?? + env.CODER_BASE_URL ?? + process.env.CODER_BASE_URL + + const proxy = + (modelCfg as any)?.proxy ?? + env.CODER_PROXY ?? + process.env.CODER_PROXY + + // Resolve maxTokens: env var > per-model entry > global settings > undefined (uses provider default) + const maxTokens = + process.env.CODER_MAX_TOKENS + ? parseInt(process.env.CODER_MAX_TOKENS, 10) + : (modelCfg?.maxTokens ?? settings.max_tokens) + + // Check CODER_COORDINATOR_MODE env var (set by entry.tsx or manually) + const coordinatorMode = + this.coordinatorMode || + process.env.CODER_COORDINATOR_MODE === 'true' + + // Share the gateway's singleton SessionManager with the engine. + // This ensures session.create RPC (gateway) and engine tool execution + // tracking / message persistence use the same sessions Map. Without + // this, the engine's internal SessionManager tracks messages in a + // separate copy, causing session state divergence. + const sessionManager = getSessionManager() + + this.engineResult = createQueryEngine({ + cwd: process.cwd(), + apiKey, + baseUrl: baseUrl || undefined, + proxy: proxy || undefined, + model: this.model, + providerName: modelCfg?.provider, + maxTurns: 100, + maxTokens, + sessionId: this.gatewaySessionId ?? undefined, + sessionManager, + coordinatorMode, + teamId: this.teamId, + thinkingMode: this.thinkingMode, + thinkingBudget: this.thinkingBudget, + }) + + this.bridgeState = createBridgeState(this.engineResult.sessionId) + this.bridgeState.model = this.model + + const roleTag = this.engineResult.roleLabel !== 'default' + ? `, role=${this.engineResult.roleLabel}` + : '' + + if (apiKey) { + this.log(`engine initialised (session=${this.engineResult.sessionId.slice(0, 8)}, model=${this.model}${roleTag})`) + } else { + this.log(`engine initialised without API key (mock mode)${roleTag}`) + } + } + + // ── Prompt submission (Agent Loop via QueryEngine) ────────────────── + + private async submitPrompt(userText: string): Promise { + // Lazy-init engine on first prompt + this.ensureEngine() + + if (!this.engineResult || !this.bridgeState) { + this.publish({ type: 'message.start' }) + this.publish({ + type: 'message.delta', + payload: { + text: '**Engine initialisation failed.**\n\nCheck that the configuration is valid.', + }, + }) + this.publish({ type: 'message.complete' }) + this.publish({ type: 'status.update', payload: { text: 'Ready' } }) + return + } + + const { engine } = this.engineResult + + this.publish({ type: 'message.start' }) + + // ── Coordinator mode: task allocation preamble ───────────────── + if (this.coordinatorMode) { + const bus = getSubagentBus() + this.publish({ + type: 'status.update', + payload: { + text: `Coordinator mode — ${bus.runningCount} active worker(s), ${this.maxWorkers} max`, + kind: 'info', + }, + }) + // TODO (Phase 2): Coordinator task allocation loop — + // After each turn, check for idle workers and assign pending + // sub-tasks from the task queue. The Agent Loop already drains + // completed agents via SubagentBus at the start of each turn + // (see query.ts:subagentBus.drainCompleted()). + this.log(`coordinator: task allocation placeholder (workers=${this.maxWorkers})`) + } + + // Initial status — bridge will override with 'Thinking…' / 'Generating…' + // once stream events arrive. This avoids a flash of 'ready' during API call setup. + this.publish({ type: 'status.update', payload: { text: 'Thinking…' } }) + + let wasCompleteEmitted = false + + try { + for await (const queryEvent of engine.submitMessage(userText)) { + switch (queryEvent.type) { + case 'message': { + // queryEvent.data is a QueryMessage from the Agent Loop + const queryMsg = queryEvent.data as QueryMessage + const gatewayEvents = bridgeQueryToGateway(queryMsg, this.bridgeState) + for (const ev of gatewayEvents) { + if (ev.type === 'message.complete') wasCompleteEmitted = true + this.publish(ev) + } + break + } + + case 'permission_required': { + // Agent Loop suspended — user must approve/deny the tool. + // Pass through as a system permission_required QueryMessage + // so bridgeQueryToGateway emits approval.request. + const permissionMsg: QueryMessage = { + type: 'system', + subtype: 'permission_required', + deferred: queryEvent.deferred!, + } + const gatewayEvents = bridgeQueryToGateway(permissionMsg, this.bridgeState) + for (const ev of gatewayEvents) { + this.publish(ev) + } + break + } + + case 'compact': { + this.publish({ + type: 'status.update', + payload: { text: 'Compressing context...', kind: 'info' }, + }) + break + } + + case 'error': { + const errData = queryEvent.data as { message?: string } | undefined + this.publish({ + type: 'error', + payload: { message: errData?.message ?? 'Unknown error' }, + }) + break + } + + case 'cost': { + // Cost updates are handled inside bridge state accumulation + break + } + + case 'done': + // Turn complete — final cleanup handled below + break + } + + // Throttled status update: emit session.info every 5s so the + // status bar shows live turn count / tokens / cost. + const now = Date.now() + if (now - this.lastInfoEmitMs >= 5000 && this.bridgeState) { + this.lastInfoEmitMs = now + this.publish({ + type: 'session.info', + payload: { + model: this.model, + skills: {}, + tools: {}, + usage: { + calls: this.bridgeState.turnCount, + input: this.bridgeState.usage.inputTokens, + output: this.bridgeState.usage.outputTokens, + total: this.bridgeState.usage.inputTokens + this.bridgeState.usage.outputTokens, + cost_usd: this.bridgeState.totalCost, + }, + }, + }) + } + } + + // message.complete is normally emitted by handleAssistantMessage in the bridge. + // If the bridge didn't emit it (e.g. mock provider edge cases), publish a + // fallback so the TUI always transitions busy → false. + if (!wasCompleteEmitted) { + this.publish({ + type: 'message.complete', + payload: { + text: this.bridgeState.accumulatedText || '', + usage: { + calls: 1, + input: this.bridgeState.usage.inputTokens, + output: this.bridgeState.usage.outputTokens, + total: this.bridgeState.usage.inputTokens + this.bridgeState.usage.outputTokens, + cost_usd: this.bridgeState.totalCost, + }, + }, + }) + } + resetTurnState(this.bridgeState) + this.publish({ type: 'status.update', payload: { text: 'Ready' } }) + + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log(`Prompt error: ${message}`) + this.publish({ + type: 'error', + payload: { message: `Agent error: ${message}` }, + }) + this.publish({ + type: 'status.update', + payload: { text: 'Error', kind: 'error' }, + }) + } + } + + // ── Helpers ──────────────────────────────────────────────────────── + + private hasProvider(): boolean { + if (this.engineResult) return true + const settings = loadClaudeSettings() + const env = settings.env ?? {} + + // Check model_list entries + if (settings.model_list && settings.model_list.length > 0) { + const defaultName = settings.default_model + let entry: ModelEntry | undefined + if (defaultName) { + const parts = defaultName.split('/') + const providerName = parts[0] + entry = settings.model_list.find(m => m.provider === providerName) + } + if (!entry) entry = settings.model_list[0] + // Check if auth_token_env looks like a real value (not placeholder) + const tokenValue = entry?.auth_token_env ?? ''; + if (tokenValue && !tokenValue.startsWith('YOUR_') && !tokenValue.includes('API_KEY') && !tokenValue.includes('NO_KEY')) { + return true + } + } + + // Legacy env check + return Boolean( + process.env.CODER_AUTH_TOKEN || + env.CODER_AUTH_TOKEN, + ) + } + + private publish(ev: Partial): void { + const event = ev as GatewayEvent + if (this.subscribed) { + this.emit('event', event) + } else { + this.bufferedEvents.push(event) + } + } + + private log(msg: string): void { + this.logLines.push(`[coder-gw] ${msg}`) + } +} diff --git a/packages/cli/src/gateway/context.tsx b/packages/cli/src/gateway/context.tsx new file mode 100644 index 0000000..8544a3f --- /dev/null +++ b/packages/cli/src/gateway/context.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' + +import type { IGatewayClient } from './client.js' +import type { MockGatewayClient } from './mock-client.js' + +export interface GatewayContextValue { + gw: IGatewayClient | MockGatewayClient +} + +const GatewayContext = createContext(null) + +export function GatewayProvider({ children, value }: { children: ReactNode; value: GatewayContextValue }) { + return {children} +} + +export function useGateway(): GatewayContextValue { + const ctx = useContext(GatewayContext) + if (!ctx) { + throw new Error('useGateway must be used within GatewayProvider') + } + return ctx +} diff --git a/packages/cli/src/gateway/deferred.ts b/packages/cli/src/gateway/deferred.ts new file mode 100644 index 0000000..0452dcd --- /dev/null +++ b/packages/cli/src/gateway/deferred.ts @@ -0,0 +1,98 @@ +/** + * deferred.ts — Deferred Permission Resolution + * + * When the Agent Loop encounters a tool that needs user confirmation + * (permission.behavior === 'ask_user'), it creates a DeferredPermission + * and yields a permission_required message. The TUI displays an approval + * overlay. When the user responds, resolvePermission() resolves the + * deferred promise, unblocking the Agent Loop. + * + * @deprecated For the TypeScript QueryEngine backend (CoderGatewayClient), + * this module's global `pendingPermissions` Map is NOT used. The Agent + * Loop (query.ts) creates DeferredPermission objects INLINE without + * registering them here. Instead, query-bridge.ts stores them in + * `bridgeState.pendingApprovals` and coder-client.ts resolves them via + * `approval.deferred.resolve(allowed)`. Calling resolvePermission() from + * this module against a QueryEngine-created permission is a silent no-op. + * + * This module remains for: + * 1. The Python gateway backend path (GatewayClient spawns Python child) + * 2. Future standalone TUI modes needing simple permission resolution + * 3. Third-party consumers that use deferred.ts directly + */ + +import type { DeferredPermission } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Pending Permissions Registry +// --------------------------------------------------------------------------- + +const pendingPermissions = new Map(); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Create a deferred permission that the Agent Loop will await. + * + * - Returns immediately with the DeferredPermission object + * - The Agent Loop yields this object and then `await deferred.promise` + * - The TUI resolves it via `resolvePermission(toolUseId, allowed)` + * - Auto-denies after `timeoutMs` (default 30s) + */ +export function createDeferredPermission( + toolName: string, + command: string, + description: string, + toolUseId: string, + timeoutMs = 30000, +): DeferredPermission { + let resolve!: (allowed: boolean) => void; + const promise = new Promise((res) => { + resolve = res; + }); + + const deferred: DeferredPermission = { + toolName, + command, + description, + toolUseId, + resolve, + promise, + }; + + pendingPermissions.set(toolUseId, deferred); + + // Auto-deny on timeout to prevent Agent Loop from hanging forever + setTimeout(() => { + if (pendingPermissions.has(toolUseId)) { + deferred.resolve(false); + pendingPermissions.delete(toolUseId); + } + }, timeoutMs); + + return deferred; +} + +/** + * Resolve a pending permission from outside the Agent Loop (e.g. TUI). + * + * @param toolUseId - The tool_use block ID + * @param allowed - true = user approved, false = user denied + */ +export function resolvePermission(toolUseId: string, allowed: boolean): void { + const deferred = pendingPermissions.get(toolUseId); + if (deferred) { + deferred.resolve(allowed); + pendingPermissions.delete(toolUseId); + } +} + +/** + * Get the map of currently pending permissions. + * Useful for status display or debugging. + */ +export function getPendingPermissions(): Map { + return pendingPermissions; +} diff --git a/packages/cli/src/gateway/engine-factory.ts b/packages/cli/src/gateway/engine-factory.ts new file mode 100644 index 0000000..6d8f6e7 --- /dev/null +++ b/packages/cli/src/gateway/engine-factory.ts @@ -0,0 +1,480 @@ +/** + * engine-factory.ts — Create a fully configured QueryEngine for the CLI. + * + * Wires together all the dependencies needed by the Agent Loop: + * - Provider (Anthropic via API key from env) + * - ToolRegistry (all 31 core tools registered) + * - SessionManager + CheckpointManager (persistence layer) + * - PermissionEngine (Plan / Ask / Auto modes) + * - SystemPromptAssembler (dynamic prompt assembly) + * + * Usage: + * const { engine, interrupt } = createQueryEngine('/path/to/project'); + * for await (const event of engine.submitMessage('fix the bug')) { + * // handle QueryEngineEvent + * } + * // or call interrupt() to abort + */ + +import { randomUUID } from 'node:crypto'; + +import { + AnthropicProvider, + OpenAICompatProvider, + DeepSeekProvider, + ProviderRouter, +} from '@coder/provider'; +import type { Provider, ProviderConfig } from '@coder/provider'; +import { + QueryEngine, + ToolRegistry, + SessionManager, + CheckpointManager, + SystemPromptAssembler, + HookManager, + CronScheduler, + setCronScheduler, + getCronScheduler, +} from '@coder/core'; +import type { QueryEngineEvent } from '@coder/core'; +import { + BashTool, + ReadTool, + WriteTool, + EditTool, + GlobTool, + GrepTool, + GitTool, + TodoWriteTool, + TaskCreateTool, + TaskUpdateTool, + TaskListTool, + TaskDescribeTool, + TaskOutputTool, + WebFetchTool, + WebSearchTool, + AskUserQuestionTool, + ExitPlanModeTool, + NotebookEditTool, + LSPTool, + AgentSpawnTool, + AgentReadTool, + AgentMessageTool, + AgentStopTool, + SkillTool, + TeamCreateTool, + TeamDeleteTool, + CronCreateTool, + CronDeleteTool, + CronListTool, + EnterWorktreeTool, + ExitWorktreeTool, +} from '@coder/tools'; +import type { BaseTool } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Factory options +// --------------------------------------------------------------------------- + +export interface EngineFactoryOptions { + /** Working directory for the agent (default: process.cwd()) */ + cwd?: string; + /** API key override (default: process.env.CODER_API_KEY) */ + apiKey?: string; + /** Base URL override for the provider */ + baseUrl?: string; + /** HTTP/HTTPS proxy URL for the provider */ + proxy?: string; + /** Model identifier (default: from env or 'claude-sonnet-4-6') */ + model?: string; + /** Provider name: "anthropic" | "openai" | "deepseek" | "auto" (default: "anthropic") */ + providerName?: string; + /** Maximum turns per interaction (default: 100) */ + maxTurns?: number; + /** Maximum budget in USD (default: no limit) */ + maxBudgetUsd?: number; + /** Context window budget in tokens (default: 180_000) */ + contextBudget?: number; + /** Compaction threshold ratio (default: 0.7) */ + compactThreshold?: number; + /** Custom system prompt (replaces default) */ + customSystemPrompt?: string; + /** Append to system prompt */ + appendSystemPrompt?: string; + /** Explicit session ID (default: random UUID). MUST match the gateway session ID + * returned by session.create RPC, otherwise TUI filters out all bridge events. */ + sessionId?: string; + /** Optional SubagentBus for tracking background sub-agents */ + subagentBus?: import('@coder/shared').SubagentBus; + /** Enable Coordinator mode (default: false) */ + coordinatorMode?: boolean; + /** Worker role: only meaningful when coordinatorMode=false (default: undefined) */ + workerRole?: 'explore' | 'builder' | 'reviewer'; + /** Team identifier for Coordinator ↔ Worker routing */ + teamId?: string; + /** Enable extended thinking mode (default: false) */ + thinkingMode?: boolean; + /** Extended thinking budget in tokens (default: 1024) */ + thinkingBudget?: number; + /** Maximum output tokens for the model (default: 32768) */ + maxTokens?: number; + /** External SessionManager — when provided, the engine shares the same + * instance as the gateway (session.create/list/resume RPCs). Without + * this, each engine creates its own instance, leading to session state + * divergence between the TUI and the Agent Loop. */ + sessionManager?: import('@coder/core').SessionManager; +} + +export interface EngineFactoryResult { + /** The configured QueryEngine, ready to submit messages */ + engine: QueryEngine; + /** Interrupt the in-progress turn (calls AbortController.abort()) */ + interrupt: () => void; + /** The session ID for this engine */ + sessionId: string; + /** Resolved agent role: 'coordinator' | 'worker' | 'default' */ + agentRole: string; + /** Comma-separated tool names registered for this engine */ + toolNames: string; + /** Human-readable role label */ + roleLabel: string; +} + +// --------------------------------------------------------------------------- +// Default tool set +// --------------------------------------------------------------------------- + +const ALL_TOOLS: BaseTool[] = [ + new BashTool(), + new ReadTool(), + new WriteTool(), + new EditTool(), + new GlobTool(), + new GrepTool(), + new GitTool(), + new TodoWriteTool(), + new TaskCreateTool(), + new TaskUpdateTool(), + new TaskListTool(), + new TaskDescribeTool(), + new TaskOutputTool(), + new WebFetchTool(), + new WebSearchTool(), + new AskUserQuestionTool(), + new ExitPlanModeTool(), + new NotebookEditTool(), + new LSPTool(), + new AgentSpawnTool(), + new AgentReadTool(), + new AgentMessageTool(), + new AgentStopTool(), + new SkillTool(), + new TeamCreateTool(), + new TeamDeleteTool(), + new CronCreateTool(), + new CronDeleteTool(), + new CronListTool(), + new EnterWorktreeTool(), + new ExitWorktreeTool(), +]; + +// --------------------------------------------------------------------------- +// Tool role filter — restricts tools by agent role +// --------------------------------------------------------------------------- + +/** + * Tool names allowed per role. + * + * Coordinator: full tool set (unrestricted) — plans, delegates, reads, searches. + * Explore: read-only discovery tools (no writes, no bash, no mutations). + * Builder: read + write + bash (the core developer toolset). + * Reviewer: read-only inspection + bash (for running tests/linters). + */ +const TOOL_NAMES_BY_ROLE: Record = { + coordinator: [], // empty = unrestricted (all tools) + explore: ['Read', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'TaskList'], + builder: ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'TaskList', 'TaskOutput', 'NotebookEdit', 'LSP'], + reviewer: ['Read', 'Glob', 'Grep', 'Bash', 'LSP'], +}; + +/** + * Returns the tool names allowed for the given role. + * + * @param role — 'coordinator' | 'explore' | 'builder' | 'reviewer' | undefined + * @returns string[] of tool names, or empty array if unrestricted + */ +export function getToolsForRole(role?: string): string[] { + if (!role) return []; + const names = TOOL_NAMES_BY_ROLE[role]; + return names ?? []; +} + +/** + * Filter ALL_TOOLS to only those allowed for the given role. + * An empty allowlist means all tools are permitted. + */ +function filterToolsByRole(role?: string): BaseTool[] { + const allowed = getToolsForRole(role); + if (allowed.length === 0) return ALL_TOOLS; + return ALL_TOOLS.filter((tool) => allowed.includes(tool.definition.name)); +} + +// --------------------------------------------------------------------------- +// Factory function +// --------------------------------------------------------------------------- + +/** + * Create a fully configured QueryEngine with all dependencies wired up. + * + * Reads CODER_API_KEY from the environment (or options.apiKey). + * Creates an AnthropicProvider, ToolRegistry with all 31 core tools, + * SessionManager, CheckpointManager, and SystemPromptAssembler. + * + * @param cwdOrOptions — Working directory or full options object + * @returns EngineFactoryResult with engine, interrupt, and sessionId + */ +export function createQueryEngine( + cwdOrOptions?: string | EngineFactoryOptions, +): EngineFactoryResult { + const opts: EngineFactoryOptions = + typeof cwdOrOptions === 'string' + ? { cwd: cwdOrOptions } + : (cwdOrOptions ?? {}); + + const cwd = opts.cwd ?? process.cwd(); + const apiKey = opts.apiKey ?? process.env.CODER_API_KEY ?? ''; + const baseUrl = opts.baseUrl ?? process.env.CODER_BASE_URL; + const proxy = opts.proxy ?? process.env.CODER_PROXY; + const model = (opts.model && opts.model !== 'claude-sonnet-4-6') ? opts.model : process.env.CODER_MODEL ?? opts.model ?? 'claude-sonnet-4-6'; + const providerName = opts.providerName ?? (model.toLowerCase().includes('deepseek') ? 'deepseek' : process.env.CODER_PROVIDER ?? 'anthropic'); + + // ── Determine agent role ───────────────────────────────────────── + const coordinatorMode = opts.coordinatorMode ?? false; + const workerRole = opts.workerRole; + const agentRole: string = coordinatorMode + ? 'coordinator' + : workerRole + ? 'worker' + : 'default'; + const engineMode: 'default' | 'coordinator' | 'worker' = + coordinatorMode ? 'coordinator' : workerRole ? 'worker' : 'default'; + + // ── 1. Provider — select by providerName ──────────────────────────── + const providerConfig: ProviderConfig = { + apiKey, + baseUrl: baseUrl || undefined, + proxy: proxy || undefined, + timeout: 300_000, // 5 minutes + maxRetries: 3, + }; + + const provider: Provider | undefined = apiKey + ? createProvider(providerName, providerConfig) + : undefined; + + // ── 2. ToolRegistry — filter by role ─────────────────────────────── + const toolRegistry = new ToolRegistry(); + const tools = + coordinatorMode + ? ALL_TOOLS // coordinator: full tool set + : workerRole + ? filterToolsByRole(workerRole) // worker: restricted set + : ALL_TOOLS; // default: full tool set + toolRegistry.registerAll(tools); + const toolNames = tools.map((t) => t.definition.name).join(', '); + const roleLabel = coordinatorMode ? 'coordinator' : workerRole ?? 'default'; + + // ── 3. Session Manager ───────────────────────────────────────────── + // When the gateway passes its singleton SessionManager, use it so that + // the TUI (session.create/list/resume RPCs) and the Agent Loop (query.ts + // tool execution + message tracking) share the same sessions Map. + // Without this, the engine's separate SessionManager creates a duplicate + // session that the TUI never sees, and tool results are lost. + const sessionManager = opts.sessionManager ?? new SessionManager(); + + // If a sessionId is provided and the session already exists (created by + // the gateway's session.create RPC), reuse it. Otherwise create a fresh + // session. When sharing SessionManager, the gateway must have already + // called session.create before ensureEngine(). + let sessionId = opts.sessionId; + if (!sessionId || !sessionManager.get(sessionId)) { + const session = sessionManager.create({ + cwd, + model, + provider: providerName, + title: `Session ${(sessionId ?? '').slice(0, 8) || 'new'}`, + }); + sessionId = session.id; + } + + // ── 4. Checkpoint Manager ────────────────────────────────────────── + const checkpointManager = new CheckpointManager(); + + // ── 4b. Cron Scheduler ──────────────────────────────────────────── + // Initialize the singleton CronScheduler if not already running. + // Cron tools (CronCreate/CronDelete/CronList) depend on + // globalThis.__coderCronScheduler which is set by setCronScheduler(). + if (!getCronScheduler()) { + const cronScheduler = new CronScheduler({ + autoStart: true, + }); + setCronScheduler(cronScheduler); + } + + // ── 5. Build ThinkingConfig ────────────────────────────────────────── + const thinkingConfig = opts.thinkingMode + ? { + mode: 'enabled' as const, + budgetTokens: opts.thinkingBudget ?? 1024, + } + : undefined; + + // ── 5.5 HookManager (lifecycle hooks) ───────────────────────────── + const hookManager = new HookManager(); + + // ── 6. Build QueryEngine ──────────────────────────────────────────── + const engine = new QueryEngine({ + cwd, + toolRegistry, + sessionManager, + maxTurns: opts.maxTurns ?? 100, + maxBudgetUsd: opts.maxBudgetUsd, + contextBudget: opts.contextBudget ?? 180_000, + compactThreshold: opts.compactThreshold ?? 0.7, + customSystemPrompt: opts.customSystemPrompt, + appendSystemPrompt: opts.appendSystemPrompt ?? process.env.CODER_APPEND_SYSTEM_PROMPT, + model, + // Provider is wired via provider + providerModel for lazy adapter loading + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: provider as any, + providerModel: model, + subagentBus: opts.subagentBus, + mode: engineMode, + thinkingConfig, + hookManager, + maxTokens: opts.maxTokens, + }); + + // ── 7. Interrupt function ────────────────────────────────────────── + const interrupt = (): void => { + engine.interrupt(); + if (provider) { + provider.abort(); + } + }; + + return { engine, interrupt, sessionId, agentRole, toolNames, roleLabel }; +} + +// --------------------------------------------------------------------------- +// Provider Factory +// --------------------------------------------------------------------------- + +/** + * Create a Provider instance based on the provider name. + * + * @param name — "anthropic" | "openai" | "deepseek" | "auto" + * @param config — Provider configuration (apiKey, baseUrl, etc.) + * @returns A Provider instance + */ +function createProvider(name: string, config: ProviderConfig): Provider { + const isAnthropicEndpoint = config.baseUrl?.includes('/anthropic'); + + switch (name) { + case 'openai': + return new OpenAICompatProvider(config, 'openai'); + case 'deepseek': { + // DeepSeek's /anthropic endpoint uses Anthropic Messages API format, not + // OpenAI Chat Completions. When the base URL targets the /anthropic + // endpoint we must use AnthropicProvider so the SDK sends the correct + // request shape. + if (isAnthropicEndpoint) { + return new AnthropicProvider(config); + } + return new DeepSeekProvider(config); + } + case 'auto': { + // Auto mode: create a router with all providers that have API keys configured + const router = new ProviderRouter(); + // Register Anthropic (always available) + router.register('anthropic', new AnthropicProvider(config), ['*']); + // Register OpenAI if key available + const openaiKey = process.env.OPENAI_API_KEY; + if (openaiKey) { + router.register('openai', new OpenAICompatProvider({ ...config, apiKey: openaiKey }, 'openai'), ['gpt-4o', 'gpt-4o-mini']); + } + // Register DeepSeek if key available — use the right provider per endpoint + const deepseekKey = process.env.DEEPSEEK_API_KEY; + if (deepseekKey) { + const deepseekCfg = { ...config, apiKey: deepseekKey }; + const deepseekProvider = isAnthropicEndpoint + ? new AnthropicProvider(deepseekCfg) + : new DeepSeekProvider(deepseekCfg); + router.register('deepseek', deepseekProvider, ['deepseek-chat', 'deepseek-reasoner']); + } + // Return a proxy provider that delegates to the router + return createRouterProxy(router, config.apiKey ? 'anthropic' : undefined); + } + case 'anthropic': + default: + return new AnthropicProvider(config); + } +} + +/** + * Create a proxy Provider that delegates to a ProviderRouter. + * The first message call uses classifyAndRoute to select the best provider. + */ +function createRouterProxy(router: ProviderRouter, _defaultProvider?: string): Provider { + const routerRef = { current: router }; + const providerRef: { current: Provider | null } = { current: null }; + const modelRef: { current: string } = { current: 'claude-sonnet-4-6' }; + + return { + async stream(modelConfig, system, messages, tools, onEvent) { + if (!providerRef.current) { + // Determine task from the last user message and auto-route + const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); + const task = typeof lastUserMsg?.content === 'string' + ? lastUserMsg.content + : 'general task'; + const route = routerRef.current.classifyAndRoute(task); + providerRef.current = route.provider; + modelRef.current = route.model; + } + return providerRef.current.stream( + { ...modelConfig, model: modelRef.current }, + system, messages, tools, onEvent, + ); + }, + abort() { + providerRef.current?.abort(); + }, + async listModels() { + return routerRef.current.listAllModels(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Convenience: resolve API Key +// --------------------------------------------------------------------------- + +/** + * Check whether any API key is configured (env or options). + */ +export function hasApiKey(): boolean { + return Boolean( + process.env.CODER_API_KEY || + process.env.CODER_AUTH_TOKEN || + process.env.OPENAI_API_KEY || + process.env.DEEPSEEK_API_KEY, + ); +} + +/** + * Get the configured model name. + */ +export function getConfiguredModel(): string { + return process.env.CODER_MODEL ?? 'claude-sonnet-4-6'; +} diff --git a/packages/cli/src/gateway/mock-client.ts b/packages/cli/src/gateway/mock-client.ts new file mode 100644 index 0000000..770dd10 --- /dev/null +++ b/packages/cli/src/gateway/mock-client.ts @@ -0,0 +1,233 @@ +import { EventEmitter } from 'node:events' + +import type { IGatewayClient } from './client.js' +import type { GatewayEvent } from './types.js' + +type EventPayload = Record | undefined | null + +interface MockEvent { + type: string + payload?: EventPayload + session_id?: string +} + +const MOCK_SESSION_ID = 'mock-session-001' +const MOCK_MODEL = 'deepseek-v4-pro' + +let mockTurnId = 0 + +export class MockGatewayClient extends EventEmitter implements IGatewayClient { + private ready = false + private subscribed = false + private bufferedEvents: GatewayEvent[] = [] + + constructor() { + super() + this.setMaxListeners(0) + } + + start(): void { + this.ready = true + this.publish({ type: 'gateway.ready' }) + } + + kill(_reason?: string): void { + this.ready = false + this.subscribed = false + this.bufferedEvents.length = 0 + } + + async request(method: string, params?: Record): Promise { + switch (method) { + case 'session.create': + case 'session.resume': + return { + session_id: MOCK_SESSION_ID, + model: MOCK_MODEL, + cwd: process.cwd(), + version: 'coder-tui-standalone', + } as unknown as T + + case 'session.list': + case 'session.active_list': + return [{ session_id: MOCK_SESSION_ID, model: MOCK_MODEL }] as unknown as T + + case 'session.most_recent': + return { session_id: MOCK_SESSION_ID } as unknown as T + + case 'session.usage': + return { tokens: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, cost: 0 } as unknown as T + + case 'session.status': + return { busy: false, model: MOCK_MODEL, turn_count: 0 } as unknown as T + + case 'session.compress': { + // Mock: simulate a compaction removing ~half the messages + const removed = 4 + return { + removed, + before_messages: 10, + after_messages: 6, + before_tokens: 5000, + after_tokens: 3000, + summary: { headline: `Compacted ${removed} messages (mock, snip)` }, + usage: { total: 2000 }, + } as unknown as T + } + + case 'session.title': + case 'session.save': + case 'session.close': + case 'session.delete': + case 'session.undo': + case 'session.branch': + case 'session.activate': + case 'session.interrupt': + case 'session.steer': + return null as unknown as T + + case 'config.full': { + const config: Record = { + display: { + bell_on_complete: false, + busy_input_mode: 'interrupt', + details_mode: 'auto', + inline_diffs: true, + mouse_tracking: true, + sections: {}, + show_cost: true, + show_reasoning: true, + streaming: true, + thinking_mode: 'full', + tui_auto_resume_recent: true, + tui_compact: false, + tui_status_indicator: 'kaomoji', + tui_statusbar: 'bottom', + }, + model: MOCK_MODEL, + providers: {}, + } + return config as unknown as T + } + + case 'config.mtime': + return 0 as unknown as T + + case 'config.get_value': { + const gkey = (params?.key as string) ?? '' + return { value: gkey ? '' : '' } as unknown as T + } + + case 'config.set': { + const val = (params?.value as string) ?? '' + return { value: val } as unknown as T + } + + case 'commands.catalog': + return { + categories: [], + pairs: [], + skill_count: 0, + } as unknown as T + + case 'completion': + return { items: [] } as unknown as T + + case 'slash.exec': + return { output: '(mock: no backend connected)' } as unknown as T + + case 'command.dispatch': + return { type: 'send', message: `/${params?.command ?? ''}` } as unknown as T + + case 'shell.exec': + return { output: `[mock] shell exec: ${params?.command ?? ''}`, exit_code: 0 } as unknown as T + + case 'terminal.resize': + case 'clipboard.paste': + case 'input.detect_drop': + case 'image.attach': + return null as unknown as T + + case 'model.options': + return { models: [MOCK_MODEL] } as unknown as T + + case 'tools.configure': + case 'mcp.reload': + case 'env.reload': + case 'process.stop': + case 'browser.manage': + return null as unknown as T + + case 'delegation.status': + return { max_spawn_depth: 3, max_concurrent_children: 5, paused: false } as unknown as T + + case 'delegation.pause': + case 'subagent.interrupt': + return null as unknown as T + + case 'spawn-tree.list': + return [] as unknown as T + + case 'spawn-tree.load': + return null as unknown as T + + case 'setup.status': + return { providers_configured: true, skills_installed: true } as unknown as T + + case 'rollback.list': + return [] as unknown as T + + case 'rollback.diff': + case 'rollback.restore': + return null as unknown as T + + case 'voice.toggle': + case 'voice.record': + return null as unknown as T + + case 'prompt.submit': { + const turnId = ++mockTurnId + const userText = (params?.text as string) ?? '' + + // Simulate an assistant response + setTimeout(() => { + this.publish({ type: 'message.start' }) + this.publish({ + type: 'message.delta', + payload: { + text: `## Mock Response\n\nYou said: "${userText}"\n\nThis is a **standalone mock** response from \`coder-tui\`.\n\nThe real Coder backend (Python gateway) is not connected.\n\n### Features available in mock mode:\n- Full TUI rendering (components, scrolling, markdown)\n- Text input and composer\n- Status bar and overlays\n- Virtual history and session management`, + }, + }) + this.publish({ type: 'message.complete' }) + this.publish({ type: 'status.update', payload: { text: 'Ready' } }) + }, 500) + + return { turn_id: turnId, accepted: true } as unknown as T + } + + default: + return null as unknown as T + } + } + + drain(): void { + this.subscribed = true + for (const ev of this.bufferedEvents) { + this.emit('event', ev) + } + this.bufferedEvents.length = 0 + } + + getLogTail(_limit?: number): string { + return '' + } + + private publish(ev: MockEvent): void { + const event = ev as unknown as GatewayEvent + if (this.subscribed) { + this.emit('event', event) + } else { + this.bufferedEvents.push(event) + } + } +} diff --git a/packages/cli/src/gateway/query-bridge.ts b/packages/cli/src/gateway/query-bridge.ts new file mode 100644 index 0000000..181f2ab --- /dev/null +++ b/packages/cli/src/gateway/query-bridge.ts @@ -0,0 +1,628 @@ +/** + * query-bridge.ts — Translate Agent Loop QueryMessage → TUI GatewayEvent + * + * This is the core translator between the Agent Loop's AsyncGenerator output + * (QueryMessage) and the TUI's event stream (GatewayEvent). It accumulates + * streaming state (text, tool use, usage) across consecutive messages and + * emits GatewayEvents that the TUI components understand. + * + * Usage: + * const state = createBridgeState(sessionId); + * for await (const msg of query(config)) { + * const events = bridgeQueryToGateway(msg, state); + * for (const ev of events) gw.publish(ev); + * } + */ + +import type { + QueryMessage, + StreamEvent, + AssistantMessage, + ToolProgress, + CompactMetadata, + DeferredPermission, + CompletionUsage, +} from '@coder/shared'; +import { AgentError } from '@coder/shared'; +import type { GatewayEvent } from './types.js'; + +// --------------------------------------------------------------------------- +// Bridge State +// --------------------------------------------------------------------------- + +export interface ActiveToolState { + id: string; + name: string; + startTime: number; + status: 'started' | 'running' | 'completed'; + /** Accumulated tool input JSON from input_json_delta events */ + inputJson: string; +} + +export interface BridgeUsage { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + totalCost: number; +} + +export interface PendingApproval { + toolUseId: string; + toolName: string; + command: string; + description: string; + deferred: DeferredPermission; +} + +export interface BridgeState { + sessionId: string; + /** Accumulated assistant text for the current turn */ + accumulatedText: string; + /** Active tool invocations keyed by tool_use id */ + activeTools: Map; + /** Cumulative cost across all turns */ + totalCost: number; + /** Usage for the current turn */ + usage: BridgeUsage; + /** Tool execution results (id → result text) */ + toolResults: Map; + /** Pending permission approvals */ + pendingApprovals: PendingApproval[]; + /** Current model name */ + model: string; + /** Turn counter */ + turnCount: number; + /** Tool call count for the current turn */ + currentTurnToolCount: number; + /** Whether the model is currently in an extended thinking block */ + inThinkingBlock: boolean; + /** Index of the active thinking content block (null when not thinking) */ + thinkingBlockIndex: number | null; + /** Whether a text generation block has been seen in this turn */ + hasTextStarted: boolean; + /** Maps content block index → tool_use id for routing input_json_delta */ + toolBlockIndexToId: Map; +} + +export function createBridgeState(sessionId: string): BridgeState { + return { + sessionId, + accumulatedText: '', + activeTools: new Map(), + totalCost: 0, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + totalCost: 0, + }, + toolResults: new Map(), + pendingApprovals: [], + model: '', + turnCount: 0, + currentTurnToolCount: 0, + inThinkingBlock: false, + thinkingBlockIndex: null, + hasTextStarted: false, + toolBlockIndexToId: new Map(), + }; +} + +// --------------------------------------------------------------------------- +// Event helpers +// --------------------------------------------------------------------------- + +function ev( + type: GatewayEvent['type'], + payload?: Record, + sessionId?: string, +): GatewayEvent { + return { type, payload: payload as GatewayEvent['payload'], session_id: sessionId } as GatewayEvent; +} + +export function resetTurnState(state: BridgeState): void { + state.accumulatedText = ''; + state.activeTools.clear(); + state.currentTurnToolCount = 0; + state.pendingApprovals = []; + state.inThinkingBlock = false; + state.thinkingBlockIndex = null; + state.hasTextStarted = false; + state.toolBlockIndexToId.clear(); + state.usage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + totalCost: 0, + }; +} + +// --------------------------------------------------------------------------- +// Main bridge function +// --------------------------------------------------------------------------- + +/** + * Translate a single QueryMessage into zero or more GatewayEvents. + * + * Maintains streaming state in `state` across calls. The caller should iterate + * the query() AsyncGenerator and call this function for each yielded message. + */ +export function bridgeQueryToGateway( + msg: QueryMessage, + state: BridgeState, +): GatewayEvent[] { + const events: GatewayEvent[] = []; + const sid = state.sessionId; + + switch (msg.type) { + // ── Stream events (incremental) ────────────────────────────────── + case 'stream_event': + events.push(...handleStreamEvent(msg.event, state, sid)); + break; + + // ── Assistant message (turn complete) ─────────────────────────── + case 'assistant': + events.push(...handleAssistantMessage(msg.message, state, sid)); + break; + + // ── User message (tool results injected back) ──────────────────── + case 'user': + // Store tool results so handleToolProgress('completed') can read them + if (Array.isArray(msg.message.content)) { + for (const block of msg.message.content) { + if (block.type === 'tool_result' && block.tool_use_id) { + const text = typeof block.content === 'string' + ? block.content + : (block.content ? JSON.stringify(block.content) : ''); + state.toolResults.set(block.tool_use_id, block.is_error ? `Error: ${text}` : text); + } + } + } + break; + + // ── System: progress (tool execution lifecycle) ────────────────── + case 'system': + switch (msg.subtype) { + case 'progress': + events.push(...handleToolProgress(msg.data, state, sid)); + break; + + case 'compact_boundary': + events.push(...handleCompactBoundary(msg.compactMetadata, sid)); + break; + + case 'error': + events.push(...handleSystemError(msg.error, sid)); + break; + + case 'permission_required': + events.push(...handlePermissionRequired(msg.deferred, state, sid)); + break; + } + break; + } + + return events; +} + +// --------------------------------------------------------------------------- +// Stream event handlers +// --------------------------------------------------------------------------- + +function handleStreamEvent( + event: StreamEvent, + state: BridgeState, + sid: string, +): GatewayEvent[] { + const events: GatewayEvent[] = []; + + switch (event.type) { + // ── Message start ────────────────────────────────────────────── + case 'message_start': + state.model = event.message.model ?? ''; + state.accumulatedText = ''; + state.currentTurnToolCount = 0; + events.push(ev('message.start', undefined, sid)); + break; + + // ── Content block delta ──────────────────────────────────────── + case 'content_block_delta': { + const delta = event.delta; + + if (delta.type === 'text_delta') { + // ── Text delta: if we were thinking, transition to Generating ── + if (state.inThinkingBlock) { + state.inThinkingBlock = false; + state.thinkingBlockIndex = null; + } + if (!state.hasTextStarted) { + state.hasTextStarted = true; + events.push( + ev('status.update', { text: 'Generating…', kind: 'generating' }, sid), + ); + } + state.accumulatedText += delta.text; + events.push( + ev('message.delta', { text: delta.text }, sid), + ); + } else if (delta.type === 'input_json_delta') { + // Tool input JSON — route to tool state, not thinking + const toolId = state.toolBlockIndexToId.get(event.index); + if (toolId) { + const tool = state.activeTools.get(toolId); + if (tool) { + tool.inputJson += delta.partial_json; + } + events.push( + ev('tool.input_delta', { tool_id: toolId, partial_json: delta.partial_json }, sid), + ); + } + } else if (delta.type === 'thinking_delta') { + // Extended thinking delta — forward as thinking.delta + events.push( + ev('thinking.delta', { text: delta.thinking }, sid), + ); + } + break; + } + + // ── Content block start ──────────────────────────────────────── + case 'content_block_start': { + const block = event.content_block; + + if (block.type === 'tool_use' && block.id && block.name) { + state.currentTurnToolCount++; + state.toolBlockIndexToId.set(event.index, block.id); + state.activeTools.set(block.id, { + id: block.id, + name: block.name, + startTime: Date.now(), + status: 'started', + inputJson: block.input ? JSON.stringify(block.input) : '', + }); + + events.push( + ev('tool.start', { + tool_id: block.id, + name: block.name, + args_text: block.input ? JSON.stringify(block.input) : undefined, + context: block.input ? JSON.stringify(block.input).slice(0, 200) : undefined, + }, sid), + ); + } else if (block.type === 'thinking') { + // ── Extended thinking block: update status to Thinking… ──── + state.inThinkingBlock = true; + state.thinkingBlockIndex = event.index; + if (!state.hasTextStarted) { + events.push( + ev('status.update', { text: 'Thinking…', kind: 'thinking' }, sid), + ); + } + events.push( + ev('thinking.delta', { text: block.thinking ?? '' }, sid), + ); + } else if (block.type === 'text') { + // ── Text generation block: transition from Thinking → Generating ── + if (state.inThinkingBlock) { + state.inThinkingBlock = false; + state.thinkingBlockIndex = null; + } + if (!state.hasTextStarted) { + state.hasTextStarted = true; + events.push( + ev('status.update', { text: 'Generating…', kind: 'generating' }, sid), + ); + } + } + break; + } + + // ── Content block stop ───────────────────────────────────────── + case 'content_block_stop': + // Clean up thinking state if the stopped block was the thinking block + if (state.thinkingBlockIndex !== null && event.index === state.thinkingBlockIndex) { + state.thinkingBlockIndex = null; + } + break; + + // ── Message delta (usage updates) ─────────────────────────────── + case 'message_delta': + if (event.delta.usage) { + accumulateUsage(state.usage, event.delta.usage); + } + // stop_reason is handled in the assistant message + break; + + // ── Message stop ──────────────────────────────────────────────── + case 'message_stop': + // The assistant message (with full content) is emitted separately + // as a 'assistant' QueryMessage. Here we just accumulate usage. + if (event.message?.usage) { + accumulateUsage(state.usage, event.message.usage); + } + break; + + // ── Cost update ───────────────────────────────────────────────── + case 'cost_update': + state.totalCost = event.totalCost; + break; + + // ── Ping (no-op for TUI) ──────────────────────────────────────── + case 'ping': + break; + } + + return events; +} + +// --------------------------------------------------------------------------- +// Assistant message handler +// --------------------------------------------------------------------------- + +function handleAssistantMessage( + message: AssistantMessage, + state: BridgeState, + sid: string, +): GatewayEvent[] { + const events: GatewayEvent[] = []; + + // Accumulate final usage + accumulateUsage(state.usage, message.usage); + state.totalCost = message.usage?.totalCost ?? state.totalCost; + state.turnCount++; + + // Build tool call summary + const toolCallCount = message.toolUseBlocks?.length ?? state.currentTurnToolCount; + const toolNames = message.toolUseBlocks?.map((b) => b.name) ?? []; + + events.push( + ev('message.complete', { + text: state.accumulatedText || extractTextContent(message), + usage: { + calls: 1, + input: state.usage.inputTokens, + output: state.usage.outputTokens, + total: state.usage.inputTokens + state.usage.outputTokens, + cost_usd: state.usage.totalCost || message.usage?.totalCost, + }, + rendered: state.accumulatedText || undefined, + }, sid), + ); + + // Status update with tool summary + const statusText = toolCallCount > 0 + ? `Used ${toolCallCount} tool(s): ${toolNames.join(', ')}` + : 'Turn complete'; + + events.push( + ev('status.update', { text: statusText }, sid), + ); + + resetTurnState(state); + + return events; +} + +// --------------------------------------------------------------------------- +// Tool progress handler +// --------------------------------------------------------------------------- + +function handleToolProgress( + progress: ToolProgress, + state: BridgeState, + sid: string, +): GatewayEvent[] { + const events: GatewayEvent[] = []; + const tool = state.activeTools.get(progress.toolUseId); + + switch (progress.status) { + case 'started': + if (tool) { + tool.status = 'started'; + } else { + state.activeTools.set(progress.toolUseId, { + id: progress.toolUseId, + name: progress.toolName, + startTime: Date.now(), + status: 'started', + inputJson: '', + }); + } + events.push( + ev('status.update', { + text: `Running ${progress.toolName}...`, + kind: 'tool', + }, sid), + ); + break; + + case 'running': + if (tool) { + tool.status = 'running'; + } + events.push( + ev('status.update', { + text: progress.message ?? `Running ${progress.toolName}...`, + kind: 'tool', + }, sid), + ); + break; + + case 'completed': { + const startTime = tool?.startTime ?? Date.now(); + const duration = (Date.now() - startTime) / 1000; + + if (tool) { + tool.status = 'completed'; + } + + // Read result from progress.message (query.ts includes it now), + // fall back to toolResults Map (populated by user message handler). + const resultText = progress.message ?? state.toolResults.get(progress.toolUseId); + + // ── Determine error status (Fix 1: use structured is_error, not text heuristic) ─ + // Priority: 1) explicit is_error field from query.ts, 2) PreToolUse "Blocked:" + // prefix from hook blocking, 3) legacy text heuristic for backwards compat. + const isBlocked = resultText?.startsWith('Blocked:') ?? false; + const isError = progress.is_error ?? isBlocked ?? resultText?.startsWith('Error:') ?? false; + + // ── Fix 2: Emit error status BEFORE tool.complete so the TUI shows it ── + if (isError) { + const errorMsg = (resultText ?? '').replace(/^(?:Error|Blocked):\s*/, ''); + events.push( + ev('status.update', { + text: `Tool "${progress.toolName}" failed: ${errorMsg}`, + kind: 'error', + }, sid), + ); + } + + events.push( + ev('tool.complete', { + tool_id: progress.toolUseId, + name: progress.toolName, + duration_s: Math.round(duration * 100) / 100, + result_text: resultText?.slice(0, 500), + error: isError ? (resultText ?? 'Unknown error') : undefined, + summary: progress.message, + }, sid), + ); + + // Clean up + state.activeTools.delete(progress.toolUseId); + state.toolResults.delete(progress.toolUseId); + break; + } + } + + return events; +} + +// --------------------------------------------------------------------------- +// Compact boundary handler +// --------------------------------------------------------------------------- + +function handleCompactBoundary( + meta: CompactMetadata, + sid: string, +): GatewayEvent[] { + return [ + ev('status.update', { + text: `Compressing context (${meta.beforeTokens.toLocaleString()} → ${meta.afterTokens.toLocaleString()} tokens, ${meta.strategy})`, + kind: 'info', + }, sid), + ]; +} + +// --------------------------------------------------------------------------- +// System error handler +// --------------------------------------------------------------------------- + +function handleSystemError( + error: AgentError, + sid: string, +): GatewayEvent[] { + return [ + ev('error', { message: `${error.code}: ${error.message}` }, sid), + ev('status.update', { + text: `Error: ${error.message}`, + kind: 'error', + }, sid), + ]; +} + +// --------------------------------------------------------------------------- +// Permission required handler +// --------------------------------------------------------------------------- + +function handlePermissionRequired( + deferred: DeferredPermission, + state: BridgeState, + sid: string, +): GatewayEvent[] { + // Store for later resolution + state.pendingApprovals.push({ + toolUseId: deferred.toolUseId, + toolName: deferred.toolName, + command: deferred.command, + description: deferred.description, + deferred, + }); + + return [ + ev('approval.request', { + command: deferred.command, + description: deferred.description, + request_id: deferred.toolUseId, + tool_use_id: deferred.toolUseId, + }, sid), + ]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function accumulateUsage( + target: BridgeUsage, + source: CompletionUsage, +): void { + target.inputTokens += source.input_tokens; + target.outputTokens += source.output_tokens; + target.cacheCreationInputTokens += source.cache_creation_input_tokens ?? 0; + target.cacheReadInputTokens += source.cache_read_input_tokens ?? 0; + if (source.totalCost) { + target.totalCost += source.totalCost; + } +} + +/** + * Extract plain text content from an assistant message. + */ +function extractTextContent(message: AssistantMessage): string { + if (typeof message.content === 'string') { + return message.content; + } + if (Array.isArray(message.content)) { + return message.content + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('\n'); + } + return ''; +} + +// --------------------------------------------------------------------------- +// Approval resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a pending approval. Called by the TUI when the user approves/denies. + * + * Returns the tool name that was resolved, or null if no matching approval. + * + * **NOTE**: This function resolves ONLY the inline DeferredPermission (System A). + * In production, coder-client.ts handles approval resolution directly because it + * must also resolve via the global pendingPermissions Map (System B) via + * resolvePermission(). This function remains exported for integration tests + * where System B is not in play. + */ +export function resolveApproval( + state: BridgeState, + toolUseId: string, + allowed: boolean, +): string | null { + const idx = state.pendingApprovals.findIndex((a) => a.toolUseId === toolUseId); + if (idx === -1) return null; + + const approval = state.pendingApprovals[idx]!; + state.pendingApprovals.splice(idx, 1); + + // Resolve the deferred promise so the Agent Loop can continue + approval.deferred.resolve(allowed); + + return approval.toolName; +} diff --git a/packages/cli/src/gateway/types.ts b/packages/cli/src/gateway/types.ts new file mode 100644 index 0000000..b09994b --- /dev/null +++ b/packages/cli/src/gateway/types.ts @@ -0,0 +1,564 @@ +import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from '../types.js' + +export interface GatewaySkin { + banner_hero?: string + banner_logo?: string + branding?: Record + colors?: Record + help_header?: string + tool_prefix?: string +} + +export interface GatewayCompletionItem { + display: string + meta?: string + text: string +} + +export interface GatewayTranscriptMessage { + context?: string + name?: string + role: 'assistant' | 'system' | 'tool' | 'user' + text?: string +} + +// ── Commands / completion ──────────────────────────────────────────── + +export interface CommandsCatalogResponse { + canon?: Record + categories?: SlashCategory[] + pairs?: [string, string][] + skill_count?: number + sub?: Record + warning?: string +} + +export interface CompletionResponse { + items?: GatewayCompletionItem[] + replace_from?: number +} + +export interface SlashExecResponse { + output?: string + warning?: string +} + +export type CommandDispatchResponse = + | { output?: string; type: 'exec' | 'plugin' } + | { target: string; type: 'alias' } + | { message?: string; name: string; type: 'skill' } + | { message: string; notice?: string; type: 'send' } + +// ── Config ─────────────────────────────────────────────────────────── + +export interface ConfigDisplayConfig { + bell_on_complete?: boolean + busy_input_mode?: string + details_mode?: string + inline_diffs?: boolean + mouse_tracking?: boolean | null | number | string + sections?: Record + show_cost?: boolean + show_reasoning?: boolean + streaming?: boolean + thinking_mode?: string + tui_auto_resume_recent?: boolean + tui_compact?: boolean + /** Legacy alias for display.mouse_tracking. */ + tui_mouse?: boolean | null | number | string + // Forward-compat: backend may send styles this client doesn't know yet — + // `normalizeIndicatorStyle` falls back to 'kaomoji' for those — but the + // wire type is documented as `string` so consumers don't get a false + // narrowing-and-autocomplete contract on a value that requires runtime + // validation anyway. + tui_status_indicator?: string + tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean +} + +export interface ConfigVoiceConfig { + // Raw `yaml.safe_load()` value from config; may be non-string if hand-edited. + // Callers must normalize/validate at runtime (parseVoiceRecordKey()). + record_key?: unknown +} + +export interface ConfigFullResponse { + config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig; paste_collapse_threshold?: number; paste_collapse_char_threshold?: number } +} + +export interface ConfigMtimeResponse { + mtime?: number +} + +export interface ConfigGetValueResponse { + display?: string + home?: string + value?: string +} + +export interface ConfigSetResponse { + credential_warning?: string + history_reset?: boolean + info?: SessionInfo + value?: string + warning?: string +} + +export interface SetupStatusResponse { + provider_configured?: boolean +} + +// ── Session lifecycle ──────────────────────────────────────────────── + +export interface SessionCreateResponse { + info?: SessionInfo & { config_warning?: string; credential_warning?: string } + session_id: string +} + +export interface SessionResumeResponse { + info?: SessionInfo + message_count?: number + messages: GatewayTranscriptMessage[] + resumed?: string + session_id: string +} + +export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working' + +export interface SessionActiveItem { + current?: boolean + id: string + last_active?: number + message_count?: number + model?: string + preview?: string + session_key?: string + started_at?: number + status: LiveSessionStatus + title?: string +} + +export interface SessionActiveListResponse { + sessions?: SessionActiveItem[] +} + +export interface SessionInflightTurn { + assistant?: string + streaming?: boolean + user?: string +} + +export interface SessionActivateResponse { + inflight?: null | SessionInflightTurn + info?: SessionInfo + message_count?: number + messages: GatewayTranscriptMessage[] + running?: boolean + session_id: string + session_key?: string + started_at?: number + status?: LiveSessionStatus +} + +export interface SessionListItem { + id: string + message_count: number + preview: string + source?: string + started_at: number + title: string +} + +export interface SessionListResponse { + sessions?: SessionListItem[] +} + +export interface SessionDeleteResponse { + deleted: string +} + +export interface SessionMostRecentResponse { + session_id?: null | string + source?: string + started_at?: number + title?: string +} + +export interface SessionTitleResponse { + pending?: boolean + session_key?: string + title?: string +} + +export interface SessionSaveResponse { + file?: string +} + +export interface SessionUndoResponse { + removed?: number +} + +export interface SessionUsageResponse { + cache_read?: number + cache_write?: number + calls?: number + compressions?: number + context_max?: number + context_percent?: number + context_used?: number + cost_status?: 'estimated' | 'exact' + cost_usd?: number + input?: number + model?: string + output?: number + total?: number +} + +export interface SessionStatusResponse { + output?: string +} + +export interface SessionCompressResponse { + after_messages?: number + after_tokens?: number + before_messages?: number + before_tokens?: number + info?: SessionInfo + messages?: GatewayTranscriptMessage[] + removed?: number + summary?: { + headline?: string + noop?: boolean + note?: null | string + token_line?: string + } + usage?: Usage +} + +export interface SessionBranchResponse { + session_id?: string + title?: string +} + +export interface SessionCloseResponse { + closed?: boolean + ok?: boolean +} + +export interface SessionInterruptResponse { + ok?: boolean +} + +export interface SessionSteerResponse { + status?: 'queued' | 'rejected' + text?: string +} + +// ── Prompt / submission ────────────────────────────────────────────── + +export interface PromptSubmitResponse { + ok?: boolean +} + +export interface BackgroundStartResponse { + task_id?: string +} + +export interface ClarifyRespondResponse { + ok?: boolean +} + +export interface ApprovalRespondResponse { + ok?: boolean +} + +export interface SudoRespondResponse { + ok?: boolean +} + +export interface SecretRespondResponse { + ok?: boolean +} + +// ── Shell / clipboard / input ──────────────────────────────────────── + +export interface ShellExecResponse { + code: number + stderr?: string + stdout?: string +} + +export interface ClipboardPasteResponse { + attached?: boolean + count?: number + height?: number + message?: string + token_estimate?: number + width?: number +} + +export interface InputDetectDropResponse { + height?: number + is_image?: boolean + matched?: boolean + name?: string + text?: string + token_estimate?: number + width?: number +} + +export interface TerminalResizeResponse { + ok?: boolean +} + +// ── Image attach ───────────────────────────────────────────────────── + +export interface ImageAttachResponse { + height?: number + name?: string + remainder?: string + token_estimate?: number + width?: number +} + +// ── Voice ──────────────────────────────────────────────────────────── + +export interface VoiceToggleResponse { + audio_available?: boolean + available?: boolean + details?: string + enabled?: boolean + record_key?: string + stt_available?: boolean + tts?: boolean +} + +export interface VoiceRecordResponse { + status?: 'busy' | 'recording' | 'stopped' + text?: string +} + +// ── Tools (TS keeps configure since it resets local history) ───────── + +export interface ToolsConfigureResponse { + changed?: string[] + enabled_toolsets?: string[] + info?: SessionInfo + missing_servers?: string[] + reset?: boolean + unknown?: string[] +} + +// ── Model picker ───────────────────────────────────────────────────── + +export interface ModelOptionProvider { + auth_type?: string + authenticated?: boolean + is_current?: boolean + key_env?: string + models?: string[] + name: string + proxy?: string | null + slug: string + total_models?: number + warning?: string +} + +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +// ── MCP ────────────────────────────────────────────────────────────── + +export interface ReloadMcpResponse { + status?: string + message?: string +} + +export interface ReloadEnvResponse { + updated?: number +} + +export interface ProcessStopResponse { + killed?: number +} + +export interface BrowserManageResponse { + connected?: boolean + messages?: string[] + url?: string +} + +export interface RollbackCheckpoint { + hash: string + message?: string + timestamp?: string +} + +export interface RollbackListResponse { + checkpoints?: RollbackCheckpoint[] + enabled?: boolean +} + +export interface RollbackDiffResponse { + diff?: string + rendered?: string + stat?: string +} + +export interface RollbackRestoreResponse { + error?: string + history_removed?: number + message?: string + reason?: string + restored_to?: string + success?: boolean +} + +// ── Subagent events ────────────────────────────────────────────────── + +export interface SubagentEventPayload { + api_calls?: number + cost_usd?: number + depth?: number + duration_seconds?: number + files_read?: string[] + files_written?: string[] + goal: string + input_tokens?: number + iteration?: number + model?: string + output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] + output_tokens?: number + parent_id?: null | string + reasoning_tokens?: number + status?: SubagentStatus + subagent_id?: string + summary?: string + task_count?: number + task_index: number + text?: string + tool_count?: number + tool_name?: string + tool_preview?: string + toolsets?: string[] +} + +// ── Delegation control RPCs ────────────────────────────────────────── + +export interface DelegationStatusResponse { + active?: { + depth?: number + goal?: string + model?: null | string + parent_id?: null | string + started_at?: number + status?: string + subagent_id?: string + tool_count?: number + }[] + max_concurrent_children?: number + max_spawn_depth?: number + paused?: boolean +} + +export interface DelegationPauseResponse { + paused?: boolean +} + +export interface SubagentInterruptResponse { + found?: boolean + subagent_id?: string +} + +// ── Spawn-tree snapshots ───────────────────────────────────────────── + +export interface SpawnTreeListEntry { + count: number + finished_at?: number + label?: string + path: string + session_id?: string + started_at?: number | null +} + +export interface SpawnTreeListResponse { + entries?: SpawnTreeListEntry[] +} + +export interface SpawnTreeLoadResponse { + finished_at?: number + label?: string + session_id?: string + started_at?: null | number + subagents?: unknown[] +} + +export type GatewayEvent = + | { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' } + | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } + | { payload: SessionInfo; session_id?: string; type: 'session.info' } + | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload: { partial_json?: string; tool_id: string }; session_id?: string; type: 'tool.input_delta' } + | { payload?: undefined; session_id?: string; type: 'message.start' } + | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } + | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } + | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } + | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } + | { + payload?: { level?: 'info' | 'warn' | 'error'; message?: string } + session_id?: string + type: 'browser.progress' + } + | { + payload?: { cwd?: string; python?: string; stderr_tail?: string } + session_id?: string + type: 'gateway.start_timeout' + } + | { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' } + | { payload?: { text?: string; verbose?: boolean }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } + | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } + | { payload: { name?: string }; session_id?: string; type: 'tool.generating' } + | { + payload: { args_text?: string; context?: string; name?: string; tool_id: string; todos?: unknown[] } + session_id?: string + type: 'tool.start' + } + | { + payload: { + duration_s?: number + error?: string + inline_diff?: string + name?: string + result_text?: string + summary?: string + tool_id: string + todos?: unknown[] + } + session_id?: string + type: 'tool.complete' + } + | { + payload: { choices: string[] | null; question: string; request_id: string } + session_id?: string + type: 'clarify.request' + } + | { payload: { command: string; description: string; request_id?: string; tool_use_id?: string }; session_id?: string; type: 'approval.request' } + | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } + | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } + | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } + | { payload?: { text?: string }; session_id?: string; type: 'review.summary' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' } + | { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' } + | { + payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage } + session_id?: string + type: 'message.complete' + } + | { payload?: { message?: string }; session_id?: string; type: 'error' } diff --git a/packages/cli/src/hooks/useCompletion.ts b/packages/cli/src/hooks/useCompletion.ts new file mode 100644 index 0000000..a626136 --- /dev/null +++ b/packages/cli/src/hooks/useCompletion.ts @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react' + +import type { CompletionItem } from '../app/interfaces.js' +import { looksLikeSlashCommand } from '../domain/slash.js' +import type { IGatewayClient } from '../gateway/client.js' +import type { CompletionResponse } from '../gateway/types.js' +import { asRpcResult } from '../lib/rpc.js' + +const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ + +export function completionRequestForInput( + input: string +): + | { method: 'complete.path'; params: { word: string }; replaceFrom: number } + | { method: 'complete.slash'; params: { text: string }; replaceFrom: number } + | null { + const isSlashCommand = looksLikeSlashCommand(input) + const pathWord = isSlashCommand ? null : (input.match(TAB_PATH_RE)?.[1] ?? null) + + if (!isSlashCommand && !pathWord) { + return null + } + + // `/model` uses the two-step ModelPicker (real curated IDs). + // Slash completion here only showed short aliases + vendor/family meta. + if (isSlashCommand && /^\/model(?:\s|$)/.test(input)) { + return null + } + + if (isSlashCommand) { + return { method: 'complete.slash', params: { text: input }, replaceFrom: 1 } + } + + return { + method: 'complete.path', + params: { word: pathWord! }, + replaceFrom: input.length - pathWord!.length + } +} + +export function useCompletion(input: string, blocked: boolean, gw: IGatewayClient) { + const [completions, setCompletions] = useState([]) + const [compIdx, setCompIdx] = useState(0) + const [compReplace, setCompReplace] = useState(0) + const ref = useRef('') + + useEffect(() => { + const clear = () => { + setCompletions(prev => (prev.length ? [] : prev)) + setCompIdx(prev => (prev ? 0 : prev)) + setCompReplace(prev => (prev ? 0 : prev)) + } + + if (blocked) { + ref.current = '' + clear() + + return + } + + if (input === ref.current) { + return + } + + ref.current = input + + const request = completionRequestForInput(input) + if (!request) { + clear() + + return + } + + const t = setTimeout(() => { + if (ref.current !== input) { + return + } + + gw.request(request.method, request.params) + .then(raw => { + if (ref.current !== input) { + return + } + + const r = asRpcResult(raw) + + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom) + }) + .catch((e: unknown) => { + if (ref.current !== input) { + return + } + + setCompletions([ + { + text: '', + display: 'completion unavailable', + meta: e instanceof Error && e.message ? e.message : 'unavailable' + } + ]) + setCompIdx(0) + setCompReplace(request.replaceFrom) + }) + }, 60) + + return () => clearTimeout(t) + }, [blocked, gw, input]) + + return { completions, compIdx, setCompIdx, compReplace } +} diff --git a/packages/cli/src/hooks/useGitBranch.ts b/packages/cli/src/hooks/useGitBranch.ts new file mode 100644 index 0000000..7eb4880 --- /dev/null +++ b/packages/cli/src/hooks/useGitBranch.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' + +import { useEffect, useState } from 'react' + +const TTL_MS = 15_000 +const TIMEOUT_MS = 500 + +const pexec = promisify(execFile) +const cache = new Map() +const inflight = new Map>() + +const resolveBranch = async (cwd: string): Promise => { + try { + const { stdout } = await pexec('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: TIMEOUT_MS }) + const b = stdout.trim() + + return !b || b === 'HEAD' ? null : b + } catch { + return null + } +} + +const fetchBranch = (cwd: string): Promise => { + const pending = inflight.get(cwd) + + if (pending) { + return pending + } + + const p = resolveBranch(cwd).finally(() => inflight.delete(cwd)) + inflight.set(cwd, p) + + return p +} + +export function useGitBranch(cwd: string): null | string { + const [branch, setBranch] = useState(() => cache.get(cwd)?.branch ?? null) + + useEffect(() => { + let cancelled = false + + const tick = async () => { + const hit = cache.get(cwd) + + if (hit && Date.now() - hit.at < TTL_MS) { + if (!cancelled) { + setBranch(hit.branch) + } + + return + } + + const b = await fetchBranch(cwd) + cache.set(cwd, { at: Date.now(), branch: b }) + + if (!cancelled) { + setBranch(b) + } + } + + void tick() + const id = setInterval(() => void tick(), TTL_MS) + + return () => { + cancelled = true + clearInterval(id) + } + }, [cwd]) + + return branch +} diff --git a/packages/cli/src/hooks/useInputHistory.ts b/packages/cli/src/hooks/useInputHistory.ts new file mode 100644 index 0000000..8192b86 --- /dev/null +++ b/packages/cli/src/hooks/useInputHistory.ts @@ -0,0 +1,11 @@ +import { useRef, useState } from 'react' + +import * as inputHistory from '../lib/history.js' + +export function useInputHistory() { + const historyRef = useRef(inputHistory.load()) + const [historyIdx, setHistoryIdx] = useState(null) + const historyDraftRef = useRef('') + + return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append } +} diff --git a/packages/cli/src/hooks/useQueue.ts b/packages/cli/src/hooks/useQueue.ts new file mode 100644 index 0000000..0c79ab4 --- /dev/null +++ b/packages/cli/src/hooks/useQueue.ts @@ -0,0 +1,76 @@ +import { useCallback, useRef, useState } from 'react' + +// Mutates `arr` in place; returned reference is the same input array, kept +// so callers can chain. Use `Array.prototype.toSpliced` if you need a copy. +export function removeAtInPlace(arr: T[], i: number): T[] { + if (i < 0 || i >= arr.length) { + return arr + } + + arr.splice(i, 1) + + return arr +} + +export function useQueue() { + const queueRef = useRef([]) + const [queuedDisplay, setQueuedDisplay] = useState([]) + const queueEditRef = useRef(null) + const [queueEditIdx, setQueueEditIdx] = useState(null) + + const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), []) + + const setQueueEdit = useCallback((idx: number | null) => { + queueEditRef.current = idx + setQueueEditIdx(idx) + }, []) + + const enqueue = useCallback( + (text: string) => { + queueRef.current.push(text) + syncQueue() + }, + [syncQueue] + ) + + const dequeue = useCallback(() => { + const head = queueRef.current.shift() + syncQueue() + + return head + }, [syncQueue]) + + const replaceQ = useCallback( + (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + }, + [syncQueue] + ) + + const removeQ = useCallback( + (i: number) => { + const before = queueRef.current.length + + removeAtInPlace(queueRef.current, i) + + if (queueRef.current.length !== before) { + syncQueue() + } + }, + [syncQueue] + ) + + return { + dequeue, + enqueue, + queueEditIdx, + queueEditRef, + queueRef, + queuedDisplay, + removeQ, + replaceQ, + setQueueEdit, + syncQueue + } +} diff --git a/packages/cli/src/hooks/useVirtualHistory.ts b/packages/cli/src/hooks/useVirtualHistory.ts new file mode 100644 index 0000000..2e11311 --- /dev/null +++ b/packages/cli/src/hooks/useVirtualHistory.ts @@ -0,0 +1,554 @@ +import type { ScrollBoxHandle } from '@coder/tui' +import { + type RefObject, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore +} from 'react' + +const ESTIMATE = 4 +// Overscan was 40 (= viewport) which is way more than needed when heights +// are well-estimated. Cutting in half saves ~20 mounted items per scroll +// edge → smaller fiber tree → less buffer-compose work per frame. HN/CC +// dev (https://news.ycombinator.com/item?id=46699072) confirmed GC pressure +// from large JSX trees was their main perf issue post-rewrite. +const OVERSCAN = 20 +// Hard cap on mounted items. Was 260; profiling showed ~23k live Yoga +// nodes during sustained PageUp catch-up (renderer p99=106ms). The +// viewport+2*overscan = 80 rows of needed coverage = ~25 items at avg 3 +// rows/item, so 120 leaves >4× headroom and never blanks the viewport +// even when items are tiny. +const MAX_MOUNTED = 120 +const COLD_START = 30 +// Floor on unmeasured row height used when computing coverage — guarantees +// the mounted span physically reaches the viewport bottom regardless of how +// small items actually are (at the cost of over-mounting when items are +// larger; overscan absorbs that). +const PESSIMISTIC = 1 +// Tightest safe scrollTop bin for the useSyncExternalStore snapshot. Small +// wheel ticks that don't cross a bin short-circuit React's commit entirely; +// Ink keeps painting via ScrollBox.forceRender + direct scrollTop reads. +// Half of OVERSCAN keeps ≥20 rows of cushion before the mounted range +// would actually need to shift. +const QUANTUM = OVERSCAN >> 1 +// Renders to keep the mount range frozen after width change (heights scaled +// but not yet re-measured). Render #1 skips measurement so pre-resize Yoga +// doesn't poison the scaled cache; render #2's useLayoutEffect captures +// post-resize heights; render #3 recomputes range with accurate data. +const FREEZE_RENDERS = 2 +// Cap on NEW items mounted per commit when scrolling fast. Without this, +// a single PageUp into unmeasured territory mounts ~190 rows with +// PESSIMISTIC=1 coverage — each row running marked lexer + syntax +// highlighting for ~3ms = ~600ms sync block. Sliding toward the target +// over several commits keeps per-commit mount cost bounded. Tightened +// from 25 → 12: each new item adds ~100 fibers / Yoga nodes, and a +// 25-item commit was the dominant contributor to the 100ms+ p99 frames. +const SLIDE_STEP = 12 + +const NOOP = () => {} + +export const virtualHistorySnapshotKey = (s?: ScrollBoxHandle | null): string => { + if (!s) { + return 'none' + } + + const target = s.getScrollTop() + s.getPendingDelta() + const bin = Math.floor(target / QUANTUM) + const viewportHeight = Math.max(0, s.getViewportHeight()) + + return `${s.isSticky() ? ~bin : bin}:${viewportHeight}` +} + +const upperBound = (arr: ArrayLike, target: number, length = arr.length) => { + let lo = 0 + let hi = length + + while (lo < hi) { + const mid = (lo + hi) >> 1 + + arr[mid]! <= target ? (lo = mid + 1) : (hi = mid) + } + + return lo +} + +export const shouldSetVirtualClamp = ({ + itemCount, + liveTailActive = false, + sticky, + viewportHeight +}: { + itemCount: number + liveTailActive?: boolean + sticky: boolean + viewportHeight: number +}) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive + +export const ensureVirtualItemHeight = ( + heights: Map, + key: string, + index: number, + estimate: number, + estimateHeight?: (index: number, key: string) => number +) => { + const cached = heights.get(key) + + if (cached !== undefined) { + return Math.max(1, Math.floor(cached)) + } + + const seeded = Math.max(1, Math.floor(estimateHeight?.(index, key) ?? estimate)) + heights.set(key, seeded) + + return seeded +} + +export function useVirtualHistory( + scrollRef: RefObject, + items: readonly { key: string }[], + columns: number, + { + estimate = ESTIMATE, + estimateHeight, + initialHeights, + liveTailActive = false, + onHeightsChange, + overscan = OVERSCAN, + maxMounted = MAX_MOUNTED, + coldStartCount = COLD_START + }: VirtualHistoryOptions = {} +) { + const nodes = useRef(new Map()) + const heights = useRef(new Map(initialHeights)) + const initialHeightsRef = useRef(initialHeights) + const refs = useRef(new Map void>()) + const onHeightsChangeRef = useRef(onHeightsChange) + // Bump whenever heightCache mutates so offsets rebuild on next read. + // Ref (not state) — checked during render phase, zero extra commits. + const offsetVersion = useRef(0) + + // Cached offsets: reused Float64Array keyed on (itemCount, version) so we + // only rebuild when something actually changed. Previous approach allocated + // a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC + // pressure during streaming. + const offsetsCache = useRef<{ arr: Float64Array; n: number; version: number }>({ + arr: new Float64Array(0), + n: -1, + version: -1 + }) + + const [hasScrollRef, setHasScrollRef] = useState(false) + // Height cache writes happen in layout effects; bump once so offsets and + // clamp bounds rebuild without waiting for the next scroll/input event. + const [measuredHeightVersion, bumpMeasuredHeightVersion] = useState(0) + const metrics = useRef({ sticky: true, top: 0, vp: 0 }) + const lastScrollTopRef = useRef(0) + + // Width change: scale cached heights by oldCols/newCols instead of clearing + // (clearing forces a pessimistic back-walk mounting ~190 rows at once, each + // a fresh marked.lexer + syntax highlight ≈ 3ms). Freeze the mount range + // for 2 renders so warm memos survive; skip one measurement pass so + // useLayoutEffect doesn't poison the scaled cache with pre-resize Yoga + // heights. + const prevColumns = useRef(columns) + const skipMeasurement = useRef(false) + const prevRange = useRef(null) + const freezeRenders = useRef(0) + + onHeightsChangeRef.current = onHeightsChange + + if (initialHeightsRef.current !== initialHeights) { + initialHeightsRef.current = initialHeights + heights.current = new Map(initialHeights) + offsetVersion.current++ + } + + if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) { + const ratio = prevColumns.current / columns + + prevColumns.current = columns + + for (const [k, h] of heights.current) { + heights.current.set(k, Math.max(1, Math.round(h * ratio))) + } + + offsetVersion.current++ + skipMeasurement.current = true + freezeRenders.current = FREEZE_RENDERS + } + + useLayoutEffect(() => { + setHasScrollRef(Boolean(scrollRef.current)) + }, [scrollRef]) + + // Quantized snapshot: same-bin scrolls (most wheel ticks) produce the same + // key → React.Object.is short-circuits the commit entirely. The key includes + // sticky state, target scroll position, and viewport height so resize-only + // changes still recompute the mounted transcript window. + const subscribe = useCallback( + (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP, + [hasScrollRef, scrollRef] + ) + + useSyncExternalStore( + subscribe, + () => virtualHistorySnapshotKey(scrollRef.current), + () => 'none' + ) + + useEffect(() => { + const keep = new Set(items.map(i => i.key)) + let dirty = false + + for (const k of heights.current.keys()) { + if (!keep.has(k)) { + heights.current.delete(k) + nodes.current.delete(k) + refs.current.delete(k) + dirty = true + } + } + + if (dirty) { + offsetVersion.current++ + } + }, [items]) + + // Offsets: Float64Array reused across renders, invalidated by offsetVersion + // bumps from heightCache writers (measureRef, resize-scale, GC). Binary + // search tolerates either monotone source, so no need to rebuild unless + // something changed. + const n = items.length + + if (offsetsCache.current.version !== offsetVersion.current || offsetsCache.current.n !== n) { + const arr = offsetsCache.current.arr.length >= n + 1 ? offsetsCache.current.arr : new Float64Array(n + 1) + + arr[0] = 0 + + for (let i = 0; i < n; i++) { + arr[i + 1] = arr[i]! + ensureVirtualItemHeight(heights.current, items[i]!.key, i, estimate, estimateHeight) + } + + offsetsCache.current = { arr, n, version: offsetVersion.current } + } + + const offsets = offsetsCache.current.arr + const total = offsets[n] ?? 0 + const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0) + const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0 + const target = Math.max(0, top + pendingDelta) + const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0) + const sticky = scrollRef.current?.isSticky() ?? true + const recentManual = Date.now() - (scrollRef.current?.getLastManualScrollAt() ?? 0) < 1200 + + // During a freeze, drop the frozen range if items shrank past its start + // (/clear, compaction) — clamping would collapse to an empty mount and + // flash blank. Fall through to the normal path in that case. + const frozenRangeCandidate = + freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n + ? ([prevRange.current[0], Math.min(prevRange.current[1], n)] as const) + : null + + // Width grows can shrink wrapped rows enough that the old tail window no + // longer covers the viewport. In that case freezing preserves stale spacers + // and visually cuts off the last message, so recompute immediately. + const frozenRange = (() => { + if (!frozenRangeCandidate || vp <= 0) { + return frozenRangeCandidate + } + + const visibleTop = sticky && !recentManual ? Math.max(0, total - vp) : target + const visibleBottom = visibleTop + vp + const rangeTop = offsets[frozenRangeCandidate[0]] ?? 0 + const rangeBottom = offsets[frozenRangeCandidate[1]] ?? total + + return rangeTop <= visibleTop && rangeBottom >= visibleBottom ? frozenRangeCandidate : null + })() + + let start = 0 + let end = n + + if (frozenRange) { + start = frozenRange[0] + end = Math.min(frozenRange[1], n) + } else if (n > 0) { + if (vp <= 0) { + start = Math.max(0, n - coldStartCount) + } else if (sticky && !recentManual) { + const budget = vp + overscan + start = n + + while (start > 0 && total - offsets[start - 1]! < budget) { + start-- + } + } else { + // User scrolled up. Span [committed..target] so every drain frame is + // covered. Claude-code caps the span at 3×viewport so pendingDelta + // growing unbounded (MX Master free-spin) doesn't blow the mount + // budget; the clamp (setClampBounds) shows edge-of-mounted content + // during catch-up. + const MAX_SPAN = vp * 3 + const rawLo = Math.min(top, target) + const rawHi = Math.max(top, target) + const span = rawHi - rawLo + const clampedLo = span > MAX_SPAN ? (pendingDelta < 0 ? rawHi - MAX_SPAN : rawLo) : rawLo + const clampedHi = clampedLo + Math.min(span, MAX_SPAN) + const lo = Math.max(0, clampedLo - overscan) + const hi = clampedHi + vp + overscan + + // Binary search — offsets is monotone. Linear walk was O(n) at n=10k+, + // ~2ms per render during scroll. + start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo, n + 1) - 1)) + end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi, n + 1))) + } + } + + if (end - start > maxMounted) { + sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(n, start + maxMounted)) + } + + // Coverage guarantee: ensure sum(real or pessimistic heights) ≥ + // viewportH + 2*overscan so the viewport is physically covered even when + // items are tiny. Pessimistic because uncached items use a floor of 1 — + // over-mounts when items are large, never leaves blank spacer showing. + if (n > 0 && vp > 0 && !frozenRange) { + const needed = vp + 2 * overscan + let coverage = 0 + + for (let i = start; i < end; i++) { + coverage += ensureVirtualItemHeight(heights.current, items[i]!.key, i, PESSIMISTIC, estimateHeight) + } + + if (sticky) { + const minStart = Math.max(0, end - maxMounted) + + while (start > minStart && coverage < needed) { + start-- + coverage += ensureVirtualItemHeight(heights.current, items[start]!.key, start, PESSIMISTIC, estimateHeight) + } + } else { + const maxEnd = Math.min(n, start + maxMounted) + + while (end < maxEnd && coverage < needed) { + coverage += ensureVirtualItemHeight(heights.current, items[end]!.key, end, PESSIMISTIC, estimateHeight) + end++ + } + } + } + + // Slide cap: limit how many NEW items mount this commit. Gates on scroll + // VELOCITY (|scrollTop delta since last commit| + |pendingDelta| > + // 2×viewport — key-repeat PageUp moves ~viewport/2 per press). Covers + // both scrollBy (pendingDelta) and scrollTo (direct write). Normal single + // PageUp skips this; the clamp holds the viewport at the mounted edge + // during catch-up so there's no blank screen. Only caps range GROWTH; + // shrinking is unbounded. + if (!frozenRange && prevRange.current && vp > 0) { + const velocity = Math.abs(top - lastScrollTopRef.current) + Math.abs(pendingDelta) + + if (velocity > vp * 2) { + const [pS, pE] = prevRange.current + + start = Math.max(start, pS - SLIDE_STEP) + end = Math.min(end, pE + SLIDE_STEP) + + // A large jump past the capped end can invert (start > end); mount + // SLIDE_STEP items from the new start so the viewport isn't blank + // during catch-up. + if (start > end) { + end = Math.min(start + SLIDE_STEP, n) + } + } + } + + lastScrollTopRef.current = top + + if (freezeRenders.current > 0) { + freezeRenders.current-- + } else { + prevRange.current = [start, end] + } + + // Time-slice range growth via useDeferredValue. Urgent render keeps Ink + // painting with the OLD range (all memo hits, fast); deferred render + // transitions to the NEW range (fresh mounts: Md, syntax highlight) in a + // non-blocking background commit. The clamp (setClampBounds) pins the + // viewport to the mounted edge so there's no visual artifact from the + // deferred range lagging briefly. Only deferral range GROWTH — shrinking + // is cheap (unmount = remove fiber, no parse). + const dStart = useDeferredValue(start) + const dEnd = useDeferredValue(end) + let effStart = start < dStart ? dStart : start + let effEnd = end > dEnd ? dEnd : end + + // Inverted range (large jump with deferred value lagging) or sticky snap + // (scrollToBottom needs the tail mounted NOW so maxScroll lands on content, + // not bottomSpacer) — skip deferral. + if (effStart > effEnd || sticky) { + effStart = start + effEnd = end + } + + // Scrolling DOWN — bypass effEnd deferral so the tail mounts immediately. + // Without this, the clamp holds scrollTop short of the real bottom and + // the user feels "stuck before bottom". effStart stays deferred so scroll- + // UP keeps time-slicing (older messages parse on mount). + if (pendingDelta > 0) { + effEnd = end + } + + // Final O(viewport) enforcement. Deferred+bypass combinations above can + // leak: during sustained PageUp, concurrent mode interleaves dStart updates + // with effEnd=end bypasses across commits and the effective window drifts + // wider than either bound alone. Trim the far edge by viewport position + // (not pendingDelta direction — that flips mid-settle under concurrent + // scheduling and yanks scrollTop). + if (effEnd - effStart > maxMounted && vp > 0) { + const mid = (offsets[effStart]! + offsets[effEnd]!) / 2 + + if (top < mid) { + effEnd = effStart + maxMounted + } else { + effStart = effEnd - maxMounted + } + } + + const measureRef = useCallback((key: string) => { + let fn = refs.current.get(key) + + if (!fn) { + fn = (el: unknown) => { + if (el) { + nodes.current.set(key, el) + + return + } + + // Measure-at-unmount: the yogaNode is still valid here (reconciler + // calls ref(null) before removeChild → freeRecursive), so we grab + // the final height before WASM release. Without this, items + // scrolled out during fast pan keep a stale estimate in heightCache + // and offset math drifts until the next mount/remount cycle. + const existing = nodes.current.get(key) as MeasuredNode | undefined + const h = Math.ceil(existing?.yogaNode?.getComputedHeight?.() ?? 0) + + if (h > 0 && heights.current.get(key) !== h) { + heights.current.set(key, h) + offsetVersion.current++ + onHeightsChangeRef.current?.(heights.current) + } + + nodes.current.delete(key) + } + + refs.current.set(key, fn) + } + + return fn + }, []) + + useLayoutEffect(() => { + const s = scrollRef.current + let dirty = false + let heightDirty = false + + // Give the renderer the mounted-row coverage for passive scroll clamping. + // Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one. + // During fast scroll, immediate [start,end] may already cover the new + // scrollTop position, but children still render at the deferred range. + // If clamp used immediate bounds, render-node-to-output's drain-gate + // would drain past the deferred children's span → viewport lands in + // spacer → white flash. + if (s && shouldSetVirtualClamp({ itemCount: n, liveTailActive, sticky, viewportHeight: vp })) { + const effTopSpacer = offsets[effStart] ?? 0 + const effBottom = offsets[effEnd] ?? total + // At effEnd=n there's no bottomSpacer — use Infinity so render-node- + // to-output's own Math.min(cur, maxScroll) governs. Using offsets[n] + // here would bake in heightCache (one render behind Yoga), and during + // streaming the tail item's cached height lags its real height — + // sticky-break would then clamp below the real max and push + // streaming text off-viewport. + const clampMin = effStart === 0 ? 0 : effTopSpacer + const clampMax = effEnd === n ? Infinity : Math.max(effTopSpacer, effBottom - vp) + + s.setClampBounds(clampMin, clampMax) + } else { + s!.setClampBounds(undefined as unknown as number, undefined as unknown as number) + } + + if (skipMeasurement.current) { + skipMeasurement.current = false + bumpMeasuredHeightVersion(n => n + 1) + } else { + for (let i = effStart; i < effEnd; i++) { + const k = items[i]?.key + + if (!k) { + continue + } + + const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0) + + if (h > 0 && heights.current.get(k) !== h) { + heights.current.set(k, h) + dirty = true + heightDirty = true + } + } + } + + if (s) { + const next = { + sticky: s.isSticky(), + top: Math.max(0, s.getScrollTop() + s.getPendingDelta()), + vp: Math.max(0, s.getViewportHeight()) + } + + if ( + next.sticky !== metrics.current.sticky || + next.top !== metrics.current.top || + next.vp !== metrics.current.vp + ) { + metrics.current = next + dirty = true + } + } + + if (dirty) { + offsetVersion.current++ + onHeightsChangeRef.current?.(heights.current) + } + + if (heightDirty) { + bumpMeasuredHeightVersion(n => n + 1) + } + }, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, total, vp]) + + return { + bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)), + end: effEnd, + measureRef, + offsets, + start: effStart, + topSpacer: offsets[effStart] ?? 0 + } +} + +interface MeasuredNode { + yogaNode?: { getComputedHeight?: () => number } | null +} + +interface VirtualHistoryOptions { + coldStartCount?: number + estimate?: number + estimateHeight?: (index: number, key: string) => number + initialHeights?: ReadonlyMap + liveTailActive?: boolean + maxMounted?: number + onHeightsChange?: (heights: ReadonlyMap) => void + overscan?: number +} diff --git a/packages/cli/src/lib/circularBuffer.ts b/packages/cli/src/lib/circularBuffer.ts new file mode 100644 index 0000000..31502fc --- /dev/null +++ b/packages/cli/src/lib/circularBuffer.ts @@ -0,0 +1,48 @@ +export class CircularBuffer { + private buf: T[] + private head = 0 + private len = 0 + + constructor(private capacity: number) { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new RangeError(`CircularBuffer capacity must be a positive integer, got ${capacity}`) + } + + this.buf = new Array(capacity) + } + + push(item: T) { + this.buf[this.head] = item + this.head = (this.head + 1) % this.capacity + + if (this.len < this.capacity) { + this.len++ + } + } + + tail(n = this.len): T[] { + const take = Math.min(Math.max(0, n), this.len) + const start = this.len < this.capacity ? 0 : this.head + const out: T[] = new Array(take) + + for (let i = 0; i < take; i++) { + out[i] = this.buf[(start + this.len - take + i) % this.capacity]! + } + + return out + } + + drain(): T[] { + const out = this.tail() + + this.clear() + + return out + } + + clear() { + this.buf = new Array(this.capacity) + this.head = 0 + this.len = 0 + } +} diff --git a/packages/cli/src/lib/clipboard.ts b/packages/cli/src/lib/clipboard.ts new file mode 100644 index 0000000..587e898 --- /dev/null +++ b/packages/cli/src/lib/clipboard.ts @@ -0,0 +1,166 @@ +import { execFile, spawn } from 'node:child_process' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024 +const POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'] as const + +type ClipboardRun = typeof execFileAsync + +export function isUsableClipboardText(text: null | string): text is string { + if (!text || !/[^\s]/.test(text)) { + return false + } + + if (text.includes('\u0000')) { + return false + } + + let suspicious = 0 + + for (const ch of text) { + const code = ch.charCodeAt(0) + const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t' + + if (isControl || ch === '\ufffd') { + suspicious += 1 + } + } + + return suspicious <= Math.max(2, Math.floor(text.length * 0.02)) +} + +function readClipboardCommands( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): Array<{ args: readonly string[]; cmd: string }> { + if (platform === 'darwin') { + return [{ cmd: 'pbpaste', args: [] }] + } + + if (platform === 'win32') { + return [{ cmd: 'powershell', args: POWERSHELL_ARGS }] + } + + const attempts: Array<{ args: readonly string[]; cmd: string }> = [] + + if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { + attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_ARGS }) + } + + if (env.WAYLAND_DISPLAY) { + attempts.push({ cmd: 'wl-paste', args: ['--type', 'text'] }) + } + + attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-out'] }) + + return attempts +} + +/** + * Read plain text from the system clipboard. + * + * Uses native platform tools in fallback order: + * - macOS: pbpaste + * - Windows: PowerShell Get-Clipboard -Raw + * - WSL: powershell.exe Get-Clipboard -Raw + * - Linux Wayland: wl-paste --type text + * - Linux X11: xclip -selection clipboard -out + */ +export async function readClipboardText( + platform: NodeJS.Platform = process.platform, + run: ClipboardRun = execFileAsync, + env: NodeJS.ProcessEnv = process.env +): Promise { + for (const attempt of readClipboardCommands(platform, env)) { + try { + const result = await run(attempt.cmd, [...attempt.args], { + encoding: 'utf8', + maxBuffer: CLIPBOARD_MAX_BUFFER, + windowsHide: true + }) + + if (typeof result.stdout === 'string') { + return result.stdout + } + } catch { + // Fall through to the next clipboard backend. + } + } + + return null +} + +function writeClipboardCommands( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): Array<{ args: readonly string[]; cmd: string }> { + if (platform === 'darwin') { + return [{ cmd: 'pbcopy', args: [] }] + } + + if (platform === 'win32') { + return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] }] + } + + const attempts: Array<{ args: readonly string[]; cmd: string }> = [] + + if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) { + attempts.push({ + cmd: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $input'] + }) + } + + if (env.WAYLAND_DISPLAY) { + attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'] }) + } + + attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'] }) + attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }) + + return attempts +} + +/** + * Write plain text to the system clipboard. + * + * Tries native platform tools in fallback order: + * - macOS: pbcopy + * - Windows: PowerShell Set-Clipboard + * - WSL: powershell.exe Set-Clipboard + * - Linux Wayland: wl-copy --type text/plain + * - Linux X11: xclip -selection clipboard -in + * - Linux X11 alt: xsel --clipboard --input + * + * Returns true if at least one backend succeeded, false otherwise + * (callers should fall back to OSC52 on false). + */ +export async function writeClipboardText( + text: string, + platform: NodeJS.Platform = process.platform, + start: typeof spawn = spawn, + env: NodeJS.ProcessEnv = process.env +): Promise { + const candidates = writeClipboardCommands(platform, env) + + for (const { cmd, args } of candidates) { + try { + const ok = await new Promise(resolve => { + const child = start(cmd, [...args], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }) + + child.once('error', () => resolve(false)) + child.once('close', code => resolve(code === 0)) + child.stdin?.end(text) + }) + + if (ok) { + return true + } + } catch { + // Fall through to the next clipboard backend. + } + } + + return false +} diff --git a/packages/cli/src/lib/editor.test.ts b/packages/cli/src/lib/editor.test.ts new file mode 100644 index 0000000..dc18be7 --- /dev/null +++ b/packages/cli/src/lib/editor.test.ts @@ -0,0 +1,74 @@ +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' + +import { beforeEach, describe, expect, it } from 'vitest' + +import { resolveEditor } from './editor.js' + +const exe = (dir: string, name: string): string => { + const path = join(dir, name) + + writeFileSync(path, '#!/bin/sh\nexit 0\n') + chmodSync(path, 0o755) + + return path +} + +describe('resolveEditor', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'editor-test-')) + }) + + it('honors $VISUAL above all else', () => { + expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toEqual(['helix']) + }) + + it('falls back to $EDITOR when $VISUAL is unset', () => { + expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toEqual(['nvim']) + }) + + it('shell-tokenizes editors with arguments', () => { + expect(resolveEditor({ EDITOR: 'code --wait', PATH: dir })).toEqual(['code', '--wait']) + expect(resolveEditor({ PATH: dir, VISUAL: 'emacsclient -t' })).toEqual(['emacsclient', '-t']) + }) + + it('ignores whitespace-only env vars', () => { + const expected = exe(dir, 'editor') + + expect(resolveEditor({ EDITOR: ' ', PATH: dir, VISUAL: '' })).toEqual([expected]) + }) + + it('prefers `editor` over nano over vi on $PATH', () => { + exe(dir, 'nano') + exe(dir, 'vi') + const expected = exe(dir, 'editor') + + expect(resolveEditor({ PATH: dir })).toEqual([expected]) + }) + + it('falls back to nano before vi when both exist', () => { + exe(dir, 'vi') + const expected = exe(dir, 'nano') + + expect(resolveEditor({ PATH: dir })).toEqual([expected]) + }) + + it('returns ["vi"] when $PATH is empty', () => { + expect(resolveEditor({ PATH: '' })).toEqual(['vi']) + }) + + it('walks multi-entry $PATH', () => { + const a = mkdtempSync(join(tmpdir(), 'editor-a-')) + const b = mkdtempSync(join(tmpdir(), 'editor-b-')) + const expected = exe(b, 'editor') + + expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toEqual([expected]) + }) + + it('uses notepad.exe on Windows when no env override', () => { + expect(resolveEditor({ PATH: dir }, 'win32')).toEqual(['notepad.exe']) + }) +}) diff --git a/packages/cli/src/lib/editor.ts b/packages/cli/src/lib/editor.ts new file mode 100644 index 0000000..806ee69 --- /dev/null +++ b/packages/cli/src/lib/editor.ts @@ -0,0 +1,47 @@ +import { accessSync, constants } from 'node:fs' +import { delimiter, join } from 'node:path' + +/** + * Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors + * prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and + * the TUI launch the same editor on a given box. + */ +const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs'] + +const isExecutable = (path: string): boolean => { + try { + accessSync(path, constants.X_OK) + + return true + } catch { + return false + } +} + +/** + * Resolve the editor invocation argv (without the file argument). + * + * 1. $VISUAL / $EDITOR, shell-tokenized so `EDITOR="code --wait"` works + * 2. on POSIX: first FALLBACKS entry resolvable on $PATH + * 3. on Windows: `notepad.exe` + * 4. literal `['vi']` as the last-resort POSIX floor + */ +export const resolveEditor = ( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string[] => { + const explicit = env.VISUAL ?? env.EDITOR + + if (explicit?.trim()) { + return explicit.trim().split(/\s+/) + } + + if (platform === 'win32') { + return ['notepad.exe'] + } + + const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean) + const found = FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable) + + return [found ?? 'vi'] +} diff --git a/packages/cli/src/lib/emoji.ts b/packages/cli/src/lib/emoji.ts new file mode 100644 index 0000000..6c22e81 --- /dev/null +++ b/packages/cli/src/lib/emoji.ts @@ -0,0 +1,55 @@ +const VS15 = 0xfe0e +const VS16 = 0xfe0f +const KEYCAP = 0x20e3 + +const TEXT_DEFAULT_EMOJI = new Set([ + 0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0x2194, 0x2195, 0x2196, 0x2197, 0x2198, 0x2199, 0x21a9, 0x21aa, + 0x2328, 0x23cf, 0x23ed, 0x23ee, 0x23ef, 0x23f1, 0x23f2, 0x23f8, 0x23f9, 0x23fa, 0x24c2, 0x25aa, 0x25ab, 0x25b6, + 0x25c0, 0x25fb, 0x25fc, 0x2600, 0x2601, 0x2602, 0x2603, 0x2604, 0x260e, 0x2611, 0x2618, 0x261d, 0x2620, 0x2622, + 0x2623, 0x2626, 0x262a, 0x262e, 0x262f, 0x2638, 0x2639, 0x263a, 0x2640, 0x2642, 0x265f, 0x2660, 0x2663, 0x2665, + 0x2666, 0x2668, 0x267b, 0x267e, 0x2692, 0x2694, 0x2695, 0x2696, 0x2697, 0x2699, 0x269b, 0x269c, 0x26a0, 0x26a7, + 0x26b0, 0x26b1, 0x26c8, 0x26cf, 0x26d1, 0x26d3, 0x26d4, 0x26e9, 0x26f0, 0x26f1, 0x26f4, 0x26f7, 0x26f8, 0x26f9, + 0x2702, 0x2708, 0x2709, 0x270c, 0x270d, 0x270f, 0x2712, 0x2714, 0x2716, 0x271d, 0x2721, 0x2733, 0x2734, 0x2744, + 0x2747, 0x2763, 0x2764, 0x27a1, 0x2934, 0x2935, 0x2b05, 0x2b06, 0x2b07, 0x3030, 0x303d, 0x3297, 0x3299 +]) + +const MAYBE_TEXT_EMOJI_RE = + /[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9\u21aa\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb\u25fc\u2600-\u2604\u260e\u2611\u2618\u261d\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u265f\u2660\u2663\u2665\u2666\u2668\u267b\u267e\u2692\u2694-\u2697\u2699\u269b\u269c\u26a0\u26a7\u26b0\u26b1\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26f0\u26f1\u26f4\u26f7-\u26f9\u2702\u2708\u2709\u270c\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u3030\u303d\u3297\u3299]/ + +export function ensureEmojiPresentation(text: string): string { + if (!text || !MAYBE_TEXT_EMOJI_RE.test(text)) { + return text + } + + // Lazy output: only start building when we actually need to insert VS16. + // Short-circuits the whole walk for strings where every text-default emoji + // is already followed by VS16/VS15, avoiding per-codepoint string growth. + let out: null | string = null + let last = 0 + let i = 0 + + while (i < text.length) { + const cp = text.codePointAt(i)! + const size = cp > 0xffff ? 2 : 1 + + if (TEXT_DEFAULT_EMOJI.has(cp)) { + const next = text.codePointAt(i + size) + + // Skip only when the sequence already carries an explicit presentation + // selector. VS16 means the user (or a prior pass) already requested + // emoji presentation; VS15 is an explicit text-presentation request so + // leave it alone and don't pile VS16 on top of it. Inject before ZWJ + // and KEYCAP so ZWJ-joined sequences (e.g. ❤️‍🔥) and digit keycaps + // both render as emoji rather than text. + if (next !== VS16 && next !== VS15) { + out ??= '' + out += text.slice(last, i + size) + '\uFE0F' + last = i + size + } + } + + i += size + } + + return out === null ? text : out + text.slice(last) +} diff --git a/packages/cli/src/lib/externalCli.ts b/packages/cli/src/lib/externalCli.ts new file mode 100644 index 0000000..9c551a7 --- /dev/null +++ b/packages/cli/src/lib/externalCli.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' + +export interface LaunchResult { + code: null | number + error?: string +} + +const resolveCoderBin = () => process.env.CODER_BIN?.trim() || 'coder' + +export const launchCoderCommand = (args: string[]): Promise => + new Promise(resolve => { + const child = spawn(resolveCoderBin(), args, { stdio: 'inherit' }) + + child.on('error', err => resolve({ code: null, error: err.message })) + child.on('exit', code => resolve({ code })) + }) diff --git a/packages/cli/src/lib/externalLink.ts b/packages/cli/src/lib/externalLink.ts new file mode 100644 index 0000000..0905206 --- /dev/null +++ b/packages/cli/src/lib/externalLink.ts @@ -0,0 +1,435 @@ +import { isIP } from 'node:net' + +import { useEffect, useMemo, useState } from 'react' + +const titleCache = new Map() +const titleInflight = new Map>() +const titleSubs = new Map void>>() + +const TITLE_CACHE_LIMIT = 500 +const TITLE_MAX_LENGTH = 240 +const TITLE_BYTE_BUDGET = 96 * 1024 +const TITLE_TIMEOUT_MS = 5000 + +const TITLE_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + +const TITLE_ERROR_RE = + /\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i + +const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i +const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|coder):/i +const LOCAL_HOSTNAME_RE = /^(?:localhost|localhost\.localdomain)$/i +const LOCAL_HOST_SUFFIXES = ['.corp', '.home', '.internal', '.lan', '.local', '.localdomain'] +const STATUS_PERMALINK_HOST_RE = /^(?:mobile\.)?(?:x|twitter)\.com$/i +const STATUS_PERMALINK_PATH_RE = /^\/[^/]+\/status\/\d+\/?$/i + +const HTML_ENTITIES: Record = { + '#39': "'", + amp: '&', + apos: "'", + gt: '>', + lt: '<', + nbsp: ' ', + quot: '"' +} + +export function normalizeExternalUrl(value: string): string { + const trimmed = value.trim() + + if (!trimmed || /^https?:\/\//i.test(trimmed)) { + return trimmed + } + + return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed +} + +function parseUrl(value: string): null | URL { + try { + return new URL(normalizeExternalUrl(value)) + } catch { + return null + } +} + +function titleCacheKey(value: string): string { + const url = parseUrl(value) + + if (!url) { + return normalizeExternalUrl(value) + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase() + const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' + + return `${host}${pathname}${url.search || ''}` +} + +function cacheTitle(key: string, title: string): void { + if (titleCache.size >= TITLE_CACHE_LIMIT) { + titleCache.delete(titleCache.keys().next().value as string) + } + + titleCache.set(key, title) +} + +export function hostPathLabel(value: string): string { + const url = parseUrl(value) + + if (!url) { + return value + } + + const host = url.hostname.replace(/^www\./, '') + const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '' + + return `${host}${path}` +} + +function cleanSlug(segment: string): string { + try { + return decodeURIComponent(segment) + .replace(/\.a\d+\..*$/i, '') + .replace(/\.(?:html?|php|aspx?)$/i, '') + .replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + } catch { + return '' + } +} + +export function urlSlugTitleLabel(value: string): string { + const url = parseUrl(value) + + if (url && STATUS_PERMALINK_HOST_RE.test(url.hostname) && STATUS_PERMALINK_PATH_RE.test(url.pathname)) { + return hostPathLabel(value) + } + + for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) { + const cleaned = cleanSlug(segment) + + if (!cleaned || !/[a-z]/i.test(cleaned)) { + continue + } + + if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) { + continue + } + + const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase()) + + if (titled.length >= 4) { + return titled + } + } + + return hostPathLabel(value) +} + +function parseIpv4Octets(value: string): null | [number, number, number, number] { + const parts = value.split('.') + + if (parts.length !== 4) { + return null + } + + const octets: number[] = [] + + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null + } + + const next = Number(part) + + if (!Number.isInteger(next) || next < 0 || next > 255) { + return null + } + + octets.push(next) + } + + return [octets[0]!, octets[1]!, octets[2]!, octets[3]!] +} + +function isPrivateIpv4(value: string): boolean { + const octets = parseIpv4Octets(value) + + if (!octets) { + return false + } + + const [a, b] = octets + + return ( + a === 0 || + a === 10 || + a === 127 || + a === 255 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) + ) +} + +function isPrivateIpv6(value: string): boolean { + const normalized = value.toLowerCase() + + if (normalized === '::' || normalized === '::1') { + return true + } + + if (normalized.startsWith('fc') || normalized.startsWith('fd')) { + return true + } + + if (normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) { + return true + } + + if (normalized.startsWith('::ffff:')) { + return isPrivateIpv4(normalized.slice('::ffff:'.length)) + } + + return false +} + +function normalizeHostname(value: string): string { + const withoutBrackets = value.replace(/^\[/, '').replace(/\]$/, '') + const withoutZoneId = withoutBrackets.split('%', 1)[0]! + + return withoutZoneId.replace(/\.$/, '').toLowerCase() +} + +function isPrivateOrLocalHost(hostname: string): boolean { + const normalized = normalizeHostname(hostname) + + if (!normalized) { + return true + } + + if (LOCAL_HOSTNAME_RE.test(normalized)) { + return true + } + + if (LOCAL_HOST_SUFFIXES.some(suffix => normalized.endsWith(suffix))) { + return true + } + + const ipVersion = isIP(normalized) + + if (ipVersion === 4) { + return isPrivateIpv4(normalized) + } + + if (ipVersion === 6) { + return isPrivateIpv6(normalized) + } + + // Single-label hostnames are usually LAN names or enterprise intranet aliases. + return !normalized.includes('.') +} + +export function isTitleFetchable(value: string): boolean { + if (!value || SKIP_PROTO_RE.test(value)) { + return false + } + + const url = parseUrl(value) + + return Boolean(url && /^https?:$/.test(url.protocol) && !isPrivateOrLocalHost(url.hostname)) +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_match, key: string) => HTML_ENTITIES[key.toLowerCase()] ?? '') + .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16) || 32)) + .replace(/&#(\d+);/g, (_match, decimal: string) => String.fromCodePoint(parseInt(decimal, 10) || 32)) +} + +function parseHtmlTitle(html: string): string { + const raw = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] + + return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' +} + +async function readResponseSnippet(response: Response): Promise { + const reader = response.body?.getReader() + + if (!reader) { + return (await response.text()).slice(0, TITLE_BYTE_BUDGET) + } + + const chunks: Uint8Array[] = [] + let done = false + let bytes = 0 + + try { + while (bytes < TITLE_BYTE_BUDGET) { + const chunk = await reader.read() + + if (chunk.done) { + done = true + + break + } + + const value = chunk.value + + if (!value?.length) { + continue + } + + const remaining = TITLE_BYTE_BUDGET - bytes + const next = value.length > remaining ? value.subarray(0, remaining) : value + + chunks.push(next) + bytes += next.length + + if (next.length < value.length) { + break + } + } + } catch { + return '' + } finally { + if (!done) { + try { + await reader.cancel() + } catch { + // Ignore stream teardown failures. + } + } + } + + if (!chunks.length) { + return '' + } + + const joined = new Uint8Array(bytes) + let offset = 0 + + for (const chunk of chunks) { + joined.set(chunk, offset) + offset += chunk.length + } + + return new TextDecoder().decode(joined) +} + +function usableTitle(value: string): string { + const clean = value.replace(/\s+/g, ' ').trim() + + return clean && !TITLE_ERROR_RE.test(clean) ? clean : '' +} + +async function fetchHtmlTitle(normalizedUrl: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS) + + try { + const response = await fetch(normalizedUrl, { + headers: { + Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5', + 'Accept-Language': 'en-US,en;q=0.7', + 'User-Agent': TITLE_USER_AGENT + }, + redirect: 'follow', + signal: controller.signal + }) + + if (!response.ok) { + return '' + } + + const contentType = response.headers.get('content-type') + + if (contentType && !/(?:html|xml|text\/html)/i.test(contentType)) { + return '' + } + + const html = await readResponseSnippet(response) + + return parseHtmlTitle(html).slice(0, TITLE_MAX_LENGTH) + } catch { + return '' + } finally { + clearTimeout(timeout) + } +} + +export function fetchLinkTitle(url: string): Promise { + const normalizedUrl = normalizeExternalUrl(url) + const key = titleCacheKey(normalizedUrl) + + if (!isTitleFetchable(normalizedUrl)) { + return Promise.resolve('') + } + + if (titleCache.has(key)) { + return Promise.resolve(titleCache.get(key) ?? '') + } + + const pending = titleInflight.get(key) + + if (pending) { + return pending + } + + const promise = fetchHtmlTitle(normalizedUrl) + .then(usableTitle) + .catch(() => '') + .then(clean => { + cacheTitle(key, clean) + titleSubs.get(key)?.forEach(sub => sub(clean)) + + return clean + }) + .finally(() => { + titleInflight.delete(key) + }) + + titleInflight.set(key, promise) + + return promise +} + +export function useLinkTitle(url?: null | string): string { + const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url]) + const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl]) + const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : '')) + + useEffect(() => { + setTitle(key ? (titleCache.get(key) ?? '') : '') + + if (!key || !isTitleFetchable(normalizedUrl)) { + return + } + + const subs = titleSubs.get(key) ?? new Set<(value: string) => void>() + + subs.add(setTitle) + titleSubs.set(key, subs) + void fetchLinkTitle(normalizedUrl) + + return () => { + subs.delete(setTitle) + + if (!subs.size) { + titleSubs.delete(key) + } + } + }, [key, normalizedUrl]) + + return title +} + +export function __resetLinkTitleCache(): void { + titleCache.clear() + titleInflight.clear() + titleSubs.clear() +} diff --git a/packages/cli/src/lib/fileTreeBuilder.ts b/packages/cli/src/lib/fileTreeBuilder.ts new file mode 100644 index 0000000..d67fa09 --- /dev/null +++ b/packages/cli/src/lib/fileTreeBuilder.ts @@ -0,0 +1,257 @@ +/** + * fileTreeBuilder.ts — File tree data model and builder + * + * Scans the filesystem and builds a tree of FileNodes for the + * FileTree component to render. Supports depth limiting, glob-style + * ignore patterns, and optional git status annotation. + */ + +import { readdirSync, statSync } from 'node:fs'; +import { basename, join, relative, sep } from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Git porcelain status short code */ +export type GitStatus = 'A' | 'M' | 'D' | 'U' | 'R'; + +export interface FileNode { + /** Display name — just the basename */ + name: string; + /** Full path from the root */ + path: string; + /** Kind */ + type: 'file' | 'directory'; + /** Child entries (directories only) */ + children?: FileNode[]; + /** Git status — only set when git info is available */ + gitStatus?: GitStatus; + /** Whether the directory is visually expanded (runtime state) */ + expanded?: boolean; + /** Nesting depth (root = 0) */ + depth: number; +} + +export interface BuildFileTreeOptions { + /** Maximum directory depth to traverse (default: 3) */ + maxDepth?: number; + /** Patterns to ignore (directory names to skip entirely) */ + ignorePatterns?: string[]; + /** When true, respects .gitignore patterns (default: false) */ + respectGitignore?: boolean; +} + +// --------------------------------------------------------------------------- +// Default ignore set +// --------------------------------------------------------------------------- + +const DEFAULT_IGNORE = new Set([ + 'node_modules', + '.git', + 'dist', + '.next', + 'build', + '__pycache__', + '.DS_Store', + '.cache', + 'coverage', + '.turbo', + '.tsbuildinfo', +]); + +// --------------------------------------------------------------------------- +// Sort: directories first, then alphabetical +// --------------------------------------------------------------------------- + +function sortNodes(nodes: FileNode[]): FileNode[] { + return nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +// --------------------------------------------------------------------------- +// Builder +// --------------------------------------------------------------------------- + +/** + * Build a file tree from the filesystem. + * + * Walks the directory tree starting at `rootPath`, respecting + * maxDepth and ignore patterns. Directories are expanded by default + * for the first 2 levels; deeper directories start collapsed. + * + * @param rootPath Absolute or relative path to the project root + * @param options Max depth, ignore patterns, git integration + * @returns Root FileNode (type: 'directory', children populated) + */ +export function buildFileTree(rootPath: string, options: BuildFileTreeOptions = {}): FileNode { + const { + maxDepth = 3, + ignorePatterns = [], + respectGitignore = false, + } = options; + + const ignoreSet = new Set([...DEFAULT_IGNORE, ...ignorePatterns]); + + // Preload gitignore patterns if requested + const gitignoreGlobs: string[] = []; + if (respectGitignore) { + gitignoreGlobs.push(...loadGitignorePatterns(rootPath)); + } + + function shouldIgnore(name: string, fullPath: string, isDir: boolean): boolean { + // Exact name match against ignore set + if (ignoreSet.has(name)) return true; + + // Hidden files/dirs (starting with '.') except the root itself + // We skip .-prefixed entries inside the tree but not the root dir + if (name.startsWith('.')) return true; + + // Simple gitignore glob matching + if (respectGitignore && gitignoreGlobs.length > 0) { + const relPath = relative(rootPath, fullPath) + (isDir ? sep : ''); + for (const pattern of gitignoreGlobs) { + if (matchSimpleGlob(pattern, relPath)) return true; + } + } + + return false; + } + + function walk(dirPath: string, depth: number): FileNode[] { + if (depth > maxDepth) return []; + + let entries: string[]; + try { + entries = readdirSync(dirPath); + } catch { + return []; + } + + const nodes: FileNode[] = []; + + for (const entry of entries) { + const fullPath = join(dirPath, entry); + let isDir: boolean; + try { + isDir = statSync(fullPath).isDirectory(); + } catch { + continue; // Skip broken symlinks / permission errors + } + + if (shouldIgnore(entry, fullPath, isDir)) continue; + + const node: FileNode = { + name: entry, + path: fullPath, + type: isDir ? 'directory' : 'file', + depth, + // Directories at depth 0-1 start expanded; deeper ones start collapsed + expanded: isDir ? depth < 2 : undefined, + }; + + if (isDir) { + node.children = walk(fullPath, depth + 1); + } + + nodes.push(node); + } + + return sortNodes(nodes); + } + + return { + name: basename(rootPath) || rootPath, + path: rootPath, + type: 'directory', + depth: 0, + expanded: true, // Root is always expanded + children: walk(rootPath, 1), + }; +} + +// --------------------------------------------------------------------------- +// Simple glob matching for gitignore +// --------------------------------------------------------------------------- + +function loadGitignorePatterns(_rootPath: string): string[] { + // Placeholder — full gitignore parsing requires reading .gitignore + // and applying negation/anchoring rules. For the initial implementation, + // we rely on the explicit ignorePatterns option. + return []; +} + +/** + * Minimal glob matching for gitignore-style patterns. + * Supports: trailing-slash directory marker, leading-slash anchoring, + * single-star (*) and double-star (**). + */ +function matchSimpleGlob(pattern: string, candidate: string): boolean { + let p = pattern; + let c = candidate; + + // Trailing / means "only match directories" + const dirOnly = p.endsWith('/'); + if (dirOnly) { + p = p.slice(0, -1); + } + + // Leading / anchors to root + if (p.startsWith('/')) { + p = p.slice(1); + } + + // Convert glob to regex + const regexStr = p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars + .replace(/\*\*\//g, '(?:.*/)?') // **/ matches zero or more dirs + .replace(/\*\*/g, '.*') // ** matches anything + .replace(/\*/g, '[^/]*') // * matches anything except / + .replace(/\?/g, '[^/]'); // ? matches single non-slash + + const re = new RegExp(`^(?:.*/)?${regexStr}${dirOnly ? '/?$' : '$'}`); + return re.test(c); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** + * Toggle a node's expanded state in-place by path. + * Returns true if the node was found and toggled. + */ +export function toggleNode(tree: FileNode, targetPath: string): boolean { + if (tree.path === targetPath && tree.type === 'directory') { + tree.expanded = !tree.expanded; + return true; + } + + if (tree.children) { + for (const child of tree.children) { + if (toggleNode(child, targetPath)) return true; + } + } + + return false; +} + +/** + * Find a node by its path in the tree. + */ +export function findNode(tree: FileNode, targetPath: string): FileNode | undefined { + if (tree.path === targetPath) return tree; + + if (tree.children) { + for (const child of tree.children) { + const found = findNode(child, targetPath); + if (found) return found; + } + } + + return undefined; +} diff --git a/packages/cli/src/lib/forceTruecolor.ts b/packages/cli/src/lib/forceTruecolor.ts new file mode 100644 index 0000000..d059e36 --- /dev/null +++ b/packages/cli/src/lib/forceTruecolor.ts @@ -0,0 +1,60 @@ +/** + * Targeted 24-bit truecolor override before chalk / supports-color imports. + * + * macOS Terminal.app before Tahoe 26 does not support RGB SGR, so do not + * infer truecolor from TERM_PROGRAM=Apple_Terminal. Users can still opt in + * explicitly on terminals that support RGB but do not advertise COLORTERM. + */ + +const TRUE_RE = /^(?:1|true|yes|on)$/i +const FALSE_RE = /^(?:0|false|no|off)$/i + +export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + const override = (env.CODER_TUI_TRUECOLOR ?? '').trim() + + if (FALSE_RE.test(override) || 'NO_COLOR' in env) { + return false + } + + return TRUE_RE.test(override) +} + +const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal' + +const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const forceColor = (env.FORCE_COLOR ?? '').trim() + + return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3' +} + +export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean { + if (!isAppleTerminal(env)) { + return false + } + + if (shouldForceTruecolor(env)) { + return false + } + + return isAdvertisedTruecolor(env) +} + +if (shouldForceTruecolor()) { + if (!process.env.COLORTERM) { + process.env.COLORTERM = 'truecolor' + } + + process.env.FORCE_COLOR = '3' +} else if (shouldDowngradeAppleTerminalTruecolor()) { + // Terminal.app may advertise truecolor even when RGB SGR paths render + // incorrectly. Keep Coder on the safer TERM-driven 256-color path unless + // users explicitly opt back in via CODER_TUI_TRUECOLOR=1. + delete process.env.COLORTERM + + if ((process.env.FORCE_COLOR ?? '').trim() === '3') { + delete process.env.FORCE_COLOR + } +} + +export {} diff --git a/packages/cli/src/lib/fpsStore.ts b/packages/cli/src/lib/fpsStore.ts new file mode 100644 index 0000000..38938e3 --- /dev/null +++ b/packages/cli/src/lib/fpsStore.ts @@ -0,0 +1,51 @@ +// Tiny FPS tracker fed by ink's onFrame callback. Each entry is an Ink +// frame (React commit + drain-only frames) — the right notion for +// user-perceived motion. +// +// Zero-cost when CODER_TUI_FPS is unset: trackFrame is undefined so the +// onFrame callback short-circuits at the optional chain. + +import { atom } from 'nanostores' + +import { SHOW_FPS } from '../config/env.js' + +const WINDOW_SIZE = 30 + +export type FpsState = { + fps: number + /** Wraps at JS-safe int — diff pairs in a debug overlay safely. */ + totalFrames: number + /** Ink render-phase total for the last frame. */ + lastDurationMs: number +} + +export const $fpsState = atom({ fps: 0, lastDurationMs: 0, totalFrames: 0 }) + +const timestamps: number[] = [] +let totalFrames = 0 + +export const trackFrame = SHOW_FPS + ? (durationMs: number) => { + timestamps.push(performance.now()) + + if (timestamps.length > WINDOW_SIZE) { + timestamps.shift() + } + + totalFrames++ + + if (timestamps.length < 2) { + return + } + + const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000 + + if (elapsed > 0) { + $fpsState.set({ + fps: Math.round(((timestamps.length - 1) / elapsed) * 10) / 10, + lastDurationMs: Math.round(durationMs * 100) / 100, + totalFrames + }) + } + } + : undefined diff --git a/packages/cli/src/lib/gracefulExit.ts b/packages/cli/src/lib/gracefulExit.ts new file mode 100644 index 0000000..2896fd1 --- /dev/null +++ b/packages/cli/src/lib/gracefulExit.ts @@ -0,0 +1,47 @@ +interface SetupOptions { + cleanups?: (() => Promise | void)[] + failsafeMs?: number + onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void + onSignal?: (signal: NodeJS.Signals) => void +} + +const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = { + SIGHUP: 129, + SIGINT: 130, + SIGTERM: 143 +} + +let wired = false + +export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, onSignal }: SetupOptions = {}) { + if (wired) { + return + } + + wired = true + + let shuttingDown = false + + const exit = (code: number, signal?: NodeJS.Signals) => { + if (shuttingDown) { + return + } + + shuttingDown = true + + if (signal) { + onSignal?.(signal) + } + + setTimeout(() => process.exit(code), failsafeMs).unref?.() + + void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code)) + } + + for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { + process.on(sig, () => exit(SIGNAL_EXIT_CODE[sig], sig)) + } + + process.on('uncaughtException', err => onError?.('uncaughtException', err)) + process.on('unhandledRejection', reason => onError?.('unhandledRejection', reason)) +} diff --git a/packages/cli/src/lib/history.ts b/packages/cli/src/lib/history.ts new file mode 100644 index 0000000..2d2e660 --- /dev/null +++ b/packages/cli/src/lib/history.ts @@ -0,0 +1,82 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MAX = 1000 +const dir = process.env.CODER_HOME ?? join(homedir(), '.coder') +const file = join(dir, '.coder_history') + +let cache: string[] | null = null + +export function load() { + if (cache) { + return cache + } + + try { + if (!existsSync(file)) { + cache = [] + + return cache + } + + const entries: string[] = [] + let current: string[] = [] + + for (const line of readFileSync(file, 'utf8').split('\n')) { + if (line.startsWith('+')) { + current.push(line.slice(1)) + } else if (current.length) { + entries.push(current.join('\n')) + current = [] + } + } + + if (current.length) { + entries.push(current.join('\n')) + } + + cache = entries.slice(-MAX) + } catch { + cache = [] + } + + return cache +} + +export function append(line: string) { + const trimmed = line.trim() + + if (!trimmed) { + return + } + + const items = load() + + if (items.at(-1) === trimmed) { + return + } + + items.push(trimmed) + + if (items.length > MAX) { + items.splice(0, items.length - MAX) + } + + try { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') + + const encoded = trimmed + .split('\n') + .map(l => `+${l}`) + .join('\n') + + appendFileSync(file, `\n# ${ts}\n${encoded}\n`) + } catch { + void 0 + } +} diff --git a/packages/cli/src/lib/inputMetrics.ts b/packages/cli/src/lib/inputMetrics.ts new file mode 100644 index 0000000..bda0938 --- /dev/null +++ b/packages/cli/src/lib/inputMetrics.ts @@ -0,0 +1,203 @@ +import { stringWidth, wrapAnsi } from '@coder/tui' + +import type { Role } from '../types.js' + +export const COMPOSER_PROMPT_GAP_WIDTH = 1 + +let _seg: Intl.Segmenter | null = null +const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) + +interface VisualLine { + end: number + start: number +} + +const graphemes = (value: string) => + [...seg().segment(value)].map(({ segment, index }) => ({ + end: index + segment.length, + index, + segment, + width: Math.max(1, stringWidth(segment)) + })) + +// Build VisualLines from wrap-ansi's output by mapping each emitted character +// back to its original offset in `value`. wrap-ansi only INSERTS '\n' at wrap +// boundaries — it never drops, reorders, or substitutes existing characters — +// so a parallel walk uniquely identifies each line's source range. +// +// This used to be a hand-rolled word-wrap whose break points disagreed with +// wrap-ansi in subtle but visible ways: exact-fill rows pushed the cursor to +// a phantom next line, mid-word breaks landed one grapheme off, etc. The +// composer's TextInput renders text via Ink's , which +// delegates to wrap-ansi — so any drift between the two algorithms parks the +// hardware cursor several cells away from the last rendered character. +// Sourcing both from wrap-ansi guarantees agreement. +function visualLines(value: string, cols: number): VisualLine[] { + if (!value.length) { + return [{ start: 0, end: 0 }] + } + + const width = Math.max(1, cols) + const wrapped = wrapAnsi(value, width) + const lines: VisualLine[] = [] + + let originalIdx = 0 + let lineStart = 0 + + for (let i = 0; i < wrapped.length; i += 1) { + const ch = wrapped[i]! + + if (ch === '\n') { + // wrap-ansi inserts '\n' to mark a soft-wrap boundary OR copies a + // literal '\n' from the input. Either way the next char in `wrapped` + // begins a new visual line. If the source character is a hard '\n', + // consume it (it doesn't appear in either line). Otherwise the '\n' + // is purely a wrap marker and originalIdx stays put. + lines.push({ start: lineStart, end: originalIdx }) + const isHardNewline = originalIdx < value.length && value[originalIdx] === '\n' + + if (isHardNewline) { + originalIdx += 1 + } + + lineStart = originalIdx + + continue + } + + // Defensive sync check. wrap-ansi (with `hard: true, trim: false`, no + // styled input) is documented to only insert '\n' at break points and + // never substitute, drop, or reorder source characters — so under those + // options `wrapped[i]` should always equal `value[originalIdx]`. But + // future option changes, library upgrades, or callers that start passing + // styled input (ANSI escapes) could violate that invariant silently. If + // they do, we'd slide `originalIdx` past the end of `value` and emit + // garbage line ranges with no diagnostic. Realign by scanning forward + // for the matching character; bail out (return whatever we have) if the + // sync is unrecoverable rather than producing wrong-but-plausible output. + if (originalIdx >= value.length) { + break + } + + if (value[originalIdx] !== ch) { + const reSync = value.indexOf(ch, originalIdx) + + if (reSync === -1) { + break + } + + originalIdx = reSync + } + + originalIdx += 1 + } + + lines.push({ start: lineStart, end: originalIdx }) + + // wrap-ansi collapses an empty input into [""] which we already handled + // above; preserve the invariant that lines is never empty for any input. + return lines.length ? lines : [{ start: 0, end: 0 }] +} + +function widthBetween(value: string, start: number, end: number) { + let width = 0 + + for (const part of graphemes(value.slice(start, end))) { + width += part.width + } + + return width +} + +/** + * Mirrors the word-wrap behavior used by the composer TextInput. + * Returns the zero-based visual line and column of the cursor cell. + * + * IMPORTANT: this MUST stay in lock-step with how Ink's `` + * lays the value out (which uses `wrap-ansi`). Any divergence parks the + * hardware cursor several cells off the last rendered character — see the + * "cursor drift past blank cells" bug. `visualLines` is sourced directly + * from wrap-ansi to enforce that invariant. + */ +export function cursorLayout(value: string, cursor: number, cols: number) { + const pos = Math.max(0, Math.min(cursor, value.length)) + const w = Math.max(1, cols) + const lines = visualLines(value, w) + let lineIndex = 0 + + for (let i = 0; i < lines.length; i += 1) { + if (lines[i]!.start <= pos) { + lineIndex = i + } else { + break + } + } + + const line = lines[lineIndex]! + const column = widthBetween(value, line.start, Math.min(pos, line.end)) + + // NOTE: the previous implementation forced an extra line break when + // `column >= w` (the "trailing cursor-cell overflows" rule). With + // `visualLines` sourcing breaks from wrap-ansi, the line wrapping + // above already matches what Ink will actually render. Pushing the + // cursor onto a phantom next line here would re-introduce the same + // drift we're fixing, so we don't. + return { column, line: lineIndex } +} + +export function offsetFromPosition(value: string, row: number, col: number, cols: number) { + if (!value.length) { + return 0 + } + + const lines = visualLines(value, cols) + const target = lines[Math.max(0, Math.min(lines.length - 1, Math.floor(row)))]! + const targetCol = Math.max(0, Math.floor(col)) + let column = 0 + + for (const part of graphemes(value.slice(target.start, target.end))) { + if (targetCol <= column + Math.max(0, part.width - 1)) { + return target.start + part.index + } + + column += part.width + } + + return target.end +} + +export function inputVisualHeight(value: string, columns: number) { + return cursorLayout(value, value.length, columns).line + 1 +} + +export function composerPromptWidth(promptText: string) { + return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH +} + +export function transcriptGutterWidth(role: Role, userPrompt: string) { + return role === 'user' ? composerPromptWidth(userPrompt) : 3 +} + +export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) { + const horizontalReserve = termuxMode ? 2 : 4 + const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve) + + if (termuxMode) { + // On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum + // width causes right-edge clipping and chopped words. + return available + } + + return Math.max(20, available) +} + +export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) { + // Physical render/wrap width. Always reserve outer composer padding and + // prompt prefix. Only reserve the transcript scrollbar gutter when the + // terminal is wide enough; on narrow panes, preserving input columns beats + // keeping gutters visually aligned. + const afterPrompt = totalCols - promptWidth + const reserveScrollbar = afterPrompt >= (termuxMode ? 36 : 24) ? 2 : 0 + + return Math.max(1, totalCols - promptWidth - 2 - reserveScrollbar) +} diff --git a/packages/cli/src/lib/liveProgress.test.ts b/packages/cli/src/lib/liveProgress.test.ts new file mode 100644 index 0000000..cea53d5 --- /dev/null +++ b/packages/cli/src/lib/liveProgress.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' + +import type { Msg } from '../types.js' + +import { appendToolShelfMessage, canHoldToolShelf, isTodoDone, mergeToolShelfInto } from './liveProgress.js' + +describe('isTodoDone', () => { + it('only treats non-empty all-completed/cancelled lists as done', () => { + expect(isTodoDone([])).toBe(false) + expect(isTodoDone([{ content: 'x', id: 'x', status: 'completed' }])).toBe(true) + expect(isTodoDone([{ content: 'x', id: 'x', status: 'in_progress' }])).toBe(false) + expect( + isTodoDone([ + { content: 'x', id: 'x', status: 'completed' }, + { content: 'y', id: 'y', status: 'cancelled' } + ]) + ).toBe(true) + }) +}) + +describe('tool shelf helpers', () => { + it('recognizes contextual thinking shelves as holders', () => { + expect(canHoldToolShelf({ kind: 'trail', role: 'system', text: '', thinking: 'plan' })).toBe(true) + expect(canHoldToolShelf({ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] })).toBe(true) + expect(canHoldToolShelf({ role: 'assistant', text: 'done' })).toBe(false) + }) + + it('merges source rows into an existing shelf', () => { + expect( + mergeToolShelfInto( + { kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }, + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } + ) + ).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }) + }) +}) + +describe('appendToolShelfMessage', () => { + it('merges adjacent tool shelves into one contextual shelf', () => { + const merged = appendToolShelfMessage([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }], { + kind: 'trail', + role: 'system', + text: '', + tools: ['two ✓'] + }) + + expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] }]) + }) + + it('adds tools to the nearest contextual thinking shelf', () => { + const merged = appendToolShelfMessage( + [{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }], + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } + ) + + expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }]) + }) + + it('merges through intervening thinking-only rows back into the nearest holder', () => { + const prev: Msg[] = [ + { kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }, + { kind: 'trail', role: 'system', text: '', thinking: 'more plan' } + ] + + const merged = appendToolShelfMessage(prev, { + kind: 'trail', + role: 'system', + text: '', + tools: ['two ✓'] + }) + + expect(merged).toHaveLength(2) + expect(merged[0]).toEqual({ + kind: 'trail', + role: 'system', + text: '', + thinking: 'plan', + tools: ['one ✓', 'two ✓'] + }) + expect(merged[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }) + }) + + it('collapses a chronological thinking/tool/thinking/tool stream into one shelf', () => { + const events: Msg[] = [ + { kind: 'trail', role: 'system', text: '', thinking: 'plan' }, + { kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, + { kind: 'trail', role: 'system', text: '', thinking: 'more plan' }, + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }, + { kind: 'trail', role: 'system', text: '', tools: ['three ✓'] } + ] + + const reduced = events.reduce((acc, msg) => appendToolShelfMessage(acc, msg), []) + + expect(reduced).toHaveLength(2) + expect(reduced[0]).toEqual({ + kind: 'trail', + role: 'system', + text: '', + thinking: 'plan', + tools: ['one ✓', 'two ✓', 'three ✓'] + }) + expect(reduced[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }) + }) + + it('starts a new shelf across assistant text boundaries', () => { + const merged = appendToolShelfMessage( + [ + { kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, + { role: 'assistant', text: 'done' } + ], + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } + ) + + expect(merged).toHaveLength(3) + }) +}) diff --git a/packages/cli/src/lib/liveProgress.ts b/packages/cli/src/lib/liveProgress.ts new file mode 100644 index 0000000..50290a5 --- /dev/null +++ b/packages/cli/src/lib/liveProgress.ts @@ -0,0 +1,93 @@ +import type { Msg, TodoItem } from '../types.js' + +export const countPendingTodos = (todos: readonly TodoItem[]) => + todos.filter(todo => todo.status === 'in_progress' || todo.status === 'pending').length + +export const isTodoDone = (todos: readonly TodoItem[]) => + todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled') + +export const isToolShelfMessage = (msg: Msg | undefined) => + Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length) + +export const canHoldToolShelf = (msg: Msg | undefined) => + Boolean(msg?.kind === 'trail' && !msg.text && (msg.thinking?.trim() || msg.tools?.length)) + +export const mergeToolShelfInto = (target: Msg, source: Msg): Msg => { + const currentTools = target.tools ?? [] + const newTools = source.tools ?? [] + + // Preserve object identity when there is nothing new to add. + // This avoids creating a fresh Msg reference that would skip + // AppendToolShelfMessage's shallow array copy and churn downstream + // content-keyed caches (messageId, vdom reconciliation). + if (newTools.length === 0) return target + + const merged = [...currentTools, ...newTools] + + // If every incoming tool was already present (merged length equals + // current length) there is no actual change — keep the same reference. + if (merged.length === currentTools.length) return target + + return { ...target, tools: merged } +} + +const isBarrierMessage = (msg: Msg | undefined) => { + if (!msg) { + return true + } + + // Assistant text, user input, intro/panel rows all terminate the shelf. + if (msg.kind === 'intro' || msg.kind === 'panel' || msg.kind === 'diff') { + return true + } + + if (msg.role && msg.role !== 'system') { + return true + } + + if (msg.text) { + return true + } + + return false +} + +const isToolCarryingTrail = (msg: Msg | undefined) => Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length) + +export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => { + if (!isToolShelfMessage(msg)) { + return [...prev, msg] + } + + let fallbackHolder: number | null = null + + for (let index = prev.length - 1; index >= 0; index--) { + const candidate = prev[index] + + if (isToolCarryingTrail(candidate)) { + const next = [...prev] + + next[index] = mergeToolShelfInto(candidate!, msg) + + return next + } + + if (fallbackHolder === null && canHoldToolShelf(candidate)) { + fallbackHolder = index + } + + if (isBarrierMessage(candidate)) { + break + } + } + + if (fallbackHolder !== null) { + const next = [...prev] + + next[fallbackHolder] = mergeToolShelfInto(prev[fallbackHolder]!, msg) + + return next + } + + return [...prev, msg] +} diff --git a/packages/cli/src/lib/mathUnicode.ts b/packages/cli/src/lib/mathUnicode.ts new file mode 100644 index 0000000..17af85e --- /dev/null +++ b/packages/cli/src/lib/mathUnicode.ts @@ -0,0 +1,770 @@ +// Best-effort LaTeX → Unicode for inline / display math captured by the +// markdown renderer. The terminal can't typeset LaTeX, but Unicode covers +// most of what models actually emit: Greek letters, blackboard / fraktur / +// calligraphic capitals, set theory + logic operators, common arrows, +// sub/superscripts, and `\frac{a}{b}` collapsed to `a/b`. +// +// Design rules: +// • Pure regex pipeline. Anything we don't recognise is preserved +// verbatim (so a `\foo{bar}` we've never heard of still survives). +// A real LaTeX parser would be more correct but throws on partial +// input — terminal users would rather see the raw command than a +// parse-error placeholder. +// • Longest-match-first ordering on commands so `\le` doesn't shadow +// `\leq`, `\sub` doesn't shadow `\subseteq`, etc. +// • Word-boundary lookahead `(?![A-Za-z])` after each command so +// `\pix` (made-up command) doesn't get partially substituted as `π`. +// • `\mathbb{X}`, `\mathcal{X}`, `\mathfrak{X}` only handle a single +// letter argument — multi-letter `\mathbb{NN}` is rare and would +// need a real parser to do correctly. +// • Sub/super scripts only convert if EVERY character has a Unicode +// equivalent. Mixed content like `^{n+1}` falls back to the raw +// LaTeX so we don't emit `ⁿ+¹` (which has no `+` superscript glyph +// in some fonts and reads worse than the source). + +const SYMBOLS: Record = { + // Greek lowercase + '\\alpha': 'α', + '\\beta': 'β', + '\\gamma': 'γ', + '\\delta': 'δ', + '\\epsilon': 'ε', + '\\varepsilon': 'ε', + '\\zeta': 'ζ', + '\\eta': 'η', + '\\theta': 'θ', + '\\vartheta': 'ϑ', + '\\iota': 'ι', + '\\kappa': 'κ', + '\\lambda': 'λ', + '\\mu': 'μ', + '\\nu': 'ν', + '\\xi': 'ξ', + '\\pi': 'π', + '\\varpi': 'ϖ', + '\\rho': 'ρ', + '\\varrho': 'ϱ', + '\\sigma': 'σ', + '\\varsigma': 'ς', + '\\tau': 'τ', + '\\upsilon': 'υ', + '\\phi': 'φ', + '\\varphi': 'φ', + '\\chi': 'χ', + '\\psi': 'ψ', + '\\omega': 'ω', + + // Greek uppercase + '\\Gamma': 'Γ', + '\\Delta': 'Δ', + '\\Theta': 'Θ', + '\\Lambda': 'Λ', + '\\Xi': 'Ξ', + '\\Pi': 'Π', + '\\Sigma': 'Σ', + '\\Upsilon': 'Υ', + '\\Phi': 'Φ', + '\\Psi': 'Ψ', + '\\Omega': 'Ω', + + // Big operators + '\\sum': '∑', + '\\prod': '∏', + '\\coprod': '∐', + '\\int': '∫', + '\\iint': '∬', + '\\iiint': '∭', + '\\oint': '∮', + '\\bigcup': '⋃', + '\\bigcap': '⋂', + '\\bigvee': '⋁', + '\\bigwedge': '⋀', + '\\bigoplus': '⨁', + '\\bigotimes': '⨂', + + // Calculus + '\\partial': '∂', + '\\nabla': '∇', + '\\sqrt': '√', + + // Sets + '\\emptyset': '∅', + '\\varnothing': '∅', + '\\infty': '∞', + '\\in': '∈', + '\\notin': '∉', + '\\ni': '∋', + '\\subset': '⊂', + '\\supset': '⊃', + '\\subseteq': '⊆', + '\\supseteq': '⊇', + '\\subsetneq': '⊊', + '\\supsetneq': '⊋', + '\\cup': '∪', + '\\cap': '∩', + '\\setminus': '∖', + '\\complement': '∁', + + // Logic + '\\forall': '∀', + '\\exists': '∃', + '\\nexists': '∄', + '\\land': '∧', + '\\lor': '∨', + '\\lnot': '¬', + '\\neg': '¬', + '\\therefore': '∴', + '\\because': '∵', + + // Relations + '\\le': '≤', + '\\leq': '≤', + '\\ge': '≥', + '\\geq': '≥', + '\\ne': '≠', + '\\neq': '≠', + '\\ll': '≪', + '\\gg': '≫', + '\\approx': '≈', + '\\equiv': '≡', + '\\cong': '≅', + '\\sim': '∼', + '\\simeq': '≃', + '\\propto': '∝', + '\\perp': '⊥', + '\\parallel': '∥', + '\\models': '⊨', + '\\vdash': '⊢', + '\\mid': '∣', + '\\nmid': '∤', + '\\divides': '∣', + + // Common standalone glyphs + '\\blacksquare': '■', + '\\square': '□', + '\\Box': '□', + '\\qed': '∎', + '\\bigstar': '★', + + // Modular arithmetic — the `\pmod{p}` form (with arg) is handled below; + // the bare `\bmod` / `\mod` commands are simple text substitutions. + '\\bmod': 'mod', + '\\mod': 'mod', + + // Brackets / fences (named delimiter commands; the `\left\X` / `\right\X` + // unwrapping below leaves these behind for the symbol pass to resolve). + '\\langle': '⟨', + '\\rangle': '⟩', + '\\lceil': '⌈', + '\\rceil': '⌉', + '\\lfloor': '⌊', + '\\rfloor': '⌋', + '\\|': '‖', + + // Arrows + '\\to': '→', + '\\rightarrow': '→', + '\\leftarrow': '←', + '\\leftrightarrow': '↔', + '\\Rightarrow': '⇒', + '\\Leftarrow': '⇐', + '\\Leftrightarrow': '⇔', + '\\implies': '⟹', + '\\impliedby': '⟸', + '\\iff': '⟺', + '\\mapsto': '↦', + '\\hookrightarrow': '↪', + '\\hookleftarrow': '↩', + '\\uparrow': '↑', + '\\downarrow': '↓', + '\\updownarrow': '↕', + + // Binary operators + '\\cdot': '⋅', + '\\cdots': '⋯', + '\\ldots': '…', + '\\dots': '…', + '\\dotsb': '…', + '\\dotsc': '…', + '\\vdots': '⋮', + '\\ddots': '⋱', + '\\times': '×', + '\\div': '÷', + '\\pm': '±', + '\\mp': '∓', + '\\circ': '∘', + '\\bullet': '•', + '\\star': '⋆', + '\\ast': '∗', + '\\oplus': '⊕', + '\\ominus': '⊖', + '\\otimes': '⊗', + '\\odot': '⊙', + '\\diamond': '⋄', + '\\angle': '∠', + '\\triangle': '△', + + // Spacing — collapse to varying widths of regular space + '\\,': ' ', + '\\;': ' ', + '\\:': ' ', + '\\!': '', + '\\ ': ' ', + '\\quad': ' ', + '\\qquad': ' ', + + // Functions (LaTeX renders these in roman; we just keep the name) + '\\sin': 'sin', + '\\cos': 'cos', + '\\tan': 'tan', + '\\cot': 'cot', + '\\sec': 'sec', + '\\csc': 'csc', + '\\arcsin': 'arcsin', + '\\arccos': 'arccos', + '\\arctan': 'arctan', + '\\sinh': 'sinh', + '\\cosh': 'cosh', + '\\tanh': 'tanh', + '\\log': 'log', + '\\ln': 'ln', + '\\exp': 'exp', + '\\det': 'det', + '\\dim': 'dim', + '\\ker': 'ker', + '\\lim': 'lim', + '\\liminf': 'liminf', + '\\limsup': 'limsup', + '\\sup': 'sup', + '\\inf': 'inf', + '\\max': 'max', + '\\min': 'min', + '\\arg': 'arg', + '\\gcd': 'gcd', + + // Escaped literals — model occasionally emits these for display + '\\&': '&', + '\\%': '%', + '\\$': '$', + '\\#': '#', + '\\_': '_', + '\\{': '{', + '\\}': '}' +} + +const BB: Record = { + A: '𝔸', + B: '𝔹', + C: 'ℂ', + D: '𝔻', + E: '𝔼', + F: '𝔽', + G: '𝔾', + H: 'ℍ', + I: '𝕀', + J: '𝕁', + K: '𝕂', + L: '𝕃', + M: '𝕄', + N: 'ℕ', + O: '𝕆', + P: 'ℙ', + Q: 'ℚ', + R: 'ℝ', + S: '𝕊', + T: '𝕋', + U: '𝕌', + V: '𝕍', + W: '𝕎', + X: '𝕏', + Y: '𝕐', + Z: 'ℤ' +} + +const CAL: Record = { + A: '𝒜', + B: 'ℬ', + C: '𝒞', + D: '𝒟', + E: 'ℰ', + F: 'ℱ', + G: '𝒢', + H: 'ℋ', + I: 'ℐ', + J: '𝒥', + K: '𝒦', + L: 'ℒ', + M: 'ℳ', + N: '𝒩', + O: '𝒪', + P: '𝒫', + Q: '𝒬', + R: 'ℛ', + S: '𝒮', + T: '𝒯', + U: '𝒰', + V: '𝒱', + W: '𝒲', + X: '𝒳', + Y: '𝒴', + Z: '𝒵' +} + +const FRAK: Record = { + A: '𝔄', + B: '𝔅', + C: 'ℭ', + D: '𝔇', + E: '𝔈', + F: '𝔉', + G: '𝔊', + H: 'ℌ', + I: 'ℑ', + J: '𝔍', + K: '𝔎', + L: '𝔏', + M: '𝔐', + N: '𝔑', + O: '𝔒', + P: '𝔓', + Q: '𝔔', + R: 'ℜ', + S: '𝔖', + T: '𝔗', + U: '𝔘', + V: '𝔙', + W: '𝔚', + X: '𝔛', + Y: '𝔜', + Z: 'ℨ' +} + +const SUPERSCRIPT: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + '+': '⁺', + '-': '⁻', + '=': '⁼', + '(': '⁽', + ')': '⁾', + a: 'ᵃ', + b: 'ᵇ', + c: 'ᶜ', + d: 'ᵈ', + e: 'ᵉ', + f: 'ᶠ', + g: 'ᵍ', + h: 'ʰ', + i: 'ⁱ', + j: 'ʲ', + k: 'ᵏ', + l: 'ˡ', + m: 'ᵐ', + n: 'ⁿ', + o: 'ᵒ', + p: 'ᵖ', + r: 'ʳ', + s: 'ˢ', + t: 'ᵗ', + u: 'ᵘ', + v: 'ᵛ', + w: 'ʷ', + x: 'ˣ', + y: 'ʸ', + z: 'ᶻ' +} + +const SUBSCRIPT: Record = { + '0': '₀', + '1': '₁', + '2': '₂', + '3': '₃', + '4': '₄', + '5': '₅', + '6': '₆', + '7': '₇', + '8': '₈', + '9': '₉', + '+': '₊', + '-': '₋', + '=': '₌', + '(': '₍', + ')': '₎', + a: 'ₐ', + e: 'ₑ', + h: 'ₕ', + i: 'ᵢ', + j: 'ⱼ', + k: 'ₖ', + l: 'ₗ', + m: 'ₘ', + n: 'ₙ', + o: 'ₒ', + p: 'ₚ', + r: 'ᵣ', + s: 'ₛ', + t: 'ₜ', + u: 'ᵤ', + v: 'ᵥ', + x: 'ₓ' +} + +// Sentinel control characters used to mark `\boxed` / `\fbox` regions in +// the converted output. The renderer splits on these to apply a highlight +// style; consumers that don't want highlighting can strip them with the +// exported `BOX_RE` below. +export const BOX_OPEN = '\u0001' +export const BOX_CLOSE = '\u0002' +export const BOX_RE = /\u0001([^\u0001\u0002]*)\u0002/g + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +// Pre-compile two symbol regexes: one for letter-ending commands (`\pi`, +// `\sum`) which need a `(?![A-Za-z])` lookahead so they don't partially +// match `\pix` or `\summa`, and one for punctuation-ending commands +// (`\{`, `\,`, `\|`) which must NOT have the lookahead — otherwise +// `\{p` would refuse to substitute because `p` is a letter. +// +// Longest commands first inside each group so `\leq` beats `\le`. +const splitByEnding = (keys: string[]) => { + const letter: string[] = [] + const punct: string[] = [] + + for (const k of keys) { + if (/[A-Za-z]$/.test(k)) { + letter.push(k) + } else { + punct.push(k) + } + } + + return { letter, punct } +} + +const buildAlt = (cmds: string[]) => + cmds + .sort((a, b) => b.length - a.length) + .map(escapeRe) + .join('|') + +const { letter: LETTER_CMDS, punct: PUNCT_CMDS } = splitByEnding(Object.keys(SYMBOLS)) + +const SYMBOL_LETTER_RE = new RegExp('(?:' + buildAlt(LETTER_CMDS) + ')(?![A-Za-z])', 'g') +const SYMBOL_PUNCT_RE = new RegExp('(?:' + buildAlt(PUNCT_CMDS) + ')', 'g') + +const convertScript = (input: string, table: Record, sigil: '^' | '_'): string => { + let out = '' + let allMapped = true + + for (const ch of input) { + const mapped = table[ch] + + if (!mapped) { + allMapped = false + + break + } + + out += mapped + } + + if (allMapped) { + return out + } + + // Fallback: if the body is a single visible character (e.g. `∞` after + // earlier symbol substitution), render it without braces — `^∞` reads + // far better than `^{∞}` in a terminal. Multi-char bodies that don't + // fully convert use parens (`e^(iπ)`) instead of braces (`e^{iπ}`) + // because parens are normal punctuation while braces look like + // unrendered LaTeX. + const trimmed = input.trim() + + if ([...trimmed].length === 1) { + return `${sigil}${trimmed}` + } + + return `${sigil}(${trimmed})` +} + +// Walk the string and parse `{...}` honouring nested braces. Unlike a +// `\{[^{}]*\}` regex this survives `\frac{|t|^{p-1}|P(t)|^p}{...}` where +// the numerator contains its own braces from a superscript. Returns the +// inner content (without the outer braces) and the offset just past the +// closing `}`. Returns null if there is no balanced brace at `start`. +const readBraced = (s: string, start: number): { content: string; end: number } | null => { + if (s[start] !== '{') { + return null + } + + let depth = 1 + let i = start + 1 + + while (i < s.length && depth > 0) { + const c = s[i] + + // Skip escapes — `\{` and `\}` inside a body are literal braces and + // should not change the brace counter. + if (c === '\\' && i + 1 < s.length) { + i += 2 + continue + } + + if (c === '{') { + depth++ + } else if (c === '}') { + depth-- + } + + if (depth > 0) { + i++ + } + } + + if (depth !== 0) { + return null + } + + return { content: s.slice(start + 1, i), end: i + 1 } +} + +// Replace every occurrence of `\command{arg}` using balanced-brace parsing +// (so `\boxed{x^{n+1}}` works where a `[^{}]*` regex would fail). The +// `render` callback receives the inner content already recursed-into, so +// `\boxed{\boxed{x}}` resolves outside-in cleanly. Unmatched `\command` +// (no following `{...}`) is preserved verbatim. +const replaceBracedCommand = (input: string, command: string, render: (content: string) => string): string => { + const cmdLen = command.length + let out = '' + let i = 0 + + while (i < input.length) { + const idx = input.indexOf(command, i) + + if (idx < 0) { + out += input.slice(i) + + return out + } + + const after = input[idx + cmdLen] + + if (after && /[A-Za-z]/.test(after)) { + out += input.slice(i, idx + cmdLen) + i = idx + cmdLen + continue + } + + out += input.slice(i, idx) + + let p = idx + cmdLen + + while (input[p] === ' ' || input[p] === '\t') p++ + + const arg = readBraced(input, p) + + if (!arg) { + out += input.slice(idx, p + 1) + i = p + 1 + continue + } + + out += render(replaceBracedCommand(arg.content, command, render)) + i = arg.end + } + + return out +} + +// Replace every `\frac{num}{den}` with `num/den` (parens around either +// side when its precedence demands it). The recursion handles nested +// fractions naturally: `\frac{1}{\frac{1}{x}}` collapses to `1/(1/x)` +// because we recurse into `den` before deciding whether to parenthesise. +const replaceFracs = (input: string): string => { + let out = '' + let i = 0 + + while (i < input.length) { + const idx = input.indexOf('\\frac', i) + + if (idx < 0) { + out += input.slice(i) + + return out + } + + const after = input[idx + 5] + + // `(?![A-Za-z])` — protect hypothetical commands like `\fraction`. + if (after && /[A-Za-z]/.test(after)) { + out += input.slice(i, idx + 5) + i = idx + 5 + continue + } + + out += input.slice(i, idx) + + let p = idx + 5 + + while (input[p] === ' ' || input[p] === '\t') p++ + + const num = readBraced(input, p) + + if (!num) { + out += input.slice(idx, p + 1) + i = p + 1 + continue + } + + p = num.end + + while (input[p] === ' ' || input[p] === '\t') p++ + + const den = readBraced(input, p) + + if (!den) { + out += input.slice(idx, p + 1) + i = p + 1 + continue + } + + out += `${wrapForFrac(replaceFracs(num.content))}/${wrapForFrac(replaceFracs(den.content))}` + i = den.end + } + + return out +} + +// Wrap multi-token expressions in parens so `\frac{a+b}{c}` becomes +// `(a+b)/c` rather than `a+b/c`. We wrap whenever inline `/` would +// change the meaning — that's any binary operator (`+`, `-`, `*`, `/`) +// or whitespace separating tokens. `*` and `/` matter because nested +// fractions and products like `\frac{a*b}{c}` and `\frac{1/x}{y}` would +// otherwise read as `a*b/c` (right-associative ambiguity) and `1/x/y`. +// Atomic factors like `n!`, `x^2`, `\sin x` don't trigger any of these +// and stay un-parenthesised — wrapping them just clutters the output. +const wrapForFrac = (expr: string) => { + const trimmed = expr.trim() + + if (!trimmed) { + return trimmed + } + + if (/^\(.*\)$/.test(trimmed)) { + return trimmed + } + + if (/[+\-/*]|\s/.test(trimmed)) { + return `(${trimmed})` + } + + return trimmed +} + +export function texToUnicode(input: string): string { + let s = input + + s = s.replace(/\\mathbb\s*\{([A-Za-z])\}/g, (raw, c: string) => BB[c] ?? raw) + s = s.replace(/\\mathcal\s*\{([A-Za-z])\}/g, (raw, c: string) => CAL[c] ?? raw) + s = s.replace(/\\mathfrak\s*\{([A-Za-z])\}/g, (raw, c: string) => FRAK[c] ?? raw) + s = s.replace(/\\mathbf\s*\{([^{}]+)\}/g, (_, c: string) => c) + s = s.replace(/\\mathit\s*\{([^{}]+)\}/g, (_, c: string) => c) + s = s.replace(/\\mathrm\s*\{([^{}]+)\}/g, (_, c: string) => c) + s = s.replace(/\\text\s*\{([^{}]+)\}/g, (_, c: string) => c) + s = s.replace(/\\operatorname\s*\{([^{}]+)\}/g, (_, c: string) => c) + + s = s.replace(/\\overline\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0305`) + s = s.replace(/\\hat\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0302`) + s = s.replace(/\\bar\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0304`) + s = s.replace(/\\tilde\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0303`) + s = s.replace(/\\vec\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u20D7`) + s = s.replace(/\\dot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0307`) + s = s.replace(/\\ddot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0308`) + + s = replaceFracs(s) + + // `\boxed{X}` / `\fbox{X}` highlight a final answer. Terminals can't + // draw a real box, so we wrap the content in U+0001 / U+0002 control + // characters — non-printable, never present in real text — and let the + // markdown renderer split on them and apply a highlight style (inverse + // video) to the bracketed region. This keeps `texToUnicode` pure-string + // while letting the React layer do the actual visual emphasis. + // Argument is parsed with balanced braces so nested `{...}` from + // superscripts / fractions inside the box survive. + s = replaceBracedCommand(s, '\\boxed', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`) + s = replaceBracedCommand(s, '\\fbox', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`) + + // `\xrightarrow{label}` / `\xleftarrow{label}` collapse to an arrow with + // the label inline. LaTeX renders the label above the arrow; in monospace + // we put it adjacent — `─label→` is the closest readable approximation. + // Run before the symbol pass so the label can still pick up Greek and + // operator substitutions afterwards. + s = s.replace(/\\xrightarrow\s*\{([^{}]*)\}/g, (_, label: string) => `─${label.trim()}→`) + s = s.replace(/\\xleftarrow\s*\{([^{}]*)\}/g, (_, label: string) => `←${label.trim()}─`) + s = s.replace(/\\Longrightarrow/g, '⟹') + s = s.replace(/\\Longleftarrow/g, '⟸') + s = s.replace(/\\Longleftrightarrow/g, '⟺') + + // `\pmod{p}` → ` (mod p)` (LaTeX adds parens automatically); `\pod{p}` + // is a paren-less variant; `\tag{n}` is the equation-number annotation + // shown to the right of an equation. Collapse to a single-space-prefixed + // bracketed form. The leading `\s*` in the pattern absorbs any whitespace + // already in the source so we don't end up with `b (mod p)` (double + // space) when the user wrote `b \pmod{p}`. + s = s.replace(/\s*\\pmod\s*\{([^{}]*)\}/g, (_, p: string) => ` (mod ${p.trim()})`) + s = s.replace(/\s*\\pod\s*\{([^{}]*)\}/g, (_, p: string) => ` (${p.trim()})`) + s = s.replace(/\s*\\tag\s*\{([^{}]*)\}/g, (_, n: string) => ` (${n.trim()})`) + + // `\big`, `\Big`, `\bigg`, `\Bigg` (with optional `l`/`r`/`m` suffix) + // are sizing wrappers analogous to `\left`/`\right` but without the + // automatic-pairing semantics. Strip them and leave whatever delimiter + // follows. The trailing `(?![A-Za-z])` protects `\bigtriangleup` and + // any other letter-continuation command from being shaved. + s = s.replace(/\\(?:Bigg|bigg|Big|big)[lrm]?(?![A-Za-z])/g, '') + + // Style / size hints that don't typeset any glyph and only affect how + // things would be sized in a real LaTeX engine. In a terminal every + // glyph is one monospace cell, so there's nothing to do — drop them + // (with any trailing whitespace) so they don't leak through as raw + // `\displaystyle` in the output. + s = s.replace(/\\(?:scriptscriptstyle|displaystyle|scriptstyle|textstyle|nolimits|limits)(?![A-Za-z])\s*/g, '') + + // `\left` and `\right` are sizing wrappers around any delimiter — bare + // (`\left(`), escaped (`\left\{`), or named (`\left\langle`). Strip the + // wrapper unconditionally and let the rest of the pipeline (or the + // upcoming symbol pass) handle whatever delimiter follows. The optional + // `.?` consumes `\left.` / `\right.` which mean "no delimiter". + // Lookahead `(?![A-Za-z])` keeps `\leftarrow` / `\leftrightarrow` safe. + s = s.replace(/\\left(?![A-Za-z])\.?/g, '') + s = s.replace(/\\right(?![A-Za-z])\.?/g, '') + + // Run symbol substitution BEFORE scripts so a body like `^{\infty}` + // becomes `^{∞}` first; convertScript can then either map ∞ to a + // superscript (it can't — Unicode lacks one) or fall back to `^∞` + // by stripping braces around the now-single-character body. + // + // Punctuation pass first — these can be followed by letters (`\{p` + // is "open-brace then p"), so the letter pass's `(?![A-Za-z])` rule + // would wrongly block them. + s = s.replace(SYMBOL_PUNCT_RE, m => SYMBOLS[m] ?? m) + s = s.replace(SYMBOL_LETTER_RE, m => SYMBOLS[m] ?? m) + + // Bare `^c` / `_c` handles ONLY alphanumerics and `+`/`-`/`=`. Parens + // are intentionally excluded because the braced-fallback above can + // emit `(...)` and we don't want a second pass to greedily convert + // its opening paren into `⁽` and orphan the closing one. + s = s.replace(/\^\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUPERSCRIPT, '^')) + s = s.replace(/\^([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUPERSCRIPT[ch] ?? raw) + s = s.replace(/_\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUBSCRIPT, '_')) + s = s.replace(/_([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUBSCRIPT[ch] ?? raw) + + return s +} diff --git a/packages/cli/src/lib/memory.ts b/packages/cli/src/lib/memory.ts new file mode 100644 index 0000000..fefa640 --- /dev/null +++ b/packages/cli/src/lib/memory.ts @@ -0,0 +1,187 @@ +import { createWriteStream } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { getHeapSnapshot, getHeapSpaceStatistics, getHeapStatistics } from 'node:v8' + +export type MemoryTrigger = 'auto-critical' | 'auto-high' | 'manual' + +export interface MemoryDiagnostics { + activeHandles: number + activeRequests: number + analysis: { + potentialLeaks: string[] + recommendation: string + } + memoryGrowthRate: { + bytesPerSecond: number + mbPerHour: number + } + memoryUsage: { + arrayBuffers: number + external: number + heapTotal: number + heapUsed: number + rss: number + } + nodeVersion: string + openFileDescriptors?: number + platform: string + resourceUsage: { + maxRSS: number + systemCPUTime: number + userCPUTime: number + } + smapsRollup?: string + timestamp: string + trigger: MemoryTrigger + uptimeSeconds: number + v8HeapSpaces?: { available: number; name: string; size: number; used: number }[] + v8HeapStats: { + detachedContexts: number + heapSizeLimit: number + mallocedMemory: number + nativeContexts: number + peakMallocedMemory: number + } +} + +export interface HeapDumpResult { + diagPath?: string + error?: string + heapPath?: string + success: boolean +} + +export async function captureMemoryDiagnostics(trigger: MemoryTrigger): Promise { + const usage = process.memoryUsage() + const heapStats = getHeapStatistics() + const resourceUsage = process.resourceUsage() + const uptimeSeconds = process.uptime() + + // Not available on Bun / older Node. + let heapSpaces: ReturnType | undefined + + try { + heapSpaces = getHeapSpaceStatistics() + } catch { + /* noop */ + } + + const internals = process as unknown as { + _getActiveHandles: () => unknown[] + _getActiveRequests: () => unknown[] + } + + const activeHandles = internals._getActiveHandles().length + const activeRequests = internals._getActiveRequests().length + const openFileDescriptors = await swallow(async () => (await readdir('/proc/self/fd')).length) + const smapsRollup = await swallow(() => readFile('/proc/self/smaps_rollup', 'utf8')) + + const nativeMemory = usage.rss - usage.heapUsed + // Real growth rate since STARTED_AT (captured at module load) — NOT a lifetime + // average of rss/uptime, which would report phantom "growth" for a stable process. + const elapsed = Math.max(0, uptimeSeconds - STARTED_AT.uptime) + const bytesPerSecond = elapsed > 0 ? (usage.rss - STARTED_AT.rss) / elapsed : 0 + const mbPerHour = (bytesPerSecond * 3600) / (1024 * 1024) + + const potentialLeaks = [ + heapStats.number_of_detached_contexts > 0 && + `${heapStats.number_of_detached_contexts} detached context(s) — possible component/closure leak`, + activeHandles > 100 && `${activeHandles} active handles — possible timer/socket leak`, + nativeMemory > usage.heapUsed && 'Native memory > heap — leak may be in native addons', + mbPerHour > 100 && `High memory growth rate: ${mbPerHour.toFixed(1)} MB/hour`, + openFileDescriptors && openFileDescriptors > 500 && `${openFileDescriptors} open FDs — possible file/socket leak` + ].filter((s): s is string => typeof s === 'string') + + return { + activeHandles, + activeRequests, + analysis: { + potentialLeaks, + recommendation: potentialLeaks.length + ? `WARNING: ${potentialLeaks.length} potential leak indicator(s). See potentialLeaks.` + : 'No obvious leak indicators. Inspect heap snapshot for retained objects.' + }, + memoryGrowthRate: { bytesPerSecond, mbPerHour }, + memoryUsage: { + arrayBuffers: usage.arrayBuffers, + external: usage.external, + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + rss: usage.rss + }, + nodeVersion: process.version, + openFileDescriptors, + platform: process.platform, + resourceUsage: { + maxRSS: resourceUsage.maxRSS * 1024, + systemCPUTime: resourceUsage.systemCPUTime, + userCPUTime: resourceUsage.userCPUTime + }, + smapsRollup, + timestamp: new Date().toISOString(), + trigger, + uptimeSeconds, + v8HeapSpaces: heapSpaces?.map(s => ({ + available: s.space_available_size, + name: s.space_name, + size: s.space_size, + used: s.space_used_size + })), + v8HeapStats: { + detachedContexts: heapStats.number_of_detached_contexts, + heapSizeLimit: heapStats.heap_size_limit, + mallocedMemory: heapStats.malloced_memory, + nativeContexts: heapStats.number_of_native_contexts, + peakMallocedMemory: heapStats.peak_malloced_memory + } + } +} + +export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promise { + try { + // Diagnostics first — heap-snapshot serialization can crash on very large + // heaps, and the JSON sidecar is the most actionable artifact if so. + const diagnostics = await captureMemoryDiagnostics(trigger) + const dir = process.env.CODER_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.coder', 'heapdumps') + + await mkdir(dir, { recursive: true }) + + const base = `coder-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}` + const heapPath = join(dir, `${base}.heapsnapshot`) + const diagPath = join(dir, `${base}.diagnostics.json`) + + await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 }) + await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 })) + + return { diagPath, heapPath, success: true } + } catch (e) { + return { error: e instanceof Error ? e.message : String(e), success: false } + } +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0B' + } + + const exp = Math.min(UNITS.length - 1, Math.floor(Math.log10(bytes) / 3)) + const value = bytes / 1024 ** exp + + return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)}${UNITS[exp]}` +} + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] + +const STARTED_AT = { rss: process.memoryUsage().rss, uptime: process.uptime() } + +// Returns undefined when the probe isn't available (non-Linux paths, sandboxed FS). +const swallow = async (fn: () => Promise): Promise => { + try { + return await fn() + } catch { + return undefined + } +} diff --git a/packages/cli/src/lib/memoryMonitor.ts b/packages/cli/src/lib/memoryMonitor.ts new file mode 100644 index 0000000..521e572 --- /dev/null +++ b/packages/cli/src/lib/memoryMonitor.ts @@ -0,0 +1,109 @@ +import { type HeapDumpResult, performHeapDump } from './memory.js' + +export type MemoryLevel = 'critical' | 'high' | 'normal' + +export interface MemorySnapshot { + heapUsed: number + level: MemoryLevel + rss: number +} + +export interface MemoryMonitorOptions { + criticalBytes?: number + highBytes?: number + intervalMs?: number + onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void + onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void +} + +const GB = 1024 ** 3 + +// Deferred @coder/tui import: loading `@coder/tui` at module top-level +// pulls the full ~414KB Ink bundle (React, renderer, components, hooks) onto +// the critical path before the Python gateway can even be spawned. That +// serialised roughly 150ms of Node work in front of gw.start() on every +// cold `coder --tui` launch. +// +// evictInkCaches only runs inside `tick()`, which fires on a 10s timer and +// only when heap pressure crosses the high-water mark — by then Ink has +// long since been loaded by the app entry. This dynamic import is a no-op +// on the hot path (module is already in the ESM cache); when a startup +// spike somehow trips the threshold before the app registers its own Ink +// import, we pay the load cost exactly once, inside the tick that needs it. +let _evictInkCaches: ((level: 'all' | 'half') => unknown) | null = null +let _evictInkCachesPromise: Promise<(level: 'all' | 'half') => unknown> | null = null + +async function _ensureEvictInkCaches(): Promise<(level: 'all' | 'half') => unknown> { + if (_evictInkCaches) { + return _evictInkCaches + } + + _evictInkCachesPromise ??= import('@coder/tui') + .then(mod => { + _evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown + + return _evictInkCaches + }) + .catch(err => { + _evictInkCachesPromise = null + throw err + }) + + return _evictInkCachesPromise +} + +export function startMemoryMonitor({ + criticalBytes = 2.5 * GB, + highBytes = 1.5 * GB, + intervalMs = 10_000, + onCritical, + onHigh +}: MemoryMonitorOptions = {}): () => void { + const dumped = new Set>() + const inFlight = new Set>() + + const tick = async () => { + const { heapUsed, rss } = process.memoryUsage() + const level: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal' + + if (level === 'normal') { + dumped.clear() + return + } + + if (dumped.has(level) || inFlight.has(level)) { + return + } + + inFlight.add(level) + + // Prune Ink content caches before dump/exit — half on 'high' (recoverable), + // full on 'critical' (post-dump RSS reduction, keeps user running). + // Deferred import keeps `@coder/tui` off the cold-start critical path; + // by the time a tick fires 10s after launch the app has already loaded + // the same module, so this resolves instantly from the ESM cache. + try { + try { + const evictInkCaches = await _ensureEvictInkCaches() + evictInkCaches(level === 'critical' ? 'all' : 'half') + } catch { + // Best-effort: if the dynamic import fails for any reason we still + // continue to the heap dump below so the user gets diagnostics. + } + + dumped.add(level) + const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null) + const snap: MemorySnapshot = { heapUsed, level, rss } + + ;(level === 'critical' ? onCritical : onHigh)?.(snap, dump) + } finally { + inFlight.delete(level) + } + } + + const handle = setInterval(() => void tick(), intervalMs) + + handle.unref?.() + + return () => clearInterval(handle) +} diff --git a/packages/cli/src/lib/messages.test.ts b/packages/cli/src/lib/messages.test.ts new file mode 100644 index 0000000..422ddb1 --- /dev/null +++ b/packages/cli/src/lib/messages.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { appendTranscriptMessage } from './messages.js' + +describe('appendTranscriptMessage', () => { + it('merges adjacent tool-only shelves into one transcript row', () => { + const out = appendTranscriptMessage([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }], { + kind: 'trail', + role: 'system', + text: '', + tools: ['Terminal("two") ✓'] + }) + + expect(out).toEqual([ + { kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] } + ]) + }) + + it('merges tool shelves into the nearest thinking shelf', () => { + const out = appendTranscriptMessage( + [{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓'] }], + { kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] } + ) + + expect(out).toEqual([ + { kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] } + ]) + }) +}) diff --git a/packages/cli/src/lib/messages.ts b/packages/cli/src/lib/messages.ts new file mode 100644 index 0000000..b8e8942 --- /dev/null +++ b/packages/cli/src/lib/messages.ts @@ -0,0 +1,8 @@ +import type { Msg, Role } from '../types.js' + +import { appendToolShelfMessage } from './liveProgress.js' + +export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => appendToolShelfMessage(prev, msg) + +export const upsert = (prev: Msg[], role: Role, text: string): Msg[] => + prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] diff --git a/packages/cli/src/lib/openExternalUrl.test.ts b/packages/cli/src/lib/openExternalUrl.test.ts new file mode 100644 index 0000000..a4d53a0 --- /dev/null +++ b/packages/cli/src/lib/openExternalUrl.test.ts @@ -0,0 +1,217 @@ +import type { ChildProcess, spawn as SpawnFn } from 'node:child_process' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { openCommand, openExternalUrl, parseSafeUrl } from './openExternalUrl.js' + +type SpawnLike = typeof SpawnFn + +describe('parseSafeUrl', () => { + it('accepts http and https URLs', () => { + expect(parseSafeUrl('https://example.com')?.href).toBe('https://example.com/') + expect(parseSafeUrl('http://example.com/path?q=1')?.href).toBe('http://example.com/path?q=1') + }) + + it('rejects file: URLs (would let a hostile model trigger arbitrary local handlers)', () => { + expect(parseSafeUrl('file:///etc/passwd')).toBeNull() + }) + + it('rejects javascript:, data:, and vbscript: URLs', () => { + expect(parseSafeUrl('javascript:alert(1)')).toBeNull() + expect(parseSafeUrl('data:text/html,')).toBeNull() + expect(parseSafeUrl('vbscript:msgbox')).toBeNull() + }) + + it('rejects mailto:, ftp:, and other non-web protocols', () => { + expect(parseSafeUrl('mailto:test@example.com')).toBeNull() + expect(parseSafeUrl('ftp://example.com')).toBeNull() + expect(parseSafeUrl('ssh://example.com')).toBeNull() + }) + + it('rejects unparseable strings', () => { + expect(parseSafeUrl('not a url')).toBeNull() + expect(parseSafeUrl('')).toBeNull() + }) + + it('rejects non-string inputs defensively', () => { + expect(parseSafeUrl(undefined as unknown as string)).toBeNull() + expect(parseSafeUrl(null as unknown as string)).toBeNull() + expect(parseSafeUrl(123 as unknown as string)).toBeNull() + }) +}) + +describe('openCommand', () => { + it('returns macOS open(1) on darwin', () => { + expect(openCommand('darwin')).toEqual({ command: 'open', args: [] }) + }) + + it('routes through explorer.exe on win32 — not cmd.exe — so URLs with & | ^ < > stay safe', () => { + // win32 must not route through cmd.exe — see comment in openCommand. + // Test pins the contract that we use explorer.exe (non-shell) so URLs + // with `&`/`|`/`^`/`<`/`>` aren't reparsed by cmd's tokenizer. + const cmd = openCommand('win32') + expect(cmd?.command).toBe('explorer.exe') + expect(cmd?.args).toEqual([]) + }) + + it('falls back to xdg-open on linux/bsd', () => { + expect(openCommand('linux')).toEqual({ command: 'xdg-open', args: [] }) + expect(openCommand('freebsd')).toEqual({ command: 'xdg-open', args: [] }) + expect(openCommand('openbsd')).toEqual({ command: 'xdg-open', args: [] }) + }) + + it('returns null for unknown platforms (aix, sunos, cygwin, etc.)', () => { + // Avoid optimistically dispatching xdg-open on platforms where it + // probably isn't installed — the caller's `if (!command) return false` + // path surfaces "no opener" honestly instead. + expect(openCommand('aix')).toBeNull() + expect(openCommand('sunos')).toBeNull() + expect(openCommand('cygwin')).toBeNull() + expect(openCommand('haiku')).toBeNull() + expect(openCommand('')).toBeNull() + }) +}) + +describe('openExternalUrl on unsupported platforms', () => { + it('returns false without spawning when the platform has no known opener', () => { + const spawn = vi.fn() as unknown as SpawnLike + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'aix' })).toBe(false) + expect(spawn).not.toHaveBeenCalled() + }) +}) + +describe('openExternalUrl', () => { + // Tracks the most recent fake child so tests can inspect its 'error' + // handlers and emit on it. Use a loose EventEmitter alias rather than + // ChildProcess — the latter's `unref` signature is strictly `() => void` + // and doesn't accept `vi.fn()` without a generic. + type FakeChild = EventEmitter & { unref: () => void } + + function mockSpawn(): { + spawn: SpawnLike + calls: Array<{ command: string; args: readonly string[] }> + lastChild: () => FakeChild | undefined + } { + const calls: Array<{ command: string; args: readonly string[] }> = [] + let lastChild: FakeChild | undefined + + const spawn = vi.fn((command: string, args: readonly string[]) => { + calls.push({ command, args }) + + // Use a real EventEmitter so .once('error', cb) wires up correctly + // and we can synthesize async failures by emitting 'error' from the + // test. The cast is the same one Node uses internally — ChildProcess + // extends EventEmitter. + const child = new EventEmitter() as FakeChild + + child.unref = () => {} + lastChild = child + + return child as unknown as ChildProcess + }) as unknown as SpawnLike + + return { spawn, calls, lastChild: () => lastChild } + } + + it('opens a normal https URL via the platform command', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('https://example.com/foo', { spawn, platform: () => 'darwin' })).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.command).toBe('open') + expect(calls[0]!.args).toEqual(['https://example.com/foo']) + }) + + it('uses xdg-open on linux', () => { + const { spawn, calls } = mockSpawn() + + openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' }) + expect(calls[0]!.command).toBe('xdg-open') + }) + + it('refuses to open file: URLs and does not spawn', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('file:///etc/passwd', { spawn, platform: () => 'darwin' })).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('refuses to open javascript: URLs and does not spawn', () => { + const { spawn, calls } = mockSpawn() + + expect(openExternalUrl('javascript:alert(1)', { spawn, platform: () => 'darwin' })).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('passes URLs containing shell metacharacters as plain args (no shell interpolation)', () => { + const { spawn, calls } = mockSpawn() + + // A URL with `; & ` plus URL-encoded backticks. spawn(..., args) without + // shell:true means the OS receives these as a single argv element. + const hostile = 'https://example.com/path%3Bevil%20%26%20rm%20-rf' + + openExternalUrl(hostile, { spawn, platform: () => 'darwin' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.args[calls[0]!.args.length - 1]).toBe(hostile) + }) + + it('on win32, a URL with & | ^ < > is forwarded as a single argv element via explorer.exe', () => { + const { spawn, calls } = mockSpawn() + + // Plain http URL with & in query (very common, e.g. analytics params) + // plus other cmd metacharacters that would split or reinterpret the + // command if win32 routed through cmd.exe /c start. Note that the URL + // parser percent-encodes `<` and `>` (which is fine — encoded forms + // can't be reinterpreted by any shell), but `&`, `|`, `^` survive + // and would tokenize cmd.exe if we ever regressed back to it. + const meta = 'https://example.com/q?a=1&b=2|c^df' + + expect(openExternalUrl(meta, { spawn, platform: () => 'win32' })).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.command).toBe('explorer.exe') + // The URL must arrive as exactly one argv element — not split on &/|/^/etc. + const forwarded = calls[0]!.args[0]! + expect(calls[0]!.args).toHaveLength(1) + expect(forwarded).toContain('a=1&b=2') + expect(forwarded).toContain('|c^d') + }) + + it('on win32, common http URLs with & query params are forwarded intact', () => { + const { spawn, calls } = mockSpawn() + const url = 'https://example.com/search?q=foo&page=2&utm_source=coder' + + openExternalUrl(url, { spawn, platform: () => 'win32' }) + expect(calls[0]!.args).toEqual([url]) + }) + + it('returns false on synchronous spawn failure', () => { + const spawn = vi.fn(() => { + throw new Error('ENOENT') + }) as unknown as SpawnLike + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(false) + }) + + it('does not crash the host when the spawned process emits an async error', () => { + // Real-world case: `xdg-open` / `explorer.exe` missing on PATH. spawn() + // returns a ChildProcess synchronously, then emits 'error' once the + // exec actually fails. Without a registered 'error' listener, Node + // re-throws the event as an uncaught exception → TUI dies. We attach + // a no-op listener inside openExternalUrl; this test pins that contract. + const { spawn, lastChild } = mockSpawn() + + expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(true) + + const child = lastChild() + expect(child).toBeDefined() + // Must have a listener registered BEFORE we emit, or EventEmitter will + // throw synchronously here (which is exactly the crash we're preventing). + expect(child!.listenerCount('error')).toBeGreaterThan(0) + + // Emit and assert it doesn't throw. If the listener weren't attached, + // this would throw 'Unhandled error' and fail the test. + expect(() => child!.emit('error', new Error('ENOENT: xdg-open not found'))).not.toThrow() + }) +}) diff --git a/packages/cli/src/lib/openExternalUrl.ts b/packages/cli/src/lib/openExternalUrl.ts new file mode 100644 index 0000000..6c095a8 --- /dev/null +++ b/packages/cli/src/lib/openExternalUrl.ts @@ -0,0 +1,158 @@ +import { spawn, type SpawnOptions } from 'node:child_process' +import { platform } from 'node:os' + +/** + * Opens an external URL in the user's default browser/handler. + * + * Wired into the Ink instance via `onHyperlinkClick` in entry.tsx, so any + * mouse click on a `` cell (or a row containing a plain-text URL the + * renderer detected) goes here. Mouse tracking inside the TUI prevents + * Terminal.app's native Cmd+click from firing — the click is captured + * before the terminal application sees it — so we have to handle the open + * ourselves. + * + * Safety: + * - http(s) only. Anything else (`file:`, `data:`, `javascript:`, etc.) is + * rejected — a hostile model could otherwise emit `` + * and trick a click into running an arbitrary local handler. + * - Hostname is parsed via `URL`; only well-formed URLs are forwarded. + * - Spawned via `child_process.spawn` with arg array (no shell), so a URL + * containing shell metacharacters (`;`, `&`, backticks) cannot be + * interpreted as a command. + * + * Returns `true` if the spawn was attempted, `false` if the open could + * not proceed — covers (a) URL rejected by `parseSafeUrl` (non-http(s), + * malformed, etc.), (b) no known opener for the current platform + * (`openCommand` returned null), or (c) `spawn()` threw synchronously + * before the child was created. Async failures after spawn (`'error'` + * event because the binary couldn't exec) still return `true` because + * the spawn was attempted — the no-op error listener absorbs the event + * so the TUI doesn't crash, and the user just doesn't see their browser + * pop. + */ +export function openExternalUrl(rawUrl: string, dependencies: OpenDependencies = {}): boolean { + const url = parseSafeUrl(rawUrl) + + if (!url) { + return false + } + + const spawnFn = dependencies.spawn ?? spawn + const platformId = dependencies.platform?.() ?? platform() + + const command = openCommand(platformId) + + if (!command) { + return false + } + + try { + const child = spawnFn(command.command, [...command.args, url.toString()], { + // Detach so closing the TUI later doesn't kill the browser process, + // and ignore stdio so we don't leak FDs into our raw-mode terminal. + // Without `ignore` here, Chrome's stderr can land in the alt screen. + detached: true, + stdio: 'ignore' + } satisfies SpawnOptions) + + // Async failure path: spawn returns a ChildProcess synchronously even + // when the binary is missing (ENOENT on `xdg-open` / `explorer.exe`), + // unreachable (EACCES), or otherwise unusable — the failure surfaces + // later as an 'error' event. Without a handler, an unhandled 'error' + // on an EventEmitter crashes Node, which would tear down the whole + // TUI. Attach a no-op listener BEFORE unref() so the event has a + // consumer; we already returned `true` synchronously, so the user + // just won't see their browser open — same as if the URL had been + // rejected upstream. + child.once('error', () => { + // Intentional no-op. The TUI keeps running; user gets no browser + // pop, which is the failure mode we promised in the doc comment. + }) + + child.unref() + + return true + } catch { + // spawn can also throw synchronously on argv-validation failures + // (e.g. NUL in the path). Treat it as a no-op rather than crashing. + return false + } +} + +export type OpenDependencies = { + spawn?: typeof spawn + platform?: () => string +} + +/** + * Validate and normalize a URL for opening externally. + * Exported for testing. + */ +export function parseSafeUrl(value: string): null | URL { + if (!value || typeof value !== 'string') { + return null + } + + let parsed: URL + + try { + parsed = new URL(value) + } catch { + return null + } + + // http(s) only — opening file://, data:, javascript:, vbscript:, etc. + // would let a malicious model run a local handler with attacker-controlled + // input on a single click. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + + // Reject empty or all-whitespace hostnames defensively. URL parsing + // accepts URLs like 'http:///foo' on some Node versions; we don't want + // to forward those to `open`. + if (!parsed.hostname.trim()) { + return null + } + + return parsed +} + +type OpenCommand = { command: string; args: readonly string[] } + +/** + * Per-platform open command. We deliberately avoid `cmd.exe /c start` on + * Windows even though it's the canonical example, because `start` is a cmd + * builtin: the URL string is reparsed by cmd's command-line tokenizer and + * characters like `&`, `|`, `^`, `<`, `>` either break the command or get + * interpreted as additional commands. That undermines the protocol + * allowlist's safety story and also breaks plain http(s) URLs with `&` in + * query strings. `explorer.exe ` is the safe, non-shell alternative — + * it invokes the registered protocol handler for http(s) without going + * through cmd. Linux/BSD use `xdg-open` directly with no shell wrapping. + * + * Returns null for platforms where we don't know a safe opener (e.g. `aix`, + * `sunos`, `cygwin`). The caller's `if (!command) return false` path then + * surfaces "no opener" instead of optimistically trying `xdg-open` on a + * platform that probably doesn't have it. + */ +export function openCommand(platformId: string): OpenCommand | null { + if (platformId === 'darwin') { + return { command: 'open', args: [] } + } + + if (platformId === 'win32') { + return { command: 'explorer.exe', args: [] } + } + + // Linux + the BSD family ship xdg-open via xdg-utils. Everything else + // (aix, sunos, cygwin, haiku, etc.) returns null so openExternalUrl's + // command-not-found fallback fires honestly. + const XDG_OPEN_PLATFORMS = new Set(['linux', 'freebsd', 'openbsd', 'netbsd', 'dragonfly']) + + if (XDG_OPEN_PLATFORMS.has(platformId)) { + return { command: 'xdg-open', args: [] } + } + + return null +} diff --git a/packages/cli/src/lib/osc52.ts b/packages/cli/src/lib/osc52.ts new file mode 100644 index 0000000..0f0573a --- /dev/null +++ b/packages/cli/src/lib/osc52.ts @@ -0,0 +1,73 @@ +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +export const OSC52_CLIPBOARD_QUERY = `${ESC}]52;c;?${BEL}` + +type OscResponse = { code: number; data: string; type: 'osc' } + +type OscQuerier = { + flush: () => Promise + send: (query: { match: (r: unknown) => r is T; request: string }) => Promise +} + +function wrapForMultiplexer(sequence: string): string { + if (process.env['TMUX']) { + return `${ESC}Ptmux;${sequence.split(ESC).join(ESC + ESC)}${ST}` + } + + if (process.env['STY']) { + return `${ESC}P${sequence}${ST}` + } + + return sequence +} + +export function buildOsc52ClipboardQuery(): string { + return wrapForMultiplexer(OSC52_CLIPBOARD_QUERY) +} + +export function parseOsc52ClipboardData(data: string): null | string { + const firstSep = data.indexOf(';') + + if (firstSep === -1) { + return null + } + + const selection = data.slice(0, firstSep) + const payload = data.slice(firstSep + 1) + + if ((selection !== 'c' && selection !== 'p') || !payload || payload === '?') { + return null + } + + try { + return Buffer.from(payload, 'base64').toString('utf8') + } catch { + return null + } +} + +export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs = 500): Promise { + if (!querier) { + return null + } + + const timeout = new Promise(resolve => setTimeout(() => resolve(), timeoutMs)) + + const query = querier.send({ + request: buildOsc52ClipboardQuery(), + match: (r: unknown): r is OscResponse => { + return !!r && typeof r === 'object' && (r as OscResponse).type === 'osc' && (r as OscResponse).code === 52 + } + }) + + const response = await Promise.race([query, timeout]) + + await querier.flush() + + return response ? parseOsc52ClipboardData(response.data) : null +} + +export const writeOsc52Clipboard = (s: string) => + process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`) diff --git a/packages/cli/src/lib/perfPane.tsx b/packages/cli/src/lib/perfPane.tsx new file mode 100644 index 0000000..e7cfd55 --- /dev/null +++ b/packages/cli/src/lib/perfPane.tsx @@ -0,0 +1,108 @@ +import React from 'react' +// Perf instrumentation for the full render pipeline. +// +// PerfPane (React.Profiler) → per-pane commit times +// logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write +// phases + yoga counters + scroll fast-path +// +// Both gate on CODER_DEV_PERF=1 and dump JSON-lines (default ~/.coder/perf.log, +// override CODER_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq. +// CODER_DEV_PERF_MS (default 2) skips sub-ms idle frames; set 0 to capture all. +// +// Zero cost when unset: PerfPane returns children directly, logFrameEvent is +// undefined so ink doesn't pay the timing cost. + +import { appendFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import type { FrameEvent } from '@coder/tui' +import { scrollFastPathStats } from '@coder/tui' +import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react' + +const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.CODER_DEV_PERF ?? '').trim()) +const THRESHOLD_MS = Number(process.env.CODER_DEV_PERF_MS ?? '2') || 0 +const LOG_PATH = process.env.CODER_DEV_PERF_LOG?.trim() || join(homedir(), '.coder', 'perf.log') + +let logReady = false + +const writeRow = (row: Record) => { + if (!logReady) { + logReady = true + + try { + mkdirSync(dirname(LOG_PATH), { recursive: true }) + } catch { + // Best-effort — never crash the TUI to log a sample. + } + } + + try { + appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`) + } catch { + /* best-effort */ + } +} + +const round2 = (n: number) => Math.round(n * 100) / 100 + +const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => { + if (actualMs < THRESHOLD_MS) { + return + } + + writeRow({ + actualMs: round2(actualMs), + baseMs: round2(baseMs), + commitTimeMs: round2(commitTime), + id, + phase, + src: 'react', + startTimeMs: round2(startTime), + ts: Date.now() + }) +} + +export function PerfPane({ children, id }: { children: ReactNode; id: string }) { + if (!ENABLED) { + return children + } + + return ( + + {children} + + ) +} + +export const logFrameEvent = ENABLED + ? (event: FrameEvent) => { + if (event.durationMs < THRESHOLD_MS) { + return + } + + writeRow({ + durationMs: round2(event.durationMs), + // Cumulative counters — consumers diff pairs to get per-frame deltas. + fastPath: { ...scrollFastPathStats, declined: { ...scrollFastPathStats.declined } }, + flickers: event.flickers.length ? event.flickers : undefined, + phases: event.phases + ? { + ...event.phases, + commit: round2(event.phases.commit), + diff: round2(event.phases.diff), + optimize: round2(event.phases.optimize), + prevFrameDrainMs: round2(event.phases.prevFrameDrainMs), + renderer: round2(event.phases.renderer), + write: round2(event.phases.write), + yoga: round2(event.phases.yoga) + } + : undefined, + src: 'frame', + ts: Date.now() + }) + } + : undefined + +export const PERF_ENABLED = ENABLED +export const PERF_LOG_PATH = LOG_PATH diff --git a/packages/cli/src/lib/platform.ts b/packages/cli/src/lib/platform.ts new file mode 100644 index 0000000..a2eaa54 --- /dev/null +++ b/packages/cli/src/lib/platform.ts @@ -0,0 +1,409 @@ +/** Platform-aware keybinding helpers. + * + * On macOS the "action" modifier is Cmd. Modern terminals that support kitty + * keyboard protocol report Cmd as `key.super`; legacy terminals often surface it + * as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace + * into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them. + * On other platforms the action modifier is Ctrl. + * Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also + * copy an active TUI selection, matching common terminal selection behavior. + */ + +export const isMac = process.platform === 'darwin' + +/** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ +export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean => + isMac ? key.meta || key.super === true : key.ctrl + +/** + * Accept raw Ctrl+ as an action shortcut on macOS, where `isActionMod` + * otherwise means Cmd. Two motivations: + * - Some macOS terminals rewrite Cmd navigation/deletion into readline control + * keys (Cmd+Left → Ctrl+A, Cmd+Right → Ctrl+E, Cmd+Backspace → Ctrl+U). + * - Ctrl+K (kill-to-end) and Ctrl+W (delete-word-back) are standard readline + * bindings that users expect to work regardless of platform, even though + * no terminal rewrites Cmd into them. + */ +export const isMacActionFallback = ( + key: { ctrl: boolean; meta: boolean; super?: boolean }, + ch: string, + target: 'a' | 'e' | 'u' | 'k' | 'w' +): boolean => isMac && key.ctrl && !key.meta && key.super !== true && ch.toLowerCase() === target + +/** Match action-modifier + a single character (case-insensitive). */ +export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean => + isActionMod(key) && ch.toLowerCase() === target + +export const isRemoteShell = (env: NodeJS.ProcessEnv = process.env): boolean => + Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) + +export const isCopyShortcut = ( + key: { ctrl: boolean; meta: boolean; super?: boolean }, + ch: string, + env: NodeJS.ProcessEnv = process.env +): boolean => + ch.toLowerCase() === 'c' && + (isAction(key, ch, 'c') || + (isRemoteShell(env) && (key.meta || key.super === true)) || + // VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u + // sequence with the super bit plus a benign ctrl bit. Accept that shape + // even though raw Ctrl+C should remain interrupt on local macOS. + (isMac && key.ctrl && (key.meta || key.super === true))) + +/** + * Voice recording toggle key — configurable via ``voice.record_key`` in + * ``settings.json`` (default ``ctrl+b``). + * + * Documented in tips.py, the Python CLI prompt_toolkit handler, and the + * settings.json default. The TUI honours the same config knob (#18994); + * when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O. + * + * Only the documented default (``ctrl+b``) additionally accepts the + * macOS action modifier (Cmd+B) — custom bindings like ``ctrl+o`` + * require the literal Ctrl bit so Cmd+O can't steal the shortcut. + */ +export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'super' + +/** Named (multi-character) keys we support, matching the CLI's + * prompt_toolkit binding shape (``c-space``, ``c-enter``, etc.) so a + * config value like ``ctrl+space`` binds in both runtimes. */ +export type VoiceRecordKeyNamed = 'backspace' | 'delete' | 'enter' | 'escape' | 'space' | 'tab' + +export interface ParsedVoiceRecordKey { + /** Single character (``'b'``, ``'o'``) when ``named`` is undefined, + * otherwise the named-key token (``'space'``, ``'enter'``…). Kept as + * one field for back-compat with the v1 ``{ ch, mod, raw }`` shape. */ + ch: string + mod: VoiceRecordKeyMod + named?: VoiceRecordKeyNamed + raw: string +} + +export const DEFAULT_VOICE_RECORD_KEY: ParsedVoiceRecordKey = { + ch: 'b', + mod: 'ctrl', + raw: 'ctrl+b' +} + +/** Modifier aliases. + * + * ``meta`` / ``cmd`` / ``command`` are intentionally absent. + * coder-ink sets ``key.meta`` for plain Alt/Option on every platform + * AND for Cmd on some legacy macOS terminals (Terminal.app without + * kitty-protocol passthrough). Accepting any of those as a literal + * modifier would produce a display/binding mismatch — a config like + * ``cmd+b`` would render as ``Cmd+B`` but silently fire on Alt+B, or + * never fire at all on legacy terminals even though the UI advertises + * it (Copilot round-6 review on #19835). Users on modern kitty-style + * terminals (iTerm2 CSI-u, Ghostty, Kitty, WezTerm, Alacritty) spell + * the platform action modifier ``super`` / ``win``, which match the + * unambiguous ``key.super`` bit. macOS users on Terminal.app stick + * with the documented ``ctrl+b``. + * + * Cross-runtime parity: the ``ctrl`` / ``control`` / ``alt`` / ``option`` / + * ``opt`` spellings are normalized identically in the classic CLI + * (``coder_cli/voice.py::normalize_voice_record_key_for_prompt_toolkit``) + * so one ``voice.record_key`` value binds the same shortcut in both + * runtimes (Copilot round-9 review on #19835). The ``super`` / + * ``win`` / ``windows`` spellings are TUI-only — prompt_toolkit has no + * super modifier, so the CLI falls back to the documented default and + * logs a warning at startup (Copilot round-11 review on #19835). */ +const _MOD_ALIASES: Record = { + alt: 'alt', + control: 'ctrl', + ctrl: 'ctrl', + option: 'alt', + opt: 'alt', + super: 'super', + win: 'super', + windows: 'super' +} + +/** Map config-string named tokens to the canonical name used at match time. + * + * Aliases mirror what prompt_toolkit accepts (``return`` ↔ ``enter``, + * ``esc`` ↔ ``escape``) so a config that round-trips through the CLI also + * binds in the TUI. */ +const _NAMED_KEY_ALIASES: Record = { + backspace: 'backspace', + bs: 'backspace', + del: 'delete', + delete: 'delete', + enter: 'enter', + esc: 'escape', + escape: 'escape', + ret: 'enter', + return: 'enter', + space: 'space', + spc: 'space', + tab: 'tab' +} + +/** ``useInputHandlers()`` intercepts these unconditionally before the + * voice check runs, so a binding like ``ctrl+c`` (interrupt), + * ``ctrl+d`` (quit), or ``ctrl+l`` (clear screen) would be advertised + * in /voice status but never fire push-to-talk. Reject at parse time + * so the user gets the documented Ctrl+B instead of a dead shortcut + * (Copilot round-4 review on #19835). + * + * ``ctrl+x`` is intentionally NOT here — it's only claimed during + * queue-edit (``queueEditIdx !== null``), so the voice binding works + * for most of the session and matches CLI parity for ``ctrl+`` + * bindings (Copilot round-8 review on #19835). */ +const _RESERVED_CTRL_CHARS = new Set(['c', 'd', 'l']) + +/** On macOS the action-modifier intercepts these editor chords via + * ``isCopyShortcut`` / ``isAction`` in ``useInputHandlers()``: + * - super+c → copy + * - super+d → exit + * - super+l → clear screen + * - super+v → paste (also claimed at the TextInput layer) + * On Linux/Windows those globals key off Ctrl instead of Super, so + * super+ bindings don't collide. Gate the rejection to darwin + * at parse time so kitty/CSI-u ``super+`` configs still work for + * non-mac users (Copilot round-8 review on #19835). */ +const _RESERVED_SUPER_CHARS = new Set(['c', 'd', 'l', 'v']) + +/** On macOS ``isActionMod`` accepts ``key.meta`` as the action + * modifier — but coder-ink reports Alt as ``key.meta`` on many + * terminals. So on darwin a configured ``alt+c`` / ``alt+d`` / ``alt+l`` + * gets swallowed by ``isCopyShortcut`` / ``isAction`` before the voice + * check runs. Block at parse time so /voice status doesn't advertise + * a shortcut that actually copies / quits / clears (Copilot round-12 + * review on #19835). */ +const _RESERVED_ALT_CHARS_MAC = new Set(['c', 'd', 'l']) + +interface RuntimeKeyEvent { + alt?: boolean + backspace?: boolean + ctrl: boolean + delete?: boolean + escape?: boolean + meta: boolean + return?: boolean + shift?: boolean + super?: boolean + tab?: boolean +} + +/** Match an ink ``key`` event against a parsed named key. The ink runtime + * sets one boolean per named key; ``space`` is a printable char so it + * arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */ +const _matchesNamedKey = ( + named: VoiceRecordKeyNamed, + key: RuntimeKeyEvent, + ch: string +): boolean => { + switch (named) { + case 'backspace': + return key.backspace === true + case 'delete': + return key.delete === true + case 'enter': + return key.return === true + case 'escape': + return key.escape === true + case 'space': + return ch === ' ' + case 'tab': + return key.tab === true + } +} + +/** + * Parse a config-string voice record key like ``ctrl+b`` / ``alt+r`` / + * ``ctrl+space`` into ``{mod, ch, named?}``. Accepts single characters + * AND the named tokens declared in ``_NAMED_KEY_ALIASES`` (``space``, + * ``enter``/``return``, ``tab``, ``escape``/``esc``, ``backspace``, + * ``delete``) — matching the keys prompt_toolkit accepts on the CLI + * side via the ``c-`` rewrite in ``cli.py``. + * + * Accepts ``unknown`` because the source is raw YAML via + * ``config.get full`` — a hand-edited ``voice.record_key: 1`` or + * ``voice.record_key: true`` would otherwise crash ``.trim()`` on a + * non-string scalar (Copilot round-3 review on #19835). Non-string / + * empty / unrecognised values fall back to the documented Ctrl+B + * default so a typo never silently disables the shortcut. + */ +export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => { + if (typeof raw !== 'string') { + return DEFAULT_VOICE_RECORD_KEY + } + + const lower = raw.trim().toLowerCase() + + if (!lower) { + return DEFAULT_VOICE_RECORD_KEY + } + + const parts = lower.split('+').map(p => p.trim()).filter(Boolean) + + if (!parts.length) { + return DEFAULT_VOICE_RECORD_KEY + } + + const last = parts[parts.length - 1] + const modCandidates = parts.slice(0, -1) + + // Reject multi-modifier chords (``ctrl+alt+r``, ``cmd+ctrl+b``) rather + // than silently dropping the extra modifier — the previous + // single-token validator made a typo bind a different shortcut than + // the user configured (Copilot round-3 review on #19835). The classic + // CLI only supports single-modifier bindings via prompt_toolkit's + // ``c-x`` / ``a-x`` rewrite in ``cli.py``, so this matches CLI parity. + if (modCandidates.length > 1) { + return DEFAULT_VOICE_RECORD_KEY + } + + // Require an explicit modifier. A bare ``o`` / ``space`` / ``escape`` + // has no sensible mapping: the CLI's prompt_toolkit binds the raw + // key (no rewrite) so bare-char configs would silently diverge + // between the two runtimes (Copilot round-4 review on #19835). + // Fall back to the documented default. + if (modCandidates.length === 0) { + return DEFAULT_VOICE_RECORD_KEY + } + + const norm = _MOD_ALIASES[modCandidates[0]] + + // Unknown modifier token (e.g. bare ``meta+b`` which is ambiguous on + // the wire) falls back to the documented default rather than + // silently coercing to Ctrl and producing a misleading bind. + if (!norm) { + return DEFAULT_VOICE_RECORD_KEY + } + + const mod = norm + + // Block bindings the TUI input handler intercepts before the voice + // check — ``ctrl+c`` / ``ctrl+d`` / ``ctrl+l`` would never actually + // fire push-to-talk, so advertising them in /voice status is a lie. + if (mod === 'ctrl' && last.length === 1 && _RESERVED_CTRL_CHARS.has(last)) { + return DEFAULT_VOICE_RECORD_KEY + } + + // Same for ``super+c`` / ``super+d`` / ``super+l`` / ``super+v`` on + // macOS only — those are copy / exit / clear / paste and get claimed + // by ``isCopyShortcut`` / ``isAction`` / the TextInput paste layer + // before voice has a chance to toggle. On Linux/Windows the TUI + // globals key off Ctrl (not Super), so kitty/CSI-u ``super+`` + // bindings stay usable for non-mac users. + if (isMac && mod === 'super' && last.length === 1 && _RESERVED_SUPER_CHARS.has(last)) { + return DEFAULT_VOICE_RECORD_KEY + } + + // On macOS coder-ink reports Alt as ``key.meta``, which ``isActionMod`` + // accepts as the mac action modifier. So ``alt+c`` / ``alt+d`` / ``alt+l`` + // collide with copy / exit / clear in ``useInputHandlers()`` before the + // voice check. Reject at parse time on darwin only — non-mac ``alt+`` + // bindings are still usable (Copilot round-12 review on #19835). + if (isMac && mod === 'alt' && last.length === 1 && _RESERVED_ALT_CHARS_MAC.has(last)) { + return DEFAULT_VOICE_RECORD_KEY + } + + if (last.length === 1) { + return { ch: last, mod, raw: lower } + } + + const named = _NAMED_KEY_ALIASES[last] + + if (named) { + return { ch: named, mod, named, raw: lower } + } + + // Unknown multi-character token (e.g. typo'd ``ctrl+spcae``) — fall back + // to the doc default rather than silently disabling the binding. + return DEFAULT_VOICE_RECORD_KEY +} + +/** Render a parsed key back as ``Ctrl+B`` / ``Ctrl+Space`` for status text. + * + * Platform-aware for the ``super`` modifier: renders ``Cmd`` on macOS and + * ``Super`` elsewhere. Previously rendered ``Cmd`` universally, which told + * Linux/Windows users the wrong modifier to press (Copilot review, round + * 2 on #19835). */ +export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => { + const modLabel = + parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1) + // Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single + // chars render upper-case to match the existing Ctrl+B convention. + const keyLabel = parsed.named + ? parsed.named[0].toUpperCase() + parsed.named.slice(1) + : parsed.ch.toUpperCase() + + return `${modLabel}+${keyLabel}` +} + +/** Whether the parsed binding is the documented default (ctrl+b). + * + * Compare on the parsed spec rather than ``raw`` so semantically-equal + * aliases (``control+b``, ``ctrl + b``) still get the macOS Cmd+B + * muscle-memory fallback (Copilot review, round 2 on #19835). */ +const _isDefaultVoiceKey = (parsed: ParsedVoiceRecordKey): boolean => + parsed.mod === DEFAULT_VOICE_RECORD_KEY.mod && + parsed.ch === DEFAULT_VOICE_RECORD_KEY.ch && + parsed.named === DEFAULT_VOICE_RECORD_KEY.named + +export const isVoiceToggleKey = ( + key: RuntimeKeyEvent, + ch: string, + configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY +): boolean => { + // Match the configured key first (single-char compare or named-key + // event-property check). Bail out before evaluating modifier shape + // so the wrong key never reaches the modifier guard. + if (configured.named) { + if (!_matchesNamedKey(configured.named, key, ch)) { + return false + } + } else if (ch.toLowerCase() !== configured.ch) { + return false + } + + // The parser rejects multi-modifier configs (``ctrl+shift+b`` etc.), + // so at match time Shift must always be clear — otherwise + // ``ctrl+tab`` would also fire on Ctrl+Shift+Tab and ``alt+enter`` + // on Alt+Shift+Enter, triggering a different chord than configured + // (Copilot round-5 review on #19835). + if (key.shift === true) { + return false + } + + switch (configured.mod) { + case 'alt': + // Most terminals surface Alt as either ``alt`` or ``meta``; accept + // both so the binding works across xterm-style and kitty-style + // protocols. Guard against ctrl/super bits so a chord like + // Ctrl+Alt+ or Cmd+Alt+ doesn't spuriously fire the + // alt binding. + // + // Bare Escape on coder-ink can arrive as ``key.meta=true`` on some + // terminals, so a configured ``alt+escape`` must not match that shape; + // require an explicit alt bit for escape chords (Copilot round-7 + // follow-up on #19835). + return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true + case 'ctrl': + // Require the Ctrl bit AND a clear Alt/Super so a chord like + // Ctrl+Alt+ / Ctrl+Cmd+ doesn't spuriously match + // ``ctrl+`` (Copilot round-6 review on #19835). + // + // The documented default (``ctrl+b``) additionally accepts the + // explicit ``key.super`` bit on macOS for Cmd+B muscle memory — + // but ONLY ``key.super`` (kitty-style), never ``key.meta``, since + // ``key.meta`` is coder-ink's Alt signal and accepting it would + // fire the binding on Alt+B. + if (key.ctrl) { + return !key.alt && !key.meta && key.super !== true + } + + return _isDefaultVoiceKey(configured) && isMac && key.super === true && !key.alt && !key.meta + case 'super': + // Require the explicit ``key.super`` bit (kitty-style protocol) + // AND clear Ctrl/Alt/Meta so Ctrl+Cmd+X or Alt+Cmd+X don't + // spuriously fire the super binding (Copilot round-6 review on + // #19835). Legacy-terminal users whose Cmd arrives as + // ``key.meta`` need a kitty-protocol terminal — see the + // _MOD_ALIASES doc-comment for the rationale. + return key.super === true && !key.ctrl && !key.alt && !key.meta + } +} diff --git a/packages/cli/src/lib/precisionWheel.ts b/packages/cli/src/lib/precisionWheel.ts new file mode 100644 index 0000000..4ddb447 --- /dev/null +++ b/packages/cli/src/lib/precisionWheel.ts @@ -0,0 +1,48 @@ +const PRECISION_WHEEL_FRAME_MS = 16 +const PRECISION_WHEEL_STICKY_MS = 80 + +export type PrecisionWheelState = { + active: boolean + dir: 0 | -1 | 1 + lastEventAtMs: number + lastScrollAtMs: number +} + +export type PrecisionWheelStep = { + active: boolean + entered: boolean + rows: 0 | 1 +} + +export function initPrecisionWheel(): PrecisionWheelState { + return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 } +} + +export function computePrecisionWheelStep( + state: PrecisionWheelState, + dir: -1 | 1, + hasModifier: boolean, + now: number +): PrecisionWheelStep { + const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS + + if (!active) { + state.active = false + + return { active: false, entered: false, rows: 0 } + } + + const entered = !state.active + + state.active = true + state.lastEventAtMs = now + + if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) { + return { active: true, entered, rows: 0 } + } + + state.dir = dir + state.lastScrollAtMs = now + + return { active: true, entered, rows: 1 } +} diff --git a/packages/cli/src/lib/prompt.ts b/packages/cli/src/lib/prompt.ts new file mode 100644 index 0000000..10961b9 --- /dev/null +++ b/packages/cli/src/lib/prompt.ts @@ -0,0 +1,35 @@ +const TERMUX_SAFE_PROMPT = '>' + +export function composerPromptText( + prompt: string, + profileName?: null | string, + shellMode = false, + termuxMode = false, + totalCols?: number +): string { + if (shellMode) { + return '$' + } + + if (termuxMode) { + // Termux fonts/terminal backends can render decorative prompt glyphs with + // ambiguous width; keep the live composer marker strictly single-cell ASCII + // so we never leave stale arrow artifacts while typing. + const basePrompt = TERMUX_SAFE_PROMPT + + // On very wide panes we can still include profile context. On narrow/mobile + // panes this burns precious columns and increases wrap/clipping risk. + const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false + if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) { + return `${profileName} ${basePrompt}` + } + + return basePrompt + } + + if (profileName && !['default', 'custom'].includes(profileName)) { + return `${profileName} ${prompt}` + } + + return prompt +} diff --git a/packages/cli/src/lib/reasoning.ts b/packages/cli/src/lib/reasoning.ts new file mode 100644 index 0000000..d80260d --- /dev/null +++ b/packages/cli/src/lib/reasoning.ts @@ -0,0 +1,55 @@ +const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const + +export interface SplitReasoning { + reasoning: string + text: string +} + +export function splitReasoning(input: string): SplitReasoning { + let text = input + const reasoning: string[] = [] + + for (const tag of TAGS) { + const paired = new RegExp(`<${tag}>([\\s\\S]*?)\\s*`, 'gi') + text = text.replace(paired, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + + // Anchor to start-of-input so a literal `` mid-prose (model quoting + // the word, code blocks containing the tag, etc.) doesn't eat every + // paragraph after it. Real unclosed reasoning blocks always lead the + // message — that's how reasoning models stream. See test + // "does not strip trailing prose after a stray mid-text mention". + const unclosed = new RegExp(`^\\s*<${tag}>([\\s\\S]*)$`, 'i') + text = text.replace(unclosed, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + } + + return { + reasoning: reasoning.join('\n\n').trim(), + text: text.trim() + } +} + +export const hasReasoningTag = (input: string) => { + for (const tag of TAGS) { + if (input.includes(`<${tag}>`)) { + return true + } + } + + return false +} diff --git a/packages/cli/src/lib/rpc.ts b/packages/cli/src/lib/rpc.ts new file mode 100644 index 0000000..f7f3896 --- /dev/null +++ b/packages/cli/src/lib/rpc.ts @@ -0,0 +1,41 @@ +import type { CommandDispatchResponse } from '../gateway/types.js' + +export type RpcResult = Record + +export const asRpcResult = (value: unknown): T | null => + !value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T) + +export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { + const o = asRpcResult(value) + + if (!o || typeof o.type !== 'string') { + return null + } + + const t = o.type + + if (t === 'exec' || t === 'plugin') { + return { type: t, output: typeof o.output === 'string' ? o.output : undefined } + } + + if (t === 'alias' && typeof o.target === 'string') { + return { type: 'alias', target: o.target } + } + + if (t === 'skill' && typeof o.name === 'string') { + return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } + } + + if (t === 'send' && typeof o.message === 'string') { + return { + type: 'send', + message: o.message, + notice: typeof o.notice === 'string' ? o.notice : undefined, + } + } + + return null +} + +export const rpcErrorMessage = (err: unknown) => + err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed' diff --git a/packages/cli/src/lib/subagentTree.ts b/packages/cli/src/lib/subagentTree.ts new file mode 100644 index 0000000..513559b --- /dev/null +++ b/packages/cli/src/lib/subagentTree.ts @@ -0,0 +1,355 @@ +import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js' + +const ROOT_KEY = '__root__' + +/** + * Reconstruct the subagent spawn tree from a flat event-ordered list. + * + * Grouping is by `parentId`; a missing `parentId` (or one pointing at an + * unknown subagent) is treated as a top-level spawn of the current turn. + * Children within a parent are sorted by `depth` then `index` — same key + * used in `turnController.upsertSubagent`, so render order matches spawn + * order regardless of network reordering of gateway events. + * + * Older gateways omit `parentId`; every subagent is then a top-level node + * and the tree renders flat — matching pre-observability behaviour. + */ +export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] { + if (!items.length) { + return [] + } + + const byParent = new Map() + const known = new Set() + + for (const item of items) { + known.add(item.id) + } + + for (const item of items) { + const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY + const bucket = byParent.get(parentKey) ?? [] + bucket.push(item) + byParent.set(parentKey, bucket) + } + + for (const bucket of byParent.values()) { + bucket.sort((a, b) => a.depth - b.depth || a.index - b.index) + } + + const build = (item: SubagentProgress): SubagentNode => { + const kids = byParent.get(item.id) ?? [] + const children = kids.map(build) + + return { aggregate: aggregate(item, children), children, item } + } + + return (byParent.get(ROOT_KEY) ?? []).map(build) +} + +/** + * Roll up counts for a node's whole subtree. Kept pure so the live view + * and the post-hoc replay can share the same renderer unchanged. + * + * `hotness` = tools per second across the subtree — a crude proxy for + * "how much work is happening in this branch". Used to colour tree rails + * in the overlay / inline view so the eye spots the expensive branch. + */ +export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate { + let totalTools = item.toolCount ?? 0 + let totalDuration = item.durationSeconds ?? 0 + let descendantCount = 0 + let activeCount = isRunning(item) ? 1 : 0 + let maxDepthFromHere = 0 + let inputTokens = item.inputTokens ?? 0 + let outputTokens = item.outputTokens ?? 0 + let costUsd = item.costUsd ?? 0 + let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0) + + for (const child of children) { + totalTools += child.aggregate.totalTools + totalDuration += child.aggregate.totalDuration + descendantCount += child.aggregate.descendantCount + 1 + activeCount += child.aggregate.activeCount + maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1) + inputTokens += child.aggregate.inputTokens + outputTokens += child.aggregate.outputTokens + costUsd += child.aggregate.costUsd + filesTouched += child.aggregate.filesTouched + } + + const hotness = totalDuration > 0 ? totalTools / totalDuration : 0 + + return { + activeCount, + costUsd, + descendantCount, + filesTouched, + hotness, + inputTokens, + maxDepthFromHere, + outputTokens, + totalDuration, + totalTools + } +} + +/** + * Count of subagents at each depth level, indexed by depth (0 = top level). + * Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD. + */ +export function widthByDepth(tree: readonly SubagentNode[]): number[] { + const widths: number[] = [] + + const walk = (nodes: readonly SubagentNode[], depth: number) => { + if (!nodes.length) { + return + } + + widths[depth] = (widths[depth] ?? 0) + nodes.length + + for (const node of nodes) { + walk(node.children, depth + 1) + } + } + + walk(tree, 0) + + return widths +} + +/** + * Flat totals across the full tree — feeds the summary chip header. + */ +export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate { + let totalTools = 0 + let totalDuration = 0 + let descendantCount = 0 + let activeCount = 0 + let maxDepthFromHere = 0 + let inputTokens = 0 + let outputTokens = 0 + let costUsd = 0 + let filesTouched = 0 + + for (const node of tree) { + totalTools += node.aggregate.totalTools + totalDuration += node.aggregate.totalDuration + descendantCount += node.aggregate.descendantCount + 1 + activeCount += node.aggregate.activeCount + maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1) + inputTokens += node.aggregate.inputTokens + outputTokens += node.aggregate.outputTokens + costUsd += node.aggregate.costUsd + filesTouched += node.aggregate.filesTouched + } + + const hotness = totalDuration > 0 ? totalTools / totalDuration : 0 + + return { + activeCount, + costUsd, + descendantCount, + filesTouched, + hotness, + inputTokens, + maxDepthFromHere, + outputTokens, + totalDuration, + totalTools + } +} + +/** + * Flatten the tree into visit order — useful for keyboard navigation and + * for "kill subtree" walks that fire one RPC per descendant. + */ +export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] { + const out: SubagentNode[] = [] + + const walk = (nodes: readonly SubagentNode[]) => { + for (const node of nodes) { + out.push(node) + walk(node.children) + } + } + + walk(tree) + + return out +} + +/** + * Collect every descendant's id for a given node (excluding the node itself). + */ +export function descendantIds(node: SubagentNode): string[] { + const ids: string[] = [] + + const walk = (children: readonly SubagentNode[]) => { + for (const child of children) { + ids.push(child.item.id) + walk(child.children) + } + } + + walk(node.children) + + return ids +} + +export function isRunning(item: Pick): boolean { + return item.status === 'running' || item.status === 'queued' +} + +const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const + +/** + * 8-step unicode bar sparkline from a positive-integer array. Zeroes render + * as spaces so a sparse tree doesn't read as equal activity at every depth. + */ +export function sparkline(values: readonly number[]): string { + if (!values.length) { + return '' + } + + const max = Math.max(...values) + + if (max <= 0) { + return ' '.repeat(values.length) + } + + return values + .map(v => { + if (v <= 0) { + return ' ' + } + + const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1)))) + + return SPARK_RAMP[idx] + }) + .join('') +} + +/** + * Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s` + */ +export function formatSummary(totals: SubagentAggregate): string { + const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`] + pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`) + + if (totals.totalTools > 0) { + pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`) + } + + if (totals.totalDuration > 0) { + pieces.push(fmtDuration(totals.totalDuration)) + } + + const tokens = totals.inputTokens + totals.outputTokens + + if (tokens > 0) { + pieces.push(`${fmtTokens(tokens)} tok`) + } + + if (totals.costUsd > 0) { + pieces.push(fmtCost(totals.costUsd)) + } + + if (totals.activeCount > 0) { + pieces.push(`⚡${totals.activeCount}`) + } + + return pieces.join(' · ') +} + +/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */ +export function fmtCost(usd: number): string { + if (!Number.isFinite(usd) || usd <= 0) { + return '' + } + + if (usd < 0.01) { + return '<$0.01' + } + + if (usd < 10) { + return `$${usd.toFixed(2)}` + } + + return `$${usd.toFixed(1)}` +} + +/** Compact token count: `12k`, `1.2k`, `542`. */ +export function fmtTokens(n: number): string { + if (!Number.isFinite(n) || n <= 0) { + return '0' + } + + if (n < 1000) { + return String(Math.round(n)) + } + + if (n < 10_000) { + return `${(n / 1000).toFixed(1)}k` + } + + return `${Math.round(n / 1000)}k` +} + +/** + * `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents + * overlay so the timeline + list + summary all speak the same dialect. + */ +export function fmtDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.max(0, Math.round(seconds))}s` + } + + const m = Math.floor(seconds / 60) + const s = Math.round(seconds - m * 60) + + return s === 0 ? `${m}m` : `${m}m ${s}s` +} + +/** + * A subagent is top-level if it has no `parentId`, or its parent isn't in + * the same snapshot (orphaned by a pruned mid-flight root). Same rule + * `buildSubagentTree` uses — keep call sites consistent across the live + * view, disk label, and diff pane. + */ +export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] { + const ids = new Set(items.map(s => s.id)) + + return items.filter(s => !s.parentId || !ids.has(s.parentId)) +} + +/** + * Normalize a node's hotness into a palette index 0..N-1 where N = buckets. + * Higher hotness = "hotter" colour. Normalized against the tree's peak hotness + * so a uniformly slow tree still shows gradient across its busiest branches. + */ +export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number { + if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) { + return 0 + } + + const ratio = Math.min(1, hotness / peakHotness) + + return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1)))) +} + +export function peakHotness(tree: readonly SubagentNode[]): number { + let peak = 0 + + const walk = (nodes: readonly SubagentNode[]) => { + for (const node of nodes) { + peak = Math.max(peak, node.aggregate.hotness) + walk(node.children) + } + } + + walk(tree) + + return peak +} diff --git a/packages/cli/src/lib/syntax.ts b/packages/cli/src/lib/syntax.ts new file mode 100644 index 0000000..3b66f6d --- /dev/null +++ b/packages/cli/src/lib/syntax.ts @@ -0,0 +1,117 @@ +import type { Theme } from '../theme.js' + +export type Token = [string, string] + +interface LangSpec { + comment: null | string + keywords: Set +} + +const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean)) + +const TS = KW(` + abstract as async await break case catch class const continue debugger default delete do else enum export extends + false finally for from function get if implements import in instanceof interface is let new null of package private + protected public readonly return set static super switch this throw true try type typeof undefined var void while + with yield +`) + +const PY = KW(` + False None True and as assert async await break class continue def del elif else except finally for from global if + import in is lambda nonlocal not or pass raise return try while with yield +`) + +const SH = KW(` + if then else elif fi for in do done while until case esac function return break continue local export readonly + declare typeset +`) + +const GO = KW(` + break case chan const continue default defer else fallthrough for func go goto if import interface map package range + return select struct switch type var nil true false +`) + +const RUST = KW(` + as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut + pub ref return self Self static struct super trait true type unsafe use where while yield +`) + +const SQL = KW(` + select from where and or not in is null as by group order limit offset insert into values update set delete create + table drop alter add column primary key foreign references join left right inner outer on +`) + +const LANGS: Record = { + go: { comment: '//', keywords: GO }, + json: { comment: null, keywords: KW('true false null') }, + py: { comment: '#', keywords: PY }, + rust: { comment: '//', keywords: RUST }, + sh: { comment: '#', keywords: SH }, + sql: { comment: '--', keywords: SQL }, + ts: { comment: '//', keywords: TS }, + yaml: { comment: '#', keywords: KW('true false null yes no on off') } +} + +const ALIAS: Record = { + bash: 'sh', + javascript: 'ts', + js: 'ts', + jsx: 'ts', + python: 'py', + rs: 'rust', + shell: 'sh', + tsx: 'ts', + typescript: 'ts', + yml: 'yaml', + zsh: 'sh' +} + +const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null + +export const isHighlightable = (lang: string): boolean => resolve(lang) !== null + +const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g + +export function highlightLine(line: string, lang: string, t: Theme): Token[] { + const spec = resolve(lang) + + if (!spec) { + return [['', line]] + } + + if (spec.comment && line.trimStart().startsWith(spec.comment)) { + return [[t.color.muted, line]] + } + + const tokens: Token[] = [] + let last = 0 + + for (const m of line.matchAll(TOKEN_RE)) { + const start = m.index ?? 0 + + if (start > last) { + tokens.push(['', line.slice(last, start)]) + } + + const tok = m[0] + const ch = tok[0]! + + if (ch === '"' || ch === "'" || ch === '`') { + tokens.push([t.color.accent, tok]) + } else if (ch >= '0' && ch <= '9') { + tokens.push([t.color.text, tok]) + } else if (spec.keywords.has(tok)) { + tokens.push([t.color.border, tok]) + } else { + tokens.push(['', tok]) + } + + last = start + tok.length + } + + if (last < line.length) { + tokens.push(['', line.slice(last)]) + } + + return tokens +} diff --git a/packages/cli/src/lib/terminalModes.ts b/packages/cli/src/lib/terminalModes.ts new file mode 100644 index 0000000..79d6981 --- /dev/null +++ b/packages/cli/src/lib/terminalModes.ts @@ -0,0 +1,51 @@ +import { writeSync } from 'node:fs' + +export const TERMINAL_MODE_RESET = + '\x1b[0\'z' + // DEC locator reporting + '\x1b[0\'{' + // selectable locator events + '\x1b[?2029l' + // passive mouse + '\x1b[?1016l' + // SGR-pixels mouse + '\x1b[?1015l' + // urxvt decimal mouse + '\x1b[?1006l' + // SGR mouse + '\x1b[?1005l' + // UTF-8 extended mouse + '\x1b[?1003l' + // any-motion mouse + '\x1b[?1002l' + // button-motion mouse + '\x1b[?1001l' + // highlight mouse + '\x1b[?1000l' + // click mouse + '\x1b[?9l' + // X10 mouse + '\x1b[?1004l' + // focus events + '\x1b[?2004l' + // bracketed paste + '\x1b[?1049l' + // alternate screen + '\x1b[4m' + // modifyOtherKeys + '\x1b[0m' + // attributes + '\x1b[?25h' // cursor visible + +type ResettableStream = Pick & { + fd?: number +} + +export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean { + if (!stream.isTTY) { + return false + } + + const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined + if (fd !== undefined) { + try { + writeSync(fd, TERMINAL_MODE_RESET) + + return true + } catch { + // Fall through to stream.write for mocked or unusual TTY streams. + } + } + + try { + stream.write(TERMINAL_MODE_RESET) + + return true + } catch { + return false + } +} diff --git a/packages/cli/src/lib/terminalParity.ts b/packages/cli/src/lib/terminalParity.ts new file mode 100644 index 0000000..31be9f7 --- /dev/null +++ b/packages/cli/src/lib/terminalParity.ts @@ -0,0 +1,78 @@ +import { + detectVSCodeLikeTerminal, + type FileOps, + isRemoteShellSession, + shouldPromptForTerminalSetup +} from './terminalSetup.js' + +export type MacTerminalHint = { + key: string + message: string + tone: 'info' | 'warn' +} + +export type MacTerminalContext = { + isAppleTerminal: boolean + isRemote: boolean + isTmux: boolean + vscodeLike: null | 'cursor' | 'vscode' | 'windsurf' +} + +export function detectMacTerminalContext(env: NodeJS.ProcessEnv = process.env): MacTerminalContext { + const termProgram = env['TERM_PROGRAM'] ?? '' + + return { + isAppleTerminal: termProgram === 'Apple_Terminal' || !!env['TERM_SESSION_ID'], + isRemote: isRemoteShellSession(env), + isTmux: !!env['TMUX'], + vscodeLike: detectVSCodeLikeTerminal(env) + } +} + +export async function terminalParityHints( + env: NodeJS.ProcessEnv = process.env, + options?: { fileOps?: Partial; homeDir?: string } +): Promise { + const ctx = detectMacTerminalContext(env) + const hints: MacTerminalHint[] = [] + + if ( + ctx.vscodeLike && + (await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir })) + ) { + hints.push({ + key: 'ide-setup', + tone: 'info', + message: `Detected ${ctx.vscodeLike} terminal · run /terminal-setup for best Cmd+Enter / undo parity` + }) + } + + if (ctx.isAppleTerminal) { + hints.push({ + key: 'apple-terminal', + tone: 'warn', + message: + 'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten' + }) + } + + if (ctx.isTmux) { + hints.push({ + key: 'tmux', + tone: 'warn', + message: + 'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability' + }) + } + + if (ctx.isRemote) { + hints.push({ + key: 'remote', + tone: 'warn', + message: + 'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Coder' + }) + } + + return hints +} diff --git a/packages/cli/src/lib/terminalSetup.ts b/packages/cli/src/lib/terminalSetup.ts new file mode 100644 index 0000000..7d38779 --- /dev/null +++ b/packages/cli/src/lib/terminalSetup.ts @@ -0,0 +1,444 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf' + +export type FileOps = { + copyFile: typeof copyFile + mkdir: typeof mkdir + readFile: typeof readFile + writeFile: typeof writeFile +} + +type Keybinding = { + args?: { text?: string } + command?: string + key?: string + when?: string +} + +export type TerminalSetupResult = { + message: string + requiresRestart?: boolean + success: boolean +} + +const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile } +const COPY_SEQUENCE = '\u001b[99;13u' +const MULTILINE_SEQUENCE = '\\\r\n' + +const TERMINAL_META: Record = { + vscode: { appName: 'Code', label: 'VS Code' }, + cursor: { appName: 'Cursor', label: 'Cursor' }, + windsurf: { appName: 'Windsurf', label: 'Windsurf' } +} + +const MAC_COPY_BINDING: Keybinding = { + key: 'cmd+c', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus && terminalTextSelected', + args: { text: COPY_SEQUENCE } +} + +const BASE_BINDINGS: Keybinding[] = [ + { + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: MULTILINE_SEQUENCE } + }, + { + key: 'ctrl+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: MULTILINE_SEQUENCE } + }, + { + key: 'cmd+enter', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: MULTILINE_SEQUENCE } + }, + { + key: 'cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;9u' } + }, + { + key: 'shift+cmd+z', + command: 'workbench.action.terminal.sendSequence', + when: 'terminalFocus', + args: { text: '\u001b[122;10u' } + } +] + +const targetBindings = (platform: NodeJS.Platform): Keybinding[] => + platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS + +export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal { + const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? '' + + if (env['CURSOR_TRACE_ID'] || askpass.includes('cursor')) { + return 'cursor' + } + + if (askpass.includes('windsurf')) { + return 'windsurf' + } + + if (env['TERM_PROGRAM'] === 'vscode' || env['VSCODE_GIT_IPC_HANDLE']) { + return 'vscode' + } + + return null +} + +/** + * Strip JSONC features (// line comments, /* block comments *\/, trailing commas) + * so the result is valid JSON parseable by JSON.parse(). + * Handles comments inside strings correctly (preserves them). + */ +export function stripJsonComments(content: string): string { + let result = '' + let i = 0 + const len = content.length + + while (i < len) { + const ch = content[i]! + + // String literal — copy as-is, including any comment-like chars inside + if (ch === '"') { + let j = i + 1 + + while (j < len) { + if (content[j] === '\\') { + j += 2 // skip escaped char + } else if (content[j] === '"') { + j++ + + break + } else { + j++ + } + } + + result += content.slice(i, j) + i = j + + continue + } + + // Line comment + if (ch === '/' && content[i + 1] === '/') { + const eol = content.indexOf('\n', i) + i = eol === -1 ? len : eol + + continue + } + + // Block comment + if (ch === '/' && content[i + 1] === '*') { + const end = content.indexOf('*/', i + 2) + i = end === -1 ? len : end + 2 + + continue + } + + result += ch + i++ + } + + // Remove trailing commas before ] or } + return result.replace(/,(\s*[}\]])/g, '$1') +} + +export function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT']) +} + +export function getVSCodeStyleConfigDir( + appName: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + homeDir: string = homedir() +): null | string { + if (platform === 'darwin') { + return join(homeDir, 'Library', 'Application Support', appName, 'User') + } + + if (platform === 'win32') { + return env['APPDATA'] ? join(env['APPDATA'], appName, 'User') : null + } + + return join(homeDir, '.config', appName, 'User') +} + +function isKeybinding(value: unknown): value is Keybinding { + return typeof value === 'object' && value !== null +} + +function sameBinding(a: Keybinding, b: Keybinding): boolean { + return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text +} + +type WhenRequirements = { + forbidden: Set + required: Set +} + +const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g + +function parseWhenRequirements(when: string): WhenRequirements { + const required = new Set() + const forbidden = new Set() + + for (const [token] of when.matchAll(WHEN_TOKEN_RE)) { + if (token.startsWith('!')) { + forbidden.add(token.slice(1)) + } else { + required.add(token) + } + } + + return { forbidden, required } +} + +function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean { + for (const token of a.required) { + if (b.forbidden.has(token)) { + return true + } + } + + for (const token of b.required) { + if (a.forbidden.has(token)) { + return true + } + } + + return false +} + +function whensOverlap(a: string, b: string): boolean { + if (a === b) { + return true + } + + // Empty when = global, overlaps every context. + if (!a || !b) { + return true + } + + const left = parseWhenRequirements(a) + const right = parseWhenRequirements(b) + + if (requirementsContradict(left, right)) { + return false + } + + // This intentionally avoids a full VS Code when-clause parser. If two + // same-key bindings share a positive context token and don't explicitly + // contradict each other, they can fire together in that context. + for (const token of left.required) { + if (right.required.has(token)) { + return true + } + } + + return false +} + +// VS Code allows multiple bindings on the same key as long as their `when` +// clauses don't overlap. We flag a conflict when the contexts overlap but +// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with +// our `terminalFocus && terminalTextSelected`, so the existing binding +// would shadow ours when text isn't selected. +function bindingsConflict(existing: Keybinding, target: Keybinding): boolean { + if (existing.key !== target.key) { + return false + } + + if (!whensOverlap(existing.when ?? '', target.when ?? '')) { + return false + } + + return !sameBinding(existing, target) +} + +async function backupFile(filePath: string, ops: FileOps): Promise { + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + await ops.copyFile(filePath, `${filePath}.backup.${stamp}`) +} + +export async function configureTerminalKeybindings( + terminal: SupportedTerminal, + options?: { + env?: NodeJS.ProcessEnv + fileOps?: Partial + homeDir?: string + platform?: NodeJS.Platform + } +): Promise { + const env = options?.env ?? process.env + const platform = options?.platform ?? process.platform + const homeDir = options?.homeDir ?? homedir() + const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) } + const meta = TERMINAL_META[terminal] + + if (isRemoteShellSession(env)) { + return { + success: false, + message: `${meta.label} terminal setup must be run on the local machine, not inside an SSH session.` + } + } + + const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir) + + if (!configDir) { + return { + success: false, + message: `Could not determine ${meta.label} settings path on this platform.` + } + } + + const keybindingsFile = join(configDir, 'keybindings.json') + + try { + await ops.mkdir(configDir, { recursive: true }) + + let keybindings: unknown[] = [] + let hasExistingFile = false + + try { + const content = await ops.readFile(keybindingsFile, 'utf8') + hasExistingFile = true + const parsed: unknown = JSON.parse(stripJsonComments(content)) + + if (!Array.isArray(parsed)) { + return { + success: false, + message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}` + } + } + + keybindings = parsed + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + + if (code !== 'ENOENT') { + return { + success: false, + message: `Failed to read ${meta.label} keybindings: ${error}` + } + } + } + + const targets = targetBindings(platform) + + const conflicts = targets.filter(target => + keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target)) + ) + + if (conflicts.length) { + return { + success: false, + message: + `Existing terminal keybindings would conflict in ${keybindingsFile}: ` + conflicts.map(c => c.key).join(', ') + } + } + + let added = 0 + + for (const target of targets.slice().reverse()) { + const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + + if (!exists) { + keybindings.unshift(target) + added += 1 + } + } + + if (!added) { + return { + success: true, + message: `${meta.label} terminal keybindings already configured.` + } + } + + if (hasExistingFile) { + await backupFile(keybindingsFile, ops) + } + + await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8') + + return { + success: true, + requiresRestart: true, + message: `Added ${added} ${meta.label} terminal keybinding${added === 1 ? '' : 's'} in ${keybindingsFile}` + } + } catch (error) { + return { + success: false, + message: `Failed to configure ${meta.label} terminal shortcuts: ${error}` + } + } +} + +export async function configureDetectedTerminalKeybindings(options?: { + env?: NodeJS.ProcessEnv + fileOps?: Partial + homeDir?: string + platform?: NodeJS.Platform +}): Promise { + const detected = detectVSCodeLikeTerminal(options?.env ?? process.env) + + if (!detected) { + return { + success: false, + message: 'No supported IDE terminal detected. Supported: VS Code, Cursor, Windsurf.' + } + } + + return configureTerminalKeybindings(detected, options) +} + +export async function shouldPromptForTerminalSetup(options?: { + env?: NodeJS.ProcessEnv + fileOps?: Partial + homeDir?: string + platform?: NodeJS.Platform +}): Promise { + const env = options?.env ?? process.env + const detected = detectVSCodeLikeTerminal(env) + + if (!detected || isRemoteShellSession(env)) { + return false + } + + const platform = options?.platform ?? process.platform + const homeDir = options?.homeDir ?? homedir() + const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) } + const meta = TERMINAL_META[detected] + const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir) + + if (!configDir) { + return false + } + + try { + const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8') + const parsed: unknown = JSON.parse(stripJsonComments(content)) + + if (!Array.isArray(parsed)) { + return true + } + + return targetBindings(platform).some( + target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target)) + ) + } catch { + return true + } +} diff --git a/packages/cli/src/lib/termux.ts b/packages/cli/src/lib/termux.ts new file mode 100644 index 0000000..84d3b5d --- /dev/null +++ b/packages/cli/src/lib/termux.ts @@ -0,0 +1,29 @@ +const TERMUX_PREFIX = '/data/data/com.termux/files/usr' + +const truthy = (value?: string) => /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim()) + +export const isTermuxEnv = (env: NodeJS.ProcessEnv = process.env): boolean => { + const prefix = String(env.PREFIX ?? '') + + return Boolean(env.TERMUX_VERSION) || prefix.includes(TERMUX_PREFIX) +} + +/** + * Return true when Coder should enable Termux-focused TUI defaults. + * + * Defaults to on in Termux, with an explicit opt-out for debugging: + * CODER_TUI_TERMUX_MODE=0 + */ +export const isTermuxTuiMode = (env: NodeJS.ProcessEnv = process.env): boolean => { + if (!isTermuxEnv(env)) { + return false + } + + const override = String(env.CODER_TUI_TERMUX_MODE ?? '').trim().toLowerCase() + + if (override) { + return truthy(override) + } + + return true +} diff --git a/packages/cli/src/lib/text.test.ts b/packages/cli/src/lib/text.test.ts new file mode 100644 index 0000000..1a3800e --- /dev/null +++ b/packages/cli/src/lib/text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { stripTrailingPasteNewlines } from './text.js' + +describe('stripTrailingPasteNewlines', () => { + it('removes trailing newline runs from pasted text', () => { + expect(stripTrailingPasteNewlines('alpha\n')).toBe('alpha') + expect(stripTrailingPasteNewlines('alpha\nbeta\n\n')).toBe('alpha\nbeta') + }) + + it('preserves interior newlines', () => { + expect(stripTrailingPasteNewlines('alpha\nbeta\ngamma')).toBe('alpha\nbeta\ngamma') + }) + + it('preserves newline-only pastes', () => { + expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n') + }) +}) diff --git a/packages/cli/src/lib/text.ts b/packages/cli/src/lib/text.ts new file mode 100644 index 0000000..41ad0e5 --- /dev/null +++ b/packages/cli/src/lib/text.ts @@ -0,0 +1,418 @@ +import { + LIVE_RENDER_MAX_CHARS, + LIVE_RENDER_MAX_LINES, + THINKING_COT_MAX +} from '../config/limits.js' +import { VERBS } from '../content/verbs.js' +import type { ThinkingMode } from '../types.js' + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g') +const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g') +const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g') +const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g') +const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g') +const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g') +const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g +const WS_RE = /\s+/g + +export const stripAnsi = (s: string) => + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_CSI_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') + +export const sanitizeAnsiForRender = (s: string) => + s + .replace(ANSI_OSC_RE, '') + .replace(ANSI_STRING_RE, '') + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : '')) + .replace(ANSI_INCOMPLETE_CSI_RE, '') + .replace(ANSI_NON_CSI_ESC_SEQ_RE, '') + .replace(ANSI_STRAY_ESC_RE, '') + .replace(CONTROL_RE, '') + +export const hasAnsi = (s: string) => s.includes(ESC) + +const renderEstimateLine = (line: string) => { + const trimmed = line.trim() + + if (trimmed.startsWith('|')) { + return trimmed + .split('|') + .filter(Boolean) + .map(cell => cell.trim()) + .join(' ') + } + + return line + .replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]') + .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(? `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `) + .replace(/^\s*[-*+]\s+/, '• ') + .replace(/^\s*(\d+)\.\s+/, '$1. ') + .replace(/^\s*(?:>\s*)+/, '│ ') +} + +export const compactPreview = (s: string, max: number) => { + const one = s.replace(WS_RE, ' ').trim() + + return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one +} + +export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2) + +export const edgePreview = (s: string, head = 16, tail = 28) => { + const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]') + + return !one + ? '' + : one.length <= head + tail + 4 + ? one + : `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` +} + +export const pasteTokenLabel = (text: string, lineCount: number) => { + const preview = edgePreview(text) + + if (!preview) { + return `[[ [${fmtK(lineCount)} lines] ]]` + } + + const [head = preview, tail = ''] = preview.split('.. ', 2) + + return tail + ? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]` + : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` +} + +const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i') +const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu') + +export const cleanThinkingText = (reasoning: string) => + reasoning + .split('\n') + .map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim()) + .filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim())) + .join('\n') + .replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + +export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { + const raw = cleanThinkingText(reasoning) + + return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max) +} + +export const boundedLiveRenderText = ( + text: string, + { maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {} +) => boundedRenderText(text, 'showing live tail', { maxChars, maxLines }) + +const boundedRenderText = ( + text: string, + labelPrefix: string, + { maxChars, maxLines }: { maxChars: number; maxLines: number } +) => { + if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) { + return text + } + + let start = 0 + let idx = text.length + + for (let seen = 0; seen < maxLines && idx > 0; seen++) { + idx = text.lastIndexOf('\n', idx - 1) + start = idx < 0 ? 0 : idx + 1 + + if (idx < 0) { + break + } + } + + const lineStart = start + start = Math.max(lineStart, text.length - maxChars) + + if (start > lineStart) { + const nextBreak = text.indexOf('\n', start) + + if (nextBreak >= 0 && nextBreak < text.length - 1) { + start = nextBreak + 1 + } + } + + const tail = text.slice(start).trimStart() + const omittedLines = countNewlines(text, start) + const omittedChars = Math.max(0, text.length - tail.length) + + const label = + omittedLines > 0 + ? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n` + : `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n` + + return `${label}${tail}` +} + +const countNewlines = (text: string, end: number) => { + let count = 0 + + for (let i = 0; i < end; i++) { + if (text.charCodeAt(i) === 10) { + count++ + } + } + + return count +} + +export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) + +export const TOOL_ICONS: Record = { + Bash: '⚡', + Read: '📖', + Write: '✏️', + Edit: '🔧', + Glob: '🔍', + Grep: '🔎', + Git: '🌿', + TodoWrite: '📋', + TaskCreate: '➕', + TaskUpdate: '🔄', + WebFetch: '🌐', + WebSearch: '🔍', + AskUserQuestion: '❓', + ExitPlanMode: '🚪', + TaskList: '📊', + TaskOutput: '📤', +} + +export const TOOL_LABELS: Record = { + Bash: '执行命令', + Read: '读取文件', + Write: '写入文件', + Edit: '编辑文件', + Glob: '搜索文件', + Grep: '搜索内容', + Git: 'Git 操作', + TodoWrite: '更新任务', + TaskCreate: '创建任务', + TaskUpdate: '更新任务', + WebFetch: '获取网页', + WebSearch: '搜索网络', + AskUserQuestion: '询问用户', + ExitPlanMode: '退出规划', + TaskList: '列出任务', + TaskOutput: '获取输出', +} + +export const toolTrailLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(p => p[0]!.toUpperCase() + p.slice(1)) + .join(' ') || name + +export const extractToolArg = (name: string, context: string): string => { + if (!context) return '' + try { + const obj = JSON.parse(context) as Record + if (name === 'Bash' && typeof obj.command === 'string') return obj.command + if (typeof obj.file_path === 'string') return obj.file_path + if (typeof obj.command === 'string') return obj.command + for (const val of Object.values(obj)) { + if (typeof val === 'string' && val.trim()) return val + } + return '' + } catch { + return context.trim() + } +} + +export const parseToolArgs = (argsText: string): { command?: string; description?: string; filePath?: string } | null => { + if (!argsText) return null + try { + const obj = JSON.parse(argsText) as Record + return { + command: typeof obj.command === 'string' ? obj.command : undefined, + description: typeof obj.description === 'string' ? obj.description : undefined, + filePath: typeof obj.file_path === 'string' ? obj.file_path : undefined, + } + } catch { + return null + } +} + +export const formatToolCall = (name: string, context = '') => { + const label = toolTrailLabel(name) + const arg = extractToolArg(name, context) + const display = arg ? `${label}(${compactPreview(arg, 72)})` : label + return display +} + +export const buildToolTrailLine = ( + name: string, + context: string, + error?: boolean, + note?: string, + duration?: number +) => { + const detail = compactPreview(note ?? '', 72) + const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : '' + + return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` +} + +const verboseToolBlock = (label: string, text?: string) => { + const body = (text ?? '').trim() + + return body ? `${label}: ${boundedLiveRenderText(body)}` : '' +} + +export const buildVerboseToolTrailLine = ( + name: string, + context: string, + error?: boolean, + duration?: number, + argsText?: string, + resultText?: string, + headerLabel?: string, +) => { + const args = parseToolArgs(argsText ?? '') + const effectiveHeader = headerLabel ?? formatToolCall(name, context) + const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : '' + + // Always include the tool-call line so the TUI renders a bold tool name. + // Skip the legacy "Args:" block — the result (or error) is the signal. + // When there is no headerLabel (description), the header already shows the + // tool call — omit it from the detail to avoid showing it twice. + let detail: string + const toolLine = formatToolCall(name, args?.command ?? context) + const resultBlock = verboseToolBlock(error ? 'Error' : 'Result', resultText) + detail = headerLabel + ? [toolLine, resultBlock].filter(Boolean).join('\n') + : resultBlock + + return `${effectiveHeader}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}` +} + +export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') + +export const parseToolTrailResultLine = (line: string) => { + if (!isToolTrailResultLine(line)) { + return null + } + + const mark = line.endsWith(' ✗') ? '✗' : '✓' + const body = line.slice(0, -2) + const sep = body.indexOf(' :: ') + + if (sep >= 0) { + return { call: body.slice(0, sep), detail: body.slice(sep + 4), mark } + } + + const legacy = body.indexOf(': ') + + if (legacy > 0) { + return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark } + } + + return { call: body, detail: '', mark } +} + +export const splitToolDuration = (call: string) => { + const match = call.match(/^(.*?)( \(\d+(?:\.\d)?s\))$/) + + return match ? { label: match[1]!, duration: match[2]! } : { label: call, duration: '' } +} + +export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' + +export const sameToolTrailGroup = (label: string, entry: string) => + entry === `${label} ✓` || + entry === `${label} ✗` || + entry.startsWith(`${label}(`) || + entry.startsWith(`${label} ::`) || + entry.startsWith(`${label}:`) + +export const lastCotTrailIndex = (trail: readonly string[]) => { + for (let i = trail.length - 1; i >= 0; i--) { + if (!isToolTrailResultLine(trail[i]!)) { + return i + } + } + + return -1 +} + +export const estimateRows = (text: string, w: number, compact = false) => { + let fence: { char: '`' | '~'; len: number } | null = null + let rows = 0 + + for (const raw of text.split('\n')) { + const line = stripAnsi(raw) + const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/) + + if (maybeFence) { + const marker = maybeFence[1]! + const lang = maybeFence[2]!.trim() + + if (!fence) { + fence = { char: marker[0] as '`' | '~', len: marker.length } + + if (lang) { + rows += Math.ceil((`─ ${lang}`.length || 1) / w) + } + } else if (marker[0] === fence.char && marker.length >= fence.len) { + fence = null + } + + continue + } + + const inCode = Boolean(fence) + const trimmed = line.trim() + + if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) { + continue + } + + const rendered = inCode ? line : renderEstimateLine(line) + + if (compact && !rendered.trim()) { + continue + } + + rows += Math.ceil((rendered.length || 1) / w) + } + + return Math.max(1, rows) +} + +export const flat = (r: Record) => Object.values(r).flat() + +const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) + +export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) + +export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! + +export const isPasteBackedText = (text: string) => + /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/packages/cli/src/lib/todo.test.ts b/packages/cli/src/lib/todo.test.ts new file mode 100644 index 0000000..bf8befa --- /dev/null +++ b/packages/cli/src/lib/todo.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { todoGlyph, todoTone } from './todo.js' + +describe('todoGlyph', () => { + it('uses fixed-width ASCII markers so the active row does not render wide or emoji-like', () => { + expect(todoGlyph('completed')).toBe('[x]') + expect(todoGlyph('in_progress')).toBe('[>]') + expect(todoGlyph('pending')).toBe('[ ]') + expect(todoGlyph('cancelled')).toBe('[-]') + }) +}) + +describe('todoTone', () => { + it('keeps todo status rows neutral instead of red/green', () => { + expect(todoTone('completed')).toBe('dim') + expect(todoTone('cancelled')).toBe('dim') + expect(todoTone('pending')).toBe('body') + expect(todoTone('in_progress')).toBe('active') + }) +}) diff --git a/packages/cli/src/lib/todo.ts b/packages/cli/src/lib/todo.ts new file mode 100644 index 0000000..1846d02 --- /dev/null +++ b/packages/cli/src/lib/todo.ts @@ -0,0 +1,9 @@ +import type { TodoItem } from '../types.js' + +export type TodoTone = 'active' | 'body' | 'dim' + +export const todoGlyph = (status: TodoItem['status']) => + status === 'completed' ? '[x]' : status === 'cancelled' ? '[-]' : status === 'in_progress' ? '[>]' : '[ ]' + +export const todoTone = (status: TodoItem['status']): TodoTone => + status === 'in_progress' ? 'active' : status === 'pending' ? 'body' : 'dim' diff --git a/packages/cli/src/lib/viewportStore.ts b/packages/cli/src/lib/viewportStore.ts new file mode 100644 index 0000000..d97e9e6 --- /dev/null +++ b/packages/cli/src/lib/viewportStore.ts @@ -0,0 +1,124 @@ +import type { ScrollBoxHandle } from '@coder/tui' +import type { RefObject } from 'react' +import { useCallback, useMemo, useSyncExternalStore } from 'react' + +export interface ViewportSnapshot { + atBottom: boolean + bottom: number + pending: number + scrollHeight: number + top: number + viewportHeight: number +} + +export interface ScrollbarSnapshot { + scrollHeight: number + top: number + viewportHeight: number +} + +const EMPTY: ViewportSnapshot = { + atBottom: true, + bottom: 0, + pending: 0, + scrollHeight: 0, + top: 0, + viewportHeight: 0 +} + +const EMPTY_SCROLLBAR: ScrollbarSnapshot = { + scrollHeight: 0, + top: 0, + viewportHeight: 0 +} + +export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot { + if (!s) { + return EMPTY + } + + const pending = s.getPendingDelta() + const top = Math.max(0, s.getScrollTop() + pending) + const viewportHeight = Math.max(0, s.getViewportHeight()) + const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + let scrollHeight = cachedScrollHeight + const bottom = top + viewportHeight + let atBottom = s.isSticky() || bottom >= scrollHeight - 2 + + if (!atBottom) { + scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight) + atBottom = s.isSticky() || bottom >= scrollHeight - 2 + } + + return { + atBottom, + bottom, + pending, + scrollHeight, + top, + viewportHeight + } +} + +export function viewportSnapshotKey(v: ViewportSnapshot) { + return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}` +} + +export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot { + if (!s) { + return EMPTY_SCROLLBAR + } + + const viewportHeight = Math.max(0, s.getViewportHeight()) + const scrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + const maxTop = Math.max(0, scrollHeight - viewportHeight) + + return { + scrollHeight, + top: Math.max(0, Math.min(maxTop, s.getScrollTop())), + viewportHeight + } +} + +export function scrollbarSnapshotKey(v: ScrollbarSnapshot) { + return `${v.top}:${v.viewportHeight}:${v.scrollHeight}` +} + +export function useViewportSnapshot(scrollRef: RefObject): ViewportSnapshot { + const key = useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => viewportSnapshotKey(getViewportSnapshot(scrollRef.current)), + () => viewportSnapshotKey(EMPTY) + ) + + return useMemo(() => { + const [atBottom = '1', top = '0', viewportHeight = '0', scrollHeight = '0', pending = '0'] = key.split(':') + + return { + atBottom: atBottom === '1', + bottom: Number(top) + Number(viewportHeight), + pending: Number(pending), + scrollHeight: Number(scrollHeight), + top: Number(top), + viewportHeight: Number(viewportHeight) + } + }, [key]) +} + +export function useScrollbarSnapshot(scrollRef: RefObject): ScrollbarSnapshot { + const key = useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)), + () => scrollbarSnapshotKey(EMPTY_SCROLLBAR) + ) + + return useMemo(() => { + const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':') + + return { + scrollHeight: Number(scrollHeight), + top: Number(top), + viewportHeight: Number(viewportHeight) + } + }, [key]) +} diff --git a/packages/cli/src/lib/virtualHeights.ts b/packages/cli/src/lib/virtualHeights.ts new file mode 100644 index 0000000..0fa14e1 --- /dev/null +++ b/packages/cli/src/lib/virtualHeights.ts @@ -0,0 +1,145 @@ +import { TERMUX_TUI_MODE } from '../config/env.js' +import type { Msg } from '../types.js' + +import { transcriptBodyWidth } from './inputMetrics.js' + +const hashText = (text: string) => { + let h = 5381 + + for (let i = 0; i < text.length; i++) { + h = ((h << 5) + h) ^ text.charCodeAt(i) + } + + return (h >>> 0).toString(36) +} + +export const messageHeightKey = (msg: Msg) => { + const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? '' + + const panelSig = + msg.panelData?.sections + .map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`) + .join('\u0001') ?? '' + + const introSig = msg.kind === 'intro' ? (msg.info?.version || '0.1.0') : '' + + return [ + msg.role, + msg.kind ?? '', + hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0')) + ].join(':') +} + +// Hard cap on rows the estimator will count. Each row above this is +// invisible to the estimator (gets clipped to MAX_ESTIMATE_LINES), but +// post-mount Yoga measurement converges to the real height on first +// render. Without this, a long assistant turn (10k+ chars) costs O(text) +// per offset rebuild × every uncached item — cold-mounting a 1000-row +// transcript becomes a multi-million-char wrap walk that blocks the UI. +// +// 800 covers any realistic assistant message (the prior history-clip +// ceiling was 16 lines, then full text — this is the sane middle). +const MAX_ESTIMATE_LINES = 800 + +export const wrappedLines = (text: string, width: number, maxLines: number = MAX_ESTIMATE_LINES) => { + const w = Math.max(1, width) + // Worst case: every cell is its own row at width=1, plus a small + // slack for the trailing partial line. Walking past this byte budget + // cannot increase n any further once n is already past maxLines, so + // bail. Saves O(text) walks on multi-megabyte single-line messages. + const budget = Math.min(text.length, maxLines * w + maxLines) + let n = 0 + let start = 0 + + for (let i = 0; i <= budget; i++) { + if (i === text.length || i === budget || text.charCodeAt(i) === 10) { + const rows = Math.max(1, Math.ceil((i - start) / w)) + n += rows >= maxLines - n ? maxLines - n : rows + start = i + 1 + + if (n >= maxLines) { + return maxLines + } + } + } + + return n +} + +export const estimatedMsgHeight = ( + msg: Msg, + cols: number, + { + compact, + details, + thinkingVisible = details, + toolsVisible = details, + userPrompt = '', + withSeparator = false + }: { + compact: boolean + details: boolean + thinkingVisible?: boolean + toolsVisible?: boolean + userPrompt?: string + withSeparator?: boolean + } +) => { + if (msg.kind === 'intro') { + return 9 + } + + if (msg.kind === 'panel') { + return Math.max(3, (msg.panelData?.sections.length ?? 1) * 2 + 1) + } + + if (msg.kind === 'trail' && msg.todos?.length) { + if (msg.todoCollapsedByDefault) { + return 2 + } + + return Math.max(2, msg.todos.length + 2) + } + + const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE) + const text = msg.text + let h = wrappedLines(text || ' ', bodyWidth) + + if (!compact && msg.role === 'assistant') { + // Paragraph gaps add up to 6 extra rows of breathing room. Slice + // first so the regex never walks more than the first ~16k chars of + // a giant assistant message — post-mount Yoga measurement converges + // to the real height regardless of how the estimate undercounts. + const scan = text.length > 16_000 ? text.slice(0, 16_000) : text + h += Math.min(6, (scan.match(/\n\s*\n/g) ?? []).length) + } + + if (details) { + const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length) + const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '') + const hasVisibleDetails = hasVisibleTools || hasVisibleThinking + + if (hasVisibleDetails) { + h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0) + + if (msg.role === 'assistant' && /\S/.test(msg.text)) { + h += 2 + } + } + } + + if (msg.role === 'user' || msg.kind === 'diff') { + h += 2 + } else if (msg.kind === 'slash') { + h++ + } + + // Inter-turn separator above non-first user messages (1 rule row + 1 + // top-margin row). The render-side gate is in appLayout.tsx; we trust + // the caller to pass `withSeparator` only when it matches that gate. + if (withSeparator) { + h += 2 + } + + return Math.max(1, h) +} diff --git a/packages/cli/src/lib/wheelAccel.ts b/packages/cli/src/lib/wheelAccel.ts new file mode 100644 index 0000000..e291660 --- /dev/null +++ b/packages/cli/src/lib/wheelAccel.ts @@ -0,0 +1,190 @@ +// Wheel-scroll acceleration state machine. +// +// One event = 1 row feels sluggish on trackpads (200+ ev/s) and sustained +// mouse-wheel; one event = 6 rows teleports and ruins precision. +// Heuristic on inter-event gap + direction flips: +// +// gap < 5ms → same-batch burst → 1 row/event +// gap < 40ms (native) → ramp +0.3, cap 6 +// gap 80-500ms (xterm.js) → mult = 1 + (mult-1)·0.5^(gap/150) + 5·decay +// cap 3 slow / 6 fast +// gap > 500ms → reset (deliberate click stays responsive) +// flip + flip-back ≤200ms → encoder bounce → engage wheel-mode (sticky cap) +// 5 consecutive <5ms events → trackpad flick → disengage wheel-mode +// +// Native terminals (Ghostty, iTerm2) and xterm.js embedders (VS Code, +// Cursor) emit wheel events with different cadences, hence two paths. + +import { isXtermJs } from '@coder/tui' + +// ── Native (ghostty, iTerm2, WezTerm, …) ─────────────────────────────── +const WHEEL_ACCEL_WINDOW_MS = 40 +const WHEEL_ACCEL_STEP = 0.3 +const WHEEL_ACCEL_MAX = 6 + +// ── Encoder bounce / wheel-mode (mechanical wheels) ──────────────────── +const WHEEL_BOUNCE_GAP_MAX_MS = 200 +const WHEEL_MODE_STEP = 15 +const WHEEL_MODE_CAP = 15 +const WHEEL_MODE_RAMP = 3 +const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500 + +// ── xterm.js (VS Code / Cursor / browser terminals) ──────────────────── +const WHEEL_DECAY_HALFLIFE_MS = 150 +const WHEEL_DECAY_STEP = 5 +const WHEEL_BURST_MS = 5 +const WHEEL_DECAY_GAP_MS = 80 +const WHEEL_DECAY_CAP_SLOW = 3 +const WHEEL_DECAY_CAP_FAST = 6 +const WHEEL_DECAY_IDLE_MS = 500 + +export type WheelAccelState = { + time: number + mult: number + dir: 0 | 1 | -1 + xtermJs: boolean + /** Carried fractional scroll (xterm.js). scrollBy floors, so without + * this a mult of 1.5 always gives 1 row; carrying the remainder gives + * 1,2,1,2 — correct throughput over time. */ + frac: number + /** Native baseline rows/event. Reset on idle/reversal; ramp builds on + * top. xterm.js path ignores. */ + base: number + /** Deferred direction flip (native): bounce vs reversal — next event + * decides. */ + pendingFlip: boolean + /** Sticky once a flip-then-flip-back fires within the bounce window. + * Cleared by idle disengage or trackpad burst. */ + wheelMode: boolean + /** Consecutive <5ms events. ≥5 → trackpad flick → disengage. */ + burstCount: number +} + +export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { + return { burstCount: 0, base, dir: 0, frac: 0, mult: base, pendingFlip: false, time: 0, wheelMode: false, xtermJs } +} + +/** CODER_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability). + * Default 1, clamped (0, 20]. */ +export function readScrollSpeedBase(): number { + const n = parseFloat(process.env.CODER_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED ?? '') + + return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1 +} + +export function initWheelAccelForHost(): WheelAccelState { + return initWheelAccel(isXtermJs(), readScrollSpeedBase()) +} + +/** Compute rows for one wheel event, mutating `state`. Returns 0 when a + * direction flip is deferred for bounce detection — call sites should + * no-op on 0. */ +export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number { + return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now) +} + +function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { + // Idle disengage runs first so a pending bounce can't mask "user paused + // 1.5s then mouse-clicked" as a real reversal. + if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { + state.wheelMode = false + state.burstCount = 0 + state.mult = state.base + } + + if (state.pendingFlip) { + state.pendingFlip = false + + if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { + // Real reversal (flip persisted OR flip-back too late). Commit. + // The deferred event's 1 row is lost — acceptable latency. + state.dir = dir + state.time = now + state.mult = state.base + + return Math.floor(state.mult) + } + + state.wheelMode = true + } + + const gap = now - state.time + + if (dir !== state.dir && state.dir !== 0) { + state.pendingFlip = true + state.time = now + + return 0 + } + + state.dir = dir + state.time = now + + if (state.wheelMode) { + if (gap < WHEEL_BURST_MS) { + // Same-batch burst (SGR proportional) OR trackpad flick. 1 row/event; + // trackpad flick trips the burst-count disengage. + if (++state.burstCount >= 5) { + state.wheelMode = false + state.burstCount = 0 + state.mult = state.base + } else { + return 1 + } + } else { + state.burstCount = 0 + } + } + + if (state.wheelMode) { + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) + const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m + + state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP) + + return Math.floor(state.mult) + } + + // Trackpad / hi-res native: tight 40ms window — sub-window ramps, + // anything slower resets to baseline. + if (gap > WHEEL_ACCEL_WINDOW_MS) { + state.mult = state.base + } else { + const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2) + + state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP) + } + + return Math.floor(state.mult) +} + +function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number { + const gap = now - state.time + const sameDir = dir === state.dir + + state.time = now + state.dir = dir + + if (sameDir && gap < WHEEL_BURST_MS) { + return 1 + } + + if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { + // Reversal or long idle — start at 2 so first click after a pause moves visibly. + state.mult = 2 + state.frac = 0 + } else { + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) + const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST + + state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m) + } + + const total = state.mult + state.frac + const rows = Math.floor(total) + + state.frac = total - rows + + return rows +} diff --git a/packages/cli/src/protocol/interpolation.ts b/packages/cli/src/protocol/interpolation.ts new file mode 100644 index 0000000..804cf1c --- /dev/null +++ b/packages/cli/src/protocol/interpolation.ts @@ -0,0 +1,3 @@ +export const INTERPOLATION_RE = /\{!(.+?)\}/g + +export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s) diff --git a/packages/cli/src/protocol/paste.ts b/packages/cli/src/protocol/paste.ts new file mode 100644 index 0000000..9eae137 --- /dev/null +++ b/packages/cli/src/protocol/paste.ts @@ -0,0 +1 @@ +export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g diff --git a/packages/cli/src/services/session-service.ts b/packages/cli/src/services/session-service.ts new file mode 100644 index 0000000..223ff3b --- /dev/null +++ b/packages/cli/src/services/session-service.ts @@ -0,0 +1,208 @@ +/** + * session-service.ts — CLI session service + * + * Wraps SessionManager and CheckpointManager for CLI use cases: + * - Resume / Continue / Fork from command-line args + * - Session persistence to ~/.coder/sessions/ + * - Checkpoint integration (auto-create on session start) + */ + +import { SessionManager, CheckpointManager } from '@coder/core'; +import type { Checkpoint } from '@coder/core'; +import type { Session, SessionSummary } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Singletons +// --------------------------------------------------------------------------- + +let _sessionManager: SessionManager | null = null; +let _checkpointManager: CheckpointManager | null = null; + +export function getSessionManager(): SessionManager { + if (!_sessionManager) { + _sessionManager = new SessionManager(); + } + return _sessionManager; +} + +export function getCheckpointManager(): CheckpointManager { + if (!_checkpointManager) { + _checkpointManager = new CheckpointManager(); + } + return _checkpointManager; +} + +// --------------------------------------------------------------------------- +// Session Operations +// --------------------------------------------------------------------------- + +export interface CreateSessionOptions { + title?: string; + cwd?: string; + model?: string; + provider?: string; + parentSessionId?: string; + baseCommit?: string; +} + +export async function createSession(options: CreateSessionOptions = {}): Promise<{ + session: Session; + checkpoint: Checkpoint | null; +}> { + const sm = getSessionManager(); + const cm = getCheckpointManager(); + + const session = sm.create({ + title: options.title, + cwd: options.cwd ?? process.cwd(), + model: options.model, + provider: options.provider, + parentSessionId: options.parentSessionId, + baseCommit: options.baseCommit, + }); + + let checkpoint: Checkpoint | null = null; + try { + checkpoint = await cm.create({ + sessionId: session.id, + cwd: session.cwd, + description: `Initial checkpoint for session ${session.id.slice(0, 8)}`, + }); + } catch { + // Non-git directory — checkpoint is optional + } + + return { session, checkpoint }; +} + +export async function resumeSession(sessionId: string): Promise<{ + session: Session; + checkpoint: Checkpoint | null; +}> { + const sm = getSessionManager(); + const cm = getCheckpointManager(); + + const session = sm.resume(sessionId); + cm.loadFromDisk(sessionId); + + let checkpoint: Checkpoint | null = null; + try { + checkpoint = await cm.create({ + sessionId: session.id, + cwd: session.cwd, + description: `Resume checkpoint for session ${session.id.slice(0, 8)}`, + }); + } catch { + // Non-git directory — checkpoint is optional + } + + return { session, checkpoint }; +} + +export async function continueLatestSession(): Promise<{ + session: Session; + checkpoint: Checkpoint | null; +}> { + const sm = getSessionManager(); + + const sessions = sm.list({ limit: 1 }); + if (sessions.length === 0) { + throw new Error('No previous sessions found. Start a new session with `coder`.'); + } + + const latest = sessions[0]!; + if (latest.status === 'completed' || latest.status === 'archived') { + throw new Error( + `Latest session "${latest.title}" is already ${latest.status}. Use --resume to force or start a new session.`, + ); + } + + return resumeSession(latest.id); +} + +export async function forkSession( + sessionId: string, + fromTurn?: number, +): Promise<{ + session: Session; + checkpoint: Checkpoint | null; +}> { + const sm = getSessionManager(); + + const session = sm.fork({ sessionId, fromTurn }); + + const cm = getCheckpointManager(); + let checkpoint: Checkpoint | null = null; + try { + checkpoint = await cm.create({ + sessionId: session.id, + cwd: session.cwd, + description: `Fork checkpoint (from ${sessionId.slice(0, 8)}${fromTurn ? ` turn ${fromTurn}` : ''})`, + }); + } catch { + // Non-git directory — checkpoint is optional + } + + return { session, checkpoint }; +} + +export function listSessions(limit = 10): SessionSummary[] { + const sm = getSessionManager(); + return sm.list({ limit }); +} + +export function getSession(sessionId: string): Session | undefined { + const sm = getSessionManager(); + return sm.get(sessionId); +} + +export function deleteSession(sessionId: string): boolean { + const sm = getSessionManager(); + return sm.delete(sessionId); +} + +// --------------------------------------------------------------------------- +// Session Display Helpers +// --------------------------------------------------------------------------- + +export function formatSessionSummary(s: SessionSummary, index?: number): string { + const statusIcon = s.status === 'active' + ? '●' + : s.status === 'paused' + ? '⏸' + : s.status === 'completed' + ? '✓' + : s.status === 'error' + ? '✗' + : '📦'; + + const date = new Date(s.updatedAt).toLocaleString(); + const cost = s.totalCost > 0 ? ` $${s.totalCost.toFixed(2)}` : ''; + const prefix = index !== undefined ? `${index}. ` : ''; + + return `${prefix}${statusIcon} ${s.id.slice(0, 8)} ${s.title.slice(0, 40)} ${s.turnCount}t${cost} ${date} ${s.model}`; +} + +export function formatSessionList(sessions: SessionSummary[]): string { + if (sessions.length === 0) { + return 'No sessions found.'; + } + + const lines = sessions.map((s, i) => formatSessionSummary(s, i + 1)); + return lines.join('\n'); +} + +export function findResumableSession(): SessionSummary | null { + const sm = getSessionManager(); + + const paused = sm.list({ status: 'paused', limit: 1 }); + if (paused.length > 0) return paused[0]!; + + const active = sm.list({ status: 'active', limit: 1 }); + if (active.length > 0) return active[0]!; + + const error = sm.list({ status: 'error', limit: 1 }); + if (error.length > 0) return error[0]!; + + return null; +} diff --git a/packages/cli/src/theme.ts b/packages/cli/src/theme.ts new file mode 100644 index 0000000..2a99e1e --- /dev/null +++ b/packages/cli/src/theme.ts @@ -0,0 +1,626 @@ +export interface ThemeColors { + primary: string + accent: string + border: string + text: string + muted: string + completionBg: string + completionCurrentBg: string + completionMetaBg: string + completionMetaCurrentBg: string + + label: string + ok: string + error: string + warn: string + + prompt: string + sessionLabel: string + sessionBorder: string + + statusBg: string + statusFg: string + statusGood: string + statusWarn: string + statusBad: string + statusCritical: string + selectionBg: string + + diffAdded: string + diffRemoved: string + diffAddedWord: string + diffRemovedWord: string + + shellDollar: string +} + +export interface ThemeBrand { + name: string + icon: string + prompt: string + welcome: string + goodbye: string + tool: string + helpHeader: string +} + +export interface FileTreeColors { + directory: string + file: string + gitAdded: string + gitModified: string + gitDeleted: string + gitUntracked: string +} + +export interface Theme { + color: ThemeColors + brand: ThemeBrand + fileTree: FileTreeColors + bannerLogo: string + bannerHero: string +} + +// ── Color math ─────────────────────────────────────────────────────── + +function parseHex(h: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{6})$/i.exec(h) + + if (!m) { + return null + } + + const n = parseInt(m[1]!, 16) + + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] +} + +function mix(a: string, b: string, t: number) { + const pa = parseHex(a) + const pb = parseHex(b) + + if (!pa || !pb) { + return a + } + + const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t) + + return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) +} + +const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const +const ANSI_LIGHT_MAX_LUMINANCE = 0.72 +const ANSI_LIGHT_TARGET_LUMINANCE = 0.34 +const ANSI_LIGHT_MIN_SATURATION = 0.22 +const ANSI_MUTED_BUCKET = 245 + +const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'text', + 'label', + 'ok', + 'error', + 'warn', + 'prompt', + 'statusFg', + 'statusGood', + 'statusWarn', + 'statusBad', + 'statusCritical', + 'shellDollar' +] + +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] + +function xtermEightBitRgb(colorNumber: number): [number, number, number] { + if (colorNumber >= 232) { + const value = 8 + (colorNumber - 232) * 10 + + return [value, value, value] + } + + if (colorNumber >= 16) { + const offset = colorNumber - 16 + + return [ + XTERM_6_LEVELS[Math.floor(offset / 36) % 6]!, + XTERM_6_LEVELS[Math.floor(offset / 6) % 6]!, + XTERM_6_LEVELS[offset % 6]! + ] + } + + return [0, 0, 0] +} + +function channelLuminance(value: number): number { + const normalized = value / 255 + + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 +} + +function relativeLuminance(red: number, green: number, blue: number): number { + return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue) +} + +function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + + if (max === min) { + return [0, 0, lightness] + } + + const delta = max - min + const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min) + + const hue = + max === rn + ? (gn - bn) / delta + (gn < bn ? 6 : 0) + : max === gn + ? (bn - rn) / delta + 2 + : (rn - gn) / delta + 4 + + return [hue / 6, saturation, lightness] +} + +function circularDistance(a: number, b: number): number { + const distance = Math.abs(a - b) + + return Math.min(distance, 1 - distance) +} + +// Mirrors @coder/tui's colorize.ts. Keep local: app code compiles from +// ui-tui/src, while @coder/tui is bundled separately from packages/. +function richEightBitColorNumber(red: number, green: number, blue: number): number { + const [, saturation, lightness] = rgbToHsl(red, green, blue) + + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + +function bestReadableAnsiColor(red: number, green: number, blue: number): number { + const [hue, saturation, lightness] = rgbToHsl(red, green, blue) + let bestColor = richEightBitColorNumber(red, green, blue) + let bestScore = Number.POSITIVE_INFINITY + + for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) { + const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber) + const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue) + + if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) { + continue + } + + const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl( + candidateRed, + candidateGreen, + candidateBlue + ) + + const saturationFloorPenalty = + candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0 + + const score = + circularDistance(candidateHue, hue) * 4 + + Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 + + Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 + + saturationFloorPenalty + + if (score < bestScore) { + bestColor = colorNumber + bestScore = score + } + } + + return bestColor +} + +function normalizeAnsiForeground(color: string): string { + const rgb = parseHex(color) + + if (!rgb) { + return color + } + + const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2]) + const richRgb = xtermEightBitRgb(richAnsi) + + const ansi = relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE + ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2]) + : richAnsi + + return `ansi256(${ansi})` +} + +// ── Defaults ───────────────────────────────────────────────────────── + +const BRAND: ThemeBrand = { + name: 'Coder Agent', + icon: '⚕', + prompt: '❯', + welcome: 'Type your message or /help for commands.', + goodbye: 'Goodbye! ⚕', + tool: '┊', + helpHeader: '(^_^)? Commands' +} + +const cleanPromptSymbol = (s: string | undefined, fallback: string) => { + const cleaned = String(s ?? '') + .replace(/\s+/g, ' ') + .trim() + + return cleaned || fallback +} + +const DARK_FILETREE: FileTreeColors = { + directory: '#4dabf7', + file: '#C0C0C0', + gitAdded: '#4caf50', + gitModified: '#ffa726', + gitDeleted: '#ff5252', + gitUntracked: '#9e9e9e', +}; + +export const DARK_THEME: Theme = { + color: { + primary: '#FFD700', + accent: '#FFBF00', + border: '#C9A24B', + text: '#FFF8DC', + muted: '#E5C07B', + // Brightened from #CC9B1F (~60% luminance) to #E5C07B (~75% luminance) + // so secondary text stays clearly visible across all dark terminal profiles, + // including Apple_Terminal where ANSI dim is unavailable. + completionBg: '#1a1a2e', + completionCurrentBg: '#333355', + completionMetaBg: '#1a1a2e', + completionMetaCurrentBg: '#333355', + + label: '#F0C040', + ok: '#4caf50', + error: '#ff5252', + warn: '#ffa726', + + prompt: '#FFF8DC', + // sessionLabel/sessionBorder intentionally track the muted value — they + // are "same role, same colour" by design. fromSkin's banner_dim fallback + // relies on this pairing (#11300). + sessionLabel: '#E5C07B', + sessionBorder: '#E5C07B', + + statusBg: '#1a1a2e', + statusFg: '#C0C0C0', + statusGood: '#8FBC8F', + statusWarn: '#FFD700', + statusBad: '#FF8C00', + statusCritical: '#FF6B6B', + selectionBg: '#3a3a55', + + diffAdded: 'rgb(220,255,220)', + diffRemoved: 'rgb(255,220,220)', + diffAddedWord: 'rgb(0,110,40)', + diffRemovedWord: 'rgb(175,25,35)', + shellDollar: '#64B5F6' + }, + + brand: BRAND, + fileTree: DARK_FILETREE, + + bannerLogo: '', + bannerHero: '' +} + +// Light-terminal palette: darker golds/ambers that stay legible on white +// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top +// cleanly (#11300). +export const LIGHT_THEME: Theme = { + color: { + primary: '#8B6914', + accent: '#A0651C', + border: '#7A4F1F', + text: '#3D2F13', + muted: '#7A5A0F', + completionBg: '#F5F5F5', + completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + completionMetaBg: '#F5F5F5', + completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + + label: '#7A5A0F', + ok: '#2E7D32', + error: '#C62828', + warn: '#E65100', + + prompt: '#2B2014', + sessionLabel: '#7A5A0F', + sessionBorder: '#7A5A0F', + + statusBg: '#F5F5F5', + statusFg: '#333333', + statusGood: '#2E7D32', + statusWarn: '#8B6914', + statusBad: '#D84315', + statusCritical: '#B71C1C', + selectionBg: '#D4E4F7', + + diffAdded: 'rgb(200,240,200)', + diffRemoved: 'rgb(240,200,200)', + diffAddedWord: 'rgb(27,94,32)', + diffRemovedWord: 'rgb(183,28,28)', + shellDollar: '#1565C0' + }, + + brand: BRAND, + fileTree: { + directory: '#1565C0', + file: '#5D4037', + gitAdded: '#2E7D32', + gitModified: '#E65100', + gitDeleted: '#C62828', + gitUntracked: '#9E9E9E', + }, + + bannerLogo: '', + bannerHero: '' +} + +const TRUE_RE = /^(?:1|true|yes|on)$/ +const FALSE_RE = /^(?:0|false|no|off)$/ + +// TERM_PROGRAM fallback allow-list for terminals whose default profile is +// light and which may not expose COLORFGBG. This currently includes Apple +// Terminal. Explicit CODER_TUI_THEME / COLORFGBG signals above still win, +// so dark Apple Terminal profiles that advertise a dark background stay dark. +const LIGHT_DEFAULT_TERM_PROGRAMS = new Set(['Apple_Terminal']) + +// Best-effort RGB → luminance check. Currently only accepts a 3- or +// 6-digit hex value (with or without a leading `#`); the env var name +// `CODER_TUI_BACKGROUND` is intentionally generic so a future OSC11 +// query helper can cache its answer there too, but additional formats +// (rgb()/hsl()/named colours) would need explicit parsing here first. +const LUMA_LIGHT_THRESHOLD = 0.6 + +// Strict allow-list: parseInt(..., 16) silently truncates at the first +// non-hex character (e.g. `fffgff` would parse as `fff` and yield a +// false-positive "white" reading), so reject anything that doesn't match +// the canonical 3- or 6-digit shape up front. +const HEX_3_RE = /^[0-9a-f]{3}$/ +const HEX_6_RE = /^[0-9a-f]{6}$/ + +function backgroundLuminance(raw: string): null | number { + const v = raw.trim().toLowerCase() + + if (!v) { + return null + } + + const hex = v.startsWith('#') ? v.slice(1) : v + + const rgb = HEX_6_RE.test(hex) + ? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)] + : HEX_3_RE.test(hex) + ? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)] + : null + + if (!rgb) { + return null + } + + // Rec. 709 luma — close enough for "is this background bright". + return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255 +} + +// Pick light vs dark with ordered, explainable signals (#11300): +// +// 1. `CODER_TUI_LIGHT` boolean — `1`/`true`/`yes`/`on` → light; +// `0`/`false`/`no`/`off` → dark. Either explicit value wins +// regardless of any later signal. +// 2. `CODER_TUI_THEME` named override — `light` / `dark` win over +// every signal below. +// 3. `CODER_TUI_BACKGROUND` hex hint (3- or 6-digit) — luminance +// ≥ LUMA_LIGHT_THRESHOLD → light. +// 4. `COLORFGBG` last field — XFCE / rxvt / Terminal.app emit +// slot 7 or 15 on light profiles; 0–15 ranges are otherwise +// treated as authoritatively dark so the TERM_PROGRAM +// allow-list below cannot override an explicit dark profile. +// 5. `TERM_PROGRAM` light-default allow-list. +// +// Anything we can't decide stays dark — the default Coder palette +// is the dark one. +export function detectLightMode( + env: NodeJS.ProcessEnv = process.env, + // Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM + // precedence rule even though the production allow-list is empty. + lightDefaultTermPrograms: ReadonlySet = LIGHT_DEFAULT_TERM_PROGRAMS +): boolean { + const lightFlag = (env.CODER_TUI_LIGHT ?? '').trim().toLowerCase() + + if (TRUE_RE.test(lightFlag)) { + return true + } + + if (FALSE_RE.test(lightFlag)) { + return false + } + + const themeFlag = (env.CODER_TUI_THEME ?? '').trim().toLowerCase() + + if (themeFlag === 'light') { + return true + } + + if (themeFlag === 'dark') { + return false + } + + const bgHint = backgroundLuminance(env.CODER_TUI_BACKGROUND ?? '') + + if (bgHint !== null) { + return bgHint >= LUMA_LIGHT_THRESHOLD + } + + const colorfgbg = (env.COLORFGBG ?? '').trim() + + if (colorfgbg) { + // Validate as a decimal integer before coercing — `Number('')` is 0, + // so a malformed `COLORFGBG='15;'` would otherwise look like an + // authoritative dark slot and incorrectly block the TERM_PROGRAM + // allow-list. Anything that isn't pure digits falls through. + const lastField = colorfgbg.split(';').at(-1) ?? '' + + if (/^\d+$/.test(lastField)) { + const bg = Number(lastField) + + if (bg === 7 || bg === 15) { + return true + } + + // Slots 0–6 and 8–14 are the dark half of the 0–15 ANSI range. + // When COLORFGBG is set we trust it as authoritative — a non-light + // value here shouldn't get overridden by the TERM_PROGRAM allow-list. + if (bg >= 0 && bg < 16) { + return false + } + } + } + + const termProgram = (env.TERM_PROGRAM ?? '').trim() + + return lightDefaultTermPrograms.has(termProgram) +} + +function shouldNormalizeAnsiLightTheme(env: NodeJS.ProcessEnv = process.env, isLight = detectLightMode(env)): boolean { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const termProgram = (env.TERM_PROGRAM ?? '').trim() + + return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight +} + +export function normalizeThemeForAnsiLightTerminal( + theme: Theme, + env: NodeJS.ProcessEnv = process.env, + isLight = detectLightMode(env) +): Theme { + if (!shouldNormalizeAnsiLightTheme(env, isLight)) { + return theme + } + + const color = { ...theme.color } + + for (const key of ANSI_NORMALIZED_FOREGROUNDS) { + color[key] = normalizeAnsiForeground(color[key]) + } + + for (const key of ANSI_MUTED_FOREGROUNDS) { + color[key] = `ansi256(${ANSI_MUTED_BUCKET})` + } + + return { ...theme, color } +} + +const DEFAULT_LIGHT_MODE = detectLightMode() + +export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal( + DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME, + process.env, + DEFAULT_LIGHT_MODE +) + +// ── Skin → Theme ───────────────────────────────────────────────────── + +export function fromSkin( + colors: Record, + branding: Record, + bannerLogo = '', + bannerHero = '', + toolPrefix = '', + helpHeader = '' +): Theme { + const d = DEFAULT_THEME + const c = (k: string) => colors[k] + const hasSkinColors = Object.keys(colors).length > 0 + + const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent + const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent + const muted = c('banner_dim') ?? d.color.muted + const completionBg = c('completion_menu_bg') ?? d.color.completionBg + + const completionCurrentBg = + c('completion_menu_current_bg') ?? + (hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg) + + const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg + const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg + + return normalizeThemeForAnsiLightTerminal({ + color: { + primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, + accent, + border: c('ui_border') ?? c('banner_border') ?? d.color.border, + text: c('ui_text') ?? c('banner_text') ?? d.color.text, + muted, + completionBg, + completionCurrentBg, + completionMetaBg, + completionMetaCurrentBg, + + label: c('ui_label') ?? d.color.label, + ok: c('ui_ok') ?? d.color.ok, + error: c('ui_error') ?? d.color.error, + warn: c('ui_warn') ?? d.color.warn, + + prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, + sessionLabel: c('session_label') ?? muted, + sessionBorder: c('session_border') ?? muted, + + statusBg: d.color.statusBg, + statusFg: d.color.statusFg, + statusGood: c('ui_ok') ?? d.color.statusGood, + statusWarn: c('ui_warn') ?? d.color.statusWarn, + statusBad: d.color.statusBad, + statusCritical: d.color.statusCritical, + selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? (hasSkinColors ? completionCurrentBg : d.color.selectionBg), + + diffAdded: d.color.diffAdded, + diffRemoved: d.color.diffRemoved, + diffAddedWord: d.color.diffAddedWord, + diffRemovedWord: d.color.diffRemovedWord, + shellDollar: c('shell_dollar') ?? d.color.shellDollar + }, + + brand: { + name: branding.agent_name ?? d.brand.name, + icon: d.brand.icon, + prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt), + welcome: branding.welcome ?? d.brand.welcome, + goodbye: branding.goodbye ?? d.brand.goodbye, + tool: toolPrefix || d.brand.tool, + helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader) + }, + + fileTree: DEFAULT_LIGHT_MODE + ? { + directory: '#1565C0', + file: '#5D4037', + gitAdded: '#2E7D32', + gitModified: '#E65100', + gitDeleted: '#C62828', + gitUntracked: '#9E9E9E', + } + : DARK_FILETREE, + + bannerLogo, + bannerHero + }, process.env, DEFAULT_LIGHT_MODE) +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 0000000..d228226 --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,214 @@ +export interface ActiveTool { + context?: string + id: string + name: string + verboseArgs?: string + startedAt?: number +} + +export interface TodoItem { + content: string + id: string + status: 'cancelled' | 'completed' | 'in_progress' | 'pending' +} + +export interface ActivityItem { + id: number + text: string + tone: 'error' | 'info' | 'warn' +} + +export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' + +export interface SubagentProgress { + apiCalls?: number + costUsd?: number + depth: number + durationSeconds?: number + filesRead?: string[] + filesWritten?: string[] + goal: string + id: string + index: number + inputTokens?: number + iteration?: number + model?: string + notes: string[] + outputTail?: SubagentOutputEntry[] + outputTokens?: number + parentId: null | string + reasoningTokens?: number + startedAt?: number + status: SubagentStatus + summary?: string + taskCount: number + thinking: string[] + toolCount: number + tools: string[] + toolsets?: string[] +} + +export interface SubagentOutputEntry { + isError: boolean + preview: string + tool: string +} + +export interface SubagentNode { + aggregate: SubagentAggregate + children: SubagentNode[] + item: SubagentProgress +} + +export interface SubagentAggregate { + activeCount: number + costUsd: number + descendantCount: number + filesTouched: number + hotness: number + inputTokens: number + maxDepthFromHere: number + outputTokens: number + totalDuration: number + totalTools: number +} + +export interface DelegationStatus { + active: { + depth?: number + goal?: string + model?: null | string + parent_id?: null | string + started_at?: number + status?: string + subagent_id?: string + tool_count?: number + }[] + max_concurrent_children?: number + max_spawn_depth?: number + paused: boolean +} + +export interface ApprovalReq { + command: string + description: string + request_id?: string + tool_use_id?: string +} + +export interface ConfirmReq { + cancelLabel?: string + confirmLabel?: string + danger?: boolean + detail?: string + onConfirm: () => void + title: string +} + +export interface ClarifyReq { + choices: string[] | null + question: string + requestId: string +} + +export interface Msg { + info?: SessionInfo + kind?: 'diff' | 'intro' | 'panel' | 'slash' | 'trail' + panelData?: PanelData + role: Role + text: string + thinking?: string + thinkingTokens?: number + toolTokens?: number + tools?: string[] + todos?: TodoItem[] + todoIncomplete?: boolean + todoCollapsedByDefault?: boolean +} + +export type Role = 'assistant' | 'system' | 'tool' | 'user' +export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' +export type ThinkingMode = 'collapsed' | 'truncated' | 'full' + +// Per-section overrides for the agent details accordion. Resolution order +// at lookup time is: explicit `display.sections.` → built-in +// SECTION_DEFAULTS → global `details_mode`. Today the built-in defaults +// expand `thinking`/`tools` and hide `activity`; `subagents` falls through +// to the global mode. Any explicit value still wins for that one section. +export type SectionName = 'thinking' | 'tools' | 'subagents' | 'activity' +export type SectionVisibility = Partial> + +export interface McpServerStatus { + connected: boolean + name: string + tools: number + transport: string +} + +export interface SessionInfo { + cwd?: string + fast?: boolean + lazy?: boolean + mcp_servers?: McpServerStatus[] + model: string + profile_name?: string + reasoning_effort?: string + release_date?: string + service_tier?: string + skills: Record + system_prompt?: string + tools: Record + update_behind?: number | null + update_command?: string + usage?: Usage + version?: string +} + +export interface Usage { + calls: number + compressions?: number + context_max?: number + context_percent?: number + context_used?: number + cost_status?: string + cost_usd?: number + input: number + output: number + reasoning?: number + total: number +} + +export interface SudoReq { + requestId: string +} + +export interface SecretReq { + envVar: string + prompt: string + requestId: string +} + +export interface PanelData { + sections: PanelSection[] + title: string +} + +export interface PanelSection { + items?: string[] + rows?: [string, string][] + text?: string + title?: string +} + +export interface SlashCatalog { + canon: Record + categories: SlashCategory[] + pairs: [string, string][] + skillCount: number + sub: Record +} + +export interface SlashCategory { + name: string + pairs: [string, string][] +} diff --git a/packages/cli/src/types/coder-tui.d.ts b/packages/cli/src/types/coder-tui.d.ts new file mode 100644 index 0000000..515d4e6 --- /dev/null +++ b/packages/cli/src/types/coder-tui.d.ts @@ -0,0 +1,176 @@ +import type * as React from 'react' + +declare module '@coder/ink' { + export type Key = { + readonly ctrl: boolean + readonly meta: boolean + readonly super: boolean + readonly shift: boolean + readonly alt: boolean + readonly upArrow: boolean + readonly downArrow: boolean + readonly leftArrow: boolean + readonly rightArrow: boolean + readonly return: boolean + readonly backspace: boolean + readonly delete: boolean + readonly escape: boolean + readonly tab: boolean + readonly pageUp: boolean + readonly pageDown: boolean + readonly wheelUp: boolean + readonly wheelDown: boolean + readonly home: boolean + readonly end: boolean + readonly [key: string]: boolean + } + + export type InputEvent = { + readonly input: string + readonly key: Key + readonly keypress: { readonly isPasted?: boolean; readonly raw?: string } + } + + export type InputHandler = (input: string, key: Key, event: InputEvent) => void + + export type FrameEvent = { + readonly durationMs: number + readonly phases?: { + readonly renderer: number + readonly diff: number + readonly optimize: number + readonly write: number + readonly patches: number + readonly optimizedPatches: number + readonly writeBytes: number + readonly backpressure: boolean + readonly prevFrameDrainMs: number + readonly yoga: number + readonly commit: number + readonly yogaVisited: number + readonly yogaMeasured: number + readonly yogaCacheHits: number + readonly yogaLive: number + } + readonly flickers: ReadonlyArray<{ + readonly desiredHeight: number + readonly availableHeight: number + readonly reason: 'resize' | 'offscreen' | 'clear' + }> + } + + export type RenderOptions = { + readonly stdin?: NodeJS.ReadStream + readonly stdout?: NodeJS.WriteStream + readonly stderr?: NodeJS.WriteStream + readonly exitOnCtrlC?: boolean + readonly patchConsole?: boolean + readonly onFrame?: (event: FrameEvent) => void + readonly onHyperlinkClick?: (url: string) => void + } + + export type Instance = { + readonly rerender: (node: React.ReactNode) => void + readonly unmount: () => void + readonly waitUntilExit: () => Promise + readonly cleanup: () => void + } + + export type ScrollBoxHandle = { + readonly scrollTo: (y: number) => void + readonly scrollBy: (dy: number) => void + readonly scrollToElement: (el: unknown, offset?: number) => void + readonly scrollToBottom: () => void + readonly getScrollTop: () => number + readonly getPendingDelta: () => number + readonly getScrollHeight: () => number + readonly getFreshScrollHeight: () => number + readonly getViewportHeight: () => number + readonly getViewportTop: () => number + readonly getLastManualScrollAt: () => number + readonly isSticky: () => boolean + readonly subscribe: (listener: () => void) => () => void + readonly setClampBounds: (min: number | undefined, max: number | undefined) => void + } + + export const Box: React.ComponentType + export const AlternateScreen: React.ComponentType + export const Ansi: React.ComponentType + export const Link: React.ComponentType<{ + readonly children?: React.ReactNode + readonly fallback?: React.ReactNode + readonly url: string + }> + export const NoSelect: React.ComponentType + export const ScrollBox: React.ComponentType + export const Text: React.ComponentType + export const TextInput: React.ComponentType + export const stringWidth: (s: string) => number + export function isXtermJs(): boolean + + export type ScrollFastPathStats = { + captured: number + taken: number + declined: { + noPrevScreen: number + heightDeltaMismatch: number + other: number + } + lastDeclineReason?: string + lastHeightDelta?: number + lastHintDelta?: number + lastScrollHeight?: number + lastPrevHeight?: number + } + export const scrollFastPathStats: ScrollFastPathStats + + export type EvictLevel = 'all' | 'half' + export type InkCacheSizes = { + readonly lineWidth: number + readonly slice: number + readonly width: number + readonly wrap: number + } + export function evictInkCaches(level?: EvictLevel): InkCacheSizes + + export function forceRedraw(stdout?: NodeJS.WriteStream): boolean + export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance + + export function useApp(): { readonly exit: (error?: Error) => void } + export type RunExternalProcess = () => Promise + export function useExternalProcess(): (run: RunExternalProcess) => Promise + export function withInkSuspended(run: RunExternalProcess): Promise + export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void + export function useSelection(): { + readonly copySelection: () => Promise + readonly copySelectionNoClear: () => Promise + readonly clearSelection: () => void + readonly hasSelection: () => boolean + readonly getState: () => unknown + readonly version: () => number + readonly subscribe: (cb: () => void) => () => void + readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void + readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void + readonly moveFocus: (move: unknown) => void + readonly captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void + readonly setSelectionBgColor: (color: string) => void + } + export function useHasSelection(): boolean + export function useStdout(): { readonly stdout?: NodeJS.WriteStream } + export function useTerminalFocus(): boolean + export function useTerminalTitle(title: string | null): void + export function useDeclaredCursor(args: { + readonly line: number + readonly column: number + readonly active: boolean + }): (el: unknown) => void + export function useCursorAdvance(): (dx: number, dy?: number) => void + export function useStdin(): { + readonly stdin: NodeJS.ReadStream + readonly setRawMode: (value: boolean) => void + readonly isRawModeSupported: boolean + readonly exitOnCtrlC: boolean + readonly inputEmitter: NodeJS.EventEmitter + readonly querier: unknown + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..9f560cb --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo", + "jsx": "react-jsx", + "jsxImportSource": "react", + "noImplicitAny": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..a243389 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,25 @@ +{ + "name": "@coder/core", + "version": "0.1.0", + "description": "Coder Agent core runtime — Agent Loop, QueryEngine, System Prompt, Context, Hooks", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } + }, + "scripts": { + "build": "tsc -b", + "dev": "tsc -b --watch", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "vitest run" + }, + "dependencies": { + "@coder/shared": "workspace:*", + "@coder/provider": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.0", + "vitest": "^3.1.0" + } +} diff --git a/packages/core/src/__tests__/checkpoint.test.ts b/packages/core/src/__tests__/checkpoint.test.ts new file mode 100644 index 0000000..f16c2c8 --- /dev/null +++ b/packages/core/src/__tests__/checkpoint.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { CheckpointManager } from '../checkpoint.js'; + +describe('CheckpointManager', () => { + let manager: CheckpointManager; + let sessionId: string; + + beforeEach(() => { + manager = new CheckpointManager(); + // Use a unique session ID per test to avoid disk persistence conflicts + sessionId = `test-${randomUUID()}`; + }); + + describe('create', () => { + it('should create a checkpoint with an ID', async () => { + const cp = await manager.create({ + sessionId, + cwd: '/tmp', + description: 'Test checkpoint', + }); + expect(cp.id).toBeDefined(); + expect(cp.sessionId).toBe(sessionId); + expect(cp.description).toBe('Test checkpoint'); + expect(cp.createdAt).toBeDefined(); + }); + + it('should use default description when not provided', async () => { + const cp = await manager.create({ + sessionId, + cwd: '/tmp', + }); + expect(cp.description).toContain('Checkpoint'); + }); + + it('should store checkpoint in memory', async () => { + const cp = await manager.create({ + sessionId, + cwd: '/tmp', + }); + const retrieved = manager.list(sessionId); + expect(retrieved).toHaveLength(1); + expect(retrieved[0]!.id).toBe(cp.id); + }); + }); + + describe('list', () => { + it('should list checkpoints for a specific session', async () => { + const s1Id = `s1-${randomUUID()}`; + const s2Id = `s2-${randomUUID()}`; + + await manager.create({ sessionId: s1Id, cwd: '/tmp' }); + await manager.create({ sessionId: s1Id, cwd: '/tmp' }); + await manager.create({ sessionId: s2Id, cwd: '/tmp' }); + + const s1Checkpoints = manager.list(s1Id); + expect(s1Checkpoints).toHaveLength(2); + + const s2Checkpoints = manager.list(s2Id); + expect(s2Checkpoints).toHaveLength(1); + }); + + it('should sort by createdAt descending (newest first)', async () => { + await manager.create({ sessionId, cwd: '/tmp' }); + // Small delay to ensure different timestamps + await new Promise((r) => setTimeout(r, 10)); + await manager.create({ sessionId, cwd: '/tmp' }); + + const list = manager.list(sessionId); + expect(list.length).toBeGreaterThanOrEqual(2); + expect(new Date(list[0]!.createdAt).getTime()) + .toBeGreaterThanOrEqual(new Date(list[1]!.createdAt).getTime()); + }); + + it('should return empty array for unknown session', () => { + expect(manager.list('nonexistent-' + randomUUID())).toEqual([]); + }); + }); + + describe('restore', () => { + it('should fail for non-existent checkpoint', async () => { + const result = await manager.restore('nonexistent', '/tmp'); + expect(result.success).toBe(false); + expect(result.error).toBe('Checkpoint not found'); + }); + }); + + describe('loadFromDisk', () => { + it('should return empty array when no disk data exists', () => { + const loaded = manager.loadFromDisk('nonexistent-' + randomUUID()); + expect(loaded).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/__tests__/compactor.test.ts b/packages/core/src/__tests__/compactor.test.ts new file mode 100644 index 0000000..1b7c89b --- /dev/null +++ b/packages/core/src/__tests__/compactor.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + Compactor, + DEFAULT_COMPACTOR_CONFIG, +} from '../context/compactor.js'; +import type { Message } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeUserMessage(content: string): Message { + return { role: 'user', content }; +} + +function makeAssistantMessage(content: string): Message { + return { role: 'assistant', content }; +} + +function makeSystemMessage(content: string): Message { + return { role: 'system', content }; +} + +function makeMessages(count: number): Message[] { + const msgs: Message[] = []; + for (let i = 0; i < count; i++) { + msgs.push(makeUserMessage(`Question ${i + 1}: ` + 'hello '.repeat(20))); + msgs.push(makeAssistantMessage(`Answer ${i + 1}: ` + 'world '.repeat(30))); + } + return msgs; +} + +// Helper to estimate total chars in messages +function estimateChars(msgs: Message[]): number { + let total = 0; + for (const m of msgs) { + total += typeof m.content === 'string' ? m.content.length : 100; + } + return total; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Compactor', () => { + let compactor: Compactor; + + beforeEach(() => { + // Use a compactor with small thresholds and character-based estimation + compactor = new Compactor({ + thresholds: { safe: 0.3, snip: 0.5, summarize: 0.8, overflow: 0.95 }, + maxTurnsToKeep: 5, + minMessagesToKeep: 4, + estimateTokens: estimateChars, + summarizeEnabled: false, + }); + }); + + describe('DEFAULT_COMPACTOR_CONFIG', () => { + it('should have expected defaults', () => { + expect(DEFAULT_COMPACTOR_CONFIG.thresholds.safe).toBe(0.4); + expect(DEFAULT_COMPACTOR_CONFIG.thresholds.snip).toBe(0.6); + expect(DEFAULT_COMPACTOR_CONFIG.maxTurnsToKeep).toBe(15); + expect(DEFAULT_COMPACTOR_CONFIG.summarizeEnabled).toBe(true); + }); + }); + + describe('computeBudget', () => { + it('should compute token budget from messages', () => { + const msgs = makeMessages(5); + const budget = compactor.computeBudget(msgs, 10000); + expect(budget.current).toBeGreaterThan(0); + expect(budget.max).toBe(10000); + expect(budget.ratio).toBeGreaterThan(0); + }); + + it('should return ratio 0 for empty messages', () => { + const budget = compactor.computeBudget([], 10000); + expect(budget.current).toBe(0); + expect(budget.ratio).toBe(0); + }); + + it('should handle max budget of 0', () => { + const budget = compactor.computeBudget(makeMessages(1), 0); + expect(budget.ratio).toBe(0); + }); + }); + + describe('selectStrategy', () => { + it('should return none when under safe threshold', () => { + const strategy = compactor.selectStrategy({ current: 200, max: 1000, ratio: 0.2 }); + expect(strategy).toBe('none'); + }); + + it('should return snip when over safe but under snip threshold', () => { + const strategy = compactor.selectStrategy({ current: 400, max: 1000, ratio: 0.4 }); + expect(strategy).toBe('snip'); + }); + + it('should return snip when summarize disabled (over snip threshold)', () => { + const strategy = compactor.selectStrategy({ current: 700, max: 1000, ratio: 0.7 }); + expect(strategy).toBe('snip'); + }); + + it('should return snip when near overflow (below overflow)', () => { + const strategy = compactor.selectStrategy({ current: 900, max: 1000, ratio: 0.9 }); + expect(strategy).toBe('snip'); + }); + + it('should return error when over overflow threshold', () => { + const strategy = compactor.selectStrategy({ current: 980, max: 1000, ratio: 0.98 }); + expect(strategy).toBe('error'); + }); + }); + + describe('needsCompaction', () => { + it('should return false when under safe threshold', () => { + const msgs = makeMessages(1); + const needs = compactor.needsCompaction(msgs, 100000); + expect(needs).toBe(false); + }); + + it('should return true when over safe threshold', () => { + const msgs = makeMessages(20); + const needs = compactor.needsCompaction(msgs, 2000); + expect(needs).toBe(true); + }); + }); + + describe('compact', () => { + it('should return unchanged messages when none strategy', async () => { + const msgs = makeMessages(2); + const result = await compactor.compact(msgs, 100000); + expect(result.strategy).toBe('none'); + expect(result.messagesRemoved).toBe(0); + expect(result.beforeTokens).toBe(result.afterTokens); + }); + + it('should snip oldest messages when over budget', async () => { + // makeMessages(20) creates 40 messages (~6400 chars) + // With budget 12000: ratio = 6400/12000 ≈ 0.533 (between snip 0.5 and summarize 0.8) → 'snip' + const msgs = makeMessages(20); + const result = await compactor.compact(msgs, 12000); + expect(result.strategy).toBe('snip'); + expect(result.messagesRemoved).toBeGreaterThan(0); + expect(result.afterTokens).toBeLessThan(result.beforeTokens); + }); + + it('should preserve system messages during snip', async () => { + const sysMsg = makeSystemMessage('Important system context'); + const msgs = [sysMsg, ...makeMessages(20)]; + const result = await compactor.compact(msgs, 12000); + const systemMsgs = result.messages.filter((m) => m.role === 'system'); + expect(systemMsgs.length).toBeGreaterThanOrEqual(1); + }); + + it('should insert boundary note when snipping', async () => { + const msgs = makeMessages(20); + const result = await compactor.compact(msgs, 12000); + const hasBoundaryNote = result.messages.some( + (m) => m.role === 'system' && typeof m.content === 'string' && m.content.includes('Context compacted'), + ); + expect(hasBoundaryNote).toBe(true); + }); + }); + + describe('reset and accessors', () => { + it('should track strategy after compact', async () => { + const msgs = makeMessages(20); + await compactor.compact(msgs, 12000); + expect(compactor.getLastStrategy()).toBe('snip'); + }); + + it('should increment compaction count', async () => { + const msgs = makeMessages(20); + await compactor.compact(msgs, 12000); + expect(compactor.getCompactionCount()).toBe(1); + }); + + it('should reset state', async () => { + const msgs = makeMessages(20); + await compactor.compact(msgs, 12000); + compactor.reset(); + expect(compactor.getLastStrategy()).toBe('none'); + expect(compactor.getCompactionCount()).toBe(0); + }); + + it('should return accumulated summary', () => { + expect(compactor.getAccumulatedSummary()).toBe(''); + }); + + it('should return config', () => { + const config = compactor.getConfig(); + expect(config.thresholds.safe).toBe(0.3); + expect(config.maxTurnsToKeep).toBe(5); + }); + }); + + describe('with summarize enabled', () => { + it('should fall back to snip when no summarizeModel configured', async () => { + const c = new Compactor({ + thresholds: { safe: 0.3, snip: 0.5, summarize: 0.8, overflow: 0.95 }, + maxTurnsToKeep: 5, + minMessagesToKeep: 4, + estimateTokens: estimateChars, + summarizeEnabled: true, + // No summarizeModel configured — selectStrategy returns 'summarize' + // but compact() falls back to snip internally + }); + const msgs = makeMessages(20); + const result = await c.compact(msgs, 12000); + // Falls back to snip since no summarizeModel + expect(result.strategy).toBe('snip'); + expect(result.messagesRemoved).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/core/src/__tests__/error-recovery.test.ts b/packages/core/src/__tests__/error-recovery.test.ts new file mode 100644 index 0000000..d102446 --- /dev/null +++ b/packages/core/src/__tests__/error-recovery.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyError, + computeBackoff, + delay, + withRetry, + MaxTurnsExceededError, + BudgetExceededError, + StopRequestedError, + FatalAPIError, + ContextOverflowError, +} from '../error-recovery.js'; + +// --------------------------------------------------------------------------- +// classifyError +// --------------------------------------------------------------------------- + +describe('classifyError', () => { + it('should classify rate limit errors', () => { + const result = classifyError(new Error('Rate limit exceeded')); + expect(result.category).toBe('rate_limit'); + expect(result.retryable).toBe(true); + }); + + it('should classify 429 errors', () => { + const result = classifyError(new Error('HTTP 429 too many requests')); + expect(result.category).toBe('rate_limit'); + expect(result.retryable).toBe(true); + }); + + it('should classify quota errors', () => { + const result = classifyError(new Error('API quota exceeded')); + expect(result.category).toBe('rate_limit'); + }); + + it('should classify server errors', () => { + const result = classifyError(new Error('Internal server error')); + expect(result.category).toBe('server_error'); + expect(result.retryable).toBe(true); + }); + + it('should classify 500 errors', () => { + const result = classifyError(new Error('HTTP 500')); + expect(result.category).toBe('server_error'); + }); + + it('should classify 503 errors', () => { + const result = classifyError(new Error('Service unavailable 503')); + expect(result.category).toBe('server_error'); + }); + + it('should classify overloaded errors', () => { + const result = classifyError(new Error('Server overloaded')); + expect(result.category).toBe('overloaded'); + expect(result.retryable).toBe(true); + }); + + it('should classify capacity errors', () => { + const result = classifyError(new Error('At capacity')); + expect(result.category).toBe('overloaded'); + }); + + it('should classify network errors', () => { + const result = classifyError(new Error('ECONNREFUSED')); + expect(result.category).toBe('network'); + expect(result.retryable).toBe(true); + }); + + it('should classify fetch failed errors', () => { + const result = classifyError(new Error('fetch failed')); + expect(result.category).toBe('network'); + }); + + it('should classify timeout errors', () => { + const result = classifyError(new Error('Request timed out')); + expect(result.category).toBe('timeout'); + expect(result.retryable).toBe(true); + }); + + it('should classify abort errors by name', () => { + // Use AbortError name WITHOUT "abort" in message to avoid matching timeout check first + const abortError = new Error('User cancelled operation'); + abortError.name = 'AbortError'; + const result = classifyError(abortError); + expect(result.category).toBe('aborted'); + expect(result.retryable).toBe(false); + }); + + it('should classify 400 errors as invalid_request', () => { + const result = classifyError(new Error('HTTP 400 bad request')); + expect(result.category).toBe('invalid_request'); + expect(result.retryable).toBe(false); + }); + + it('should classify 401 errors as auth', () => { + const result = classifyError(new Error('HTTP 401 unauthorized')); + expect(result.category).toBe('auth'); + expect(result.retryable).toBe(false); + }); + + it('should classify 403 errors as auth', () => { + const result = classifyError(new Error('HTTP 403 forbidden')); + expect(result.category).toBe('auth'); + }); + + it('should classify context too large errors', () => { + const result = classifyError(new Error('Prompt too long')); + expect(result.category).toBe('context_too_large'); + expect(result.retryable).toBe(false); + }); + + it('should classify 413 errors', () => { + const result = classifyError(new Error('HTTP 413 too many tokens')); + expect(result.category).toBe('context_too_large'); + }); + + it('should classify unknown errors', () => { + const result = classifyError(new Error('Something weird happened')); + expect(result.category).toBe('unknown'); + expect(result.retryable).toBe(false); + }); + + it('should preserve the original error', () => { + const original = new Error('test'); + const result = classifyError(original); + expect(result.original).toBe(original); + }); +}); + +// --------------------------------------------------------------------------- +// computeBackoff +// --------------------------------------------------------------------------- + +describe('computeBackoff', () => { + it('should return base delay for attempt 0', () => { + const delay = computeBackoff(0, { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000, retryableCategories: new Set() }); + expect(delay).toBeGreaterThanOrEqual(1000); + expect(delay).toBeLessThanOrEqual(1500); // base + jitter (max 50%) + }); + + it('should increase with each attempt', () => { + const d0 = computeBackoff(0); + const d1 = computeBackoff(1); + const d2 = computeBackoff(2); + expect(d1).toBeGreaterThan(d0); + expect(d2).toBeGreaterThan(d1); + }); + + it('should not exceed maxDelayMs', () => { + const delay = computeBackoff(10, { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 5000, retryableCategories: new Set() }); + expect(delay).toBeLessThanOrEqual(5000); + }); +}); + +// --------------------------------------------------------------------------- +// delay +// --------------------------------------------------------------------------- + +describe('delay', () => { + it('should wait for specified time', async () => { + const start = Date.now(); + await delay(50); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(40); // Allow small timing variance + }); +}); + +// --------------------------------------------------------------------------- +// withRetry +// --------------------------------------------------------------------------- + +describe('withRetry', () => { + it('should return result on first success', async () => { + const result = await withRetry(async () => 'success'); + expect(result).toBe('success'); + }); + + it('should retry on retryable error', async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + if (calls < 2) throw new Error('Rate limit exceeded'); + return 'retried'; + }, + { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100, retryableCategories: new Set(['rate_limit']) }, + ); + expect(result).toBe('retried'); + expect(calls).toBe(2); + }); + + it('should not retry on non-retryable error', async () => { + let calls = 0; + await expect( + withRetry( + async () => { + calls++; + throw new Error('HTTP 401 unauthorized'); + }, + { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100, retryableCategories: new Set(['rate_limit', 'server_error', 'network', 'timeout']) }, + ), + ).rejects.toThrow('HTTP 401 unauthorized'); + expect(calls).toBe(1); + }); + + it('should throw after max retries', async () => { + await expect( + withRetry( + async () => { throw new Error('Rate limit exceeded'); }, + { maxRetries: 2, baseDelayMs: 10, maxDelayMs: 100, retryableCategories: new Set(['rate_limit']) }, + ), + ).rejects.toThrow('Rate limit exceeded'); + }); + + it('should handle non-Error throws', async () => { + await expect( + withRetry( + async () => { throw 'string error'; }, + { maxRetries: 0 }, + ), + ).rejects.toThrow('string error'); + }); +}); + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +describe('Error classes', () => { + it('MaxTurnsExceededError should contain maxTurns', () => { + const err = new MaxTurnsExceededError(50); + expect(err.name).toBe('MaxTurnsExceededError'); + expect(err.maxTurns).toBe(50); + expect(err.message).toContain('50'); + }); + + it('BudgetExceededError should contain totalCost', () => { + const err = new BudgetExceededError(10.50); + expect(err.name).toBe('BudgetExceededError'); + expect(err.totalCost).toBe(10.50); + expect(err.message).toContain('$10.50'); + }); + + it('StopRequestedError should contain reason', () => { + const err = new StopRequestedError('user abort'); + expect(err.name).toBe('StopRequestedError'); + expect(err.reason).toBe('user abort'); + }); + + it('FatalAPIError should contain category', () => { + const err = new FatalAPIError('API key invalid', 'auth'); + expect(err.name).toBe('FatalAPIError'); + expect(err.category).toBe('auth'); + }); + + it('ContextOverflowError should contain ratio as percentage', () => { + const err = new ContextOverflowError(0.96); + expect(err.name).toBe('ContextOverflowError'); + expect(err.ratio).toBe(0.96); + expect(err.message).toContain('96%'); + }); +}); diff --git a/packages/core/src/__tests__/hooks-phase5.test.ts b/packages/core/src/__tests__/hooks-phase5.test.ts new file mode 100644 index 0000000..6675cac --- /dev/null +++ b/packages/core/src/__tests__/hooks-phase5.test.ts @@ -0,0 +1,522 @@ +/** + * hooks-phase5.test.ts — Unit tests for Phase 5 Hook events (Sprint 7). + * + * Tests the 5 new hook events added in Phase 5 Batch 1: + * 1. PostToolUseFailure — tool execution exception + * 2. StopFailure — API error terminates Agent Loop + * 3. TaskCreated — background task / sub-agent spawned + * 4. TaskCompleted — background task / sub-agent finished + * 5. Notification — system-level event notifications + * + * All 5 events are non-blockable — hook failures must never affect the main flow. + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { HookManager } from '../hooks/manager.js'; +import type { Hook, HookResult } from '../hooks/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createSilentHookManager(): HookManager { + // Create a manager that doesn't try to load from disk + const mgr = new HookManager(); + // Remove any hooks loaded from disk + for (const h of mgr.list()) { + mgr.unregister(h.id); + } + return mgr; +} + +function createSpyHook( + id: string, + event: string, + result?: HookResult, +): { hook: Hook; spy: ReturnType } { + const spy = vi.fn().mockResolvedValue(result ?? {}); + const hook: Hook = { + id, + event: event as Hook['event'], + description: `Test hook: ${id}`, + handler: spy, + timeout: 5000, + priority: 10, + }; + return { hook, spy }; +} + +// --------------------------------------------------------------------------- +// 1. PostToolUseFailure +// --------------------------------------------------------------------------- + +describe('PostToolUseFailure hook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fire when onPostToolUseFailure is called', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('tool-fail-logger', 'PostToolUseFailure'); + mgr.register(hook); + + const testError = new Error('EACCES: permission denied'); + await mgr.onPostToolUseFailure( + 'session-1', + '/tmp/test', + 'Write', + { file_path: '/etc/hosts', content: 'evil' }, + testError, + ); + + // Give the async fire-and-forget a moment to execute + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('PostToolUseFailure'); + expect(ctx.toolName).toBe('Write'); + expect(ctx.input).toEqual({ file_path: '/etc/hosts', content: 'evil' }); + expect(ctx.error.message).toBe('EACCES: permission denied'); + expect(ctx.error.stack).toBeDefined(); + }); + + it('should not throw if no hooks are registered', async () => { + const mgr = createSilentHookManager(); + const testError = new Error('test'); + + // This should not throw + await expect( + mgr.onPostToolUseFailure('session-1', '/tmp', 'Bash', {}, testError), + ).resolves.toBeUndefined(); + }); + + it('should not block the loop even if hook throws', async () => { + const mgr = createSilentHookManager(); + const throwingHook: Hook = { + id: 'crashy', + event: 'PostToolUseFailure', + handler: async () => { + throw new Error('Hook crashed!'); + }, + }; + mgr.register(throwingHook); + + const testError = new Error('original error'); + // Should resolve without throwing — hook errors are non-fatal + await expect( + mgr.onPostToolUseFailure('session-1', '/tmp', 'Bash', {}, testError), + ).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. StopFailure +// --------------------------------------------------------------------------- + +describe('StopFailure hook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fire when onStopFailure is called', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('stop-fail-diagnostics', 'StopFailure'); + mgr.register(hook); + + await mgr.onStopFailure( + 'session-1', + '/tmp/test', + { message: 'API overloaded', code: 'overloaded_error', status: 529 }, + 42, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('StopFailure'); + expect(ctx.error.message).toBe('API overloaded'); + expect(ctx.error.code).toBe('overloaded_error'); + expect(ctx.error.status).toBe(529); + expect(ctx.turnCount).toBe(42); + }); + + it('should handle missing optional error fields', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('stop-fail-minimal', 'StopFailure'); + mgr.register(hook); + + await mgr.onStopFailure( + 'session-1', + '/tmp', + { message: 'Unknown error' }, + 0, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.error.code).toBeUndefined(); + expect(ctx.error.status).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. TaskCreated +// --------------------------------------------------------------------------- + +describe('TaskCreated hook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fire when onTaskCreated is called for a subagent', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('task-tracker', 'TaskCreated'); + mgr.register(hook); + + await mgr.onTaskCreated( + 'parent-session-1', + '/tmp/test', + 'agent-a1b2c3', + 'subagent', + 'Investigate null pointer in src/auth/validate.ts', + ['Read', 'Grep', 'Glob'], + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('TaskCreated'); + expect(ctx.taskId).toBe('agent-a1b2c3'); + expect(ctx.taskType).toBe('subagent'); + expect(ctx.prompt).toContain('null pointer'); + expect(ctx.toolSet).toEqual(['Read', 'Grep', 'Glob']); + }); + + it('should handle cron task type', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('cron-tracker', 'TaskCreated'); + mgr.register(hook); + + await mgr.onTaskCreated( + 'session-1', + '/tmp', + 'cron-xyz', + 'cron', + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.taskType).toBe('cron'); + expect(ctx.prompt).toBeUndefined(); + expect(ctx.toolSet).toBeUndefined(); + }); + + it('should handle unrestricted tools (empty toolSet)', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('unrestricted-tracker', 'TaskCreated'); + mgr.register(hook); + + await mgr.onTaskCreated( + 'session-1', + '/tmp', + 'coordinator-1', + 'background', + 'General task', + undefined, // unrestricted + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.toolSet).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. TaskCompleted +// --------------------------------------------------------------------------- + +describe('TaskCompleted hook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fire when onTaskCompleted is called with completed status', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('task-done-logger', 'TaskCompleted'); + mgr.register(hook); + + await mgr.onTaskCompleted( + 'parent-session-1', + '/tmp/test', + 'agent-a1b2c3', + 'completed', + 'Fixed null pointer in validate.ts:42', + { tokens: 12400, toolCalls: 8, durationMs: 45000 }, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('TaskCompleted'); + expect(ctx.taskId).toBe('agent-a1b2c3'); + expect(ctx.status).toBe('completed'); + expect(ctx.summary).toContain('null pointer'); + expect(ctx.usage).toEqual({ tokens: 12400, toolCalls: 8, durationMs: 45000 }); + }); + + it('should fire for failed tasks', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('task-fail-alert', 'TaskCompleted'); + mgr.register(hook); + + await mgr.onTaskCompleted( + 'parent-session-1', + '/tmp/test', + 'agent-failed', + 'failed', + 'ENOENT: no such file or directory', + { tokens: 0, toolCalls: 1, durationMs: 5000 }, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.status).toBe('failed'); + }); + + it('should fire for killed tasks', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('task-kill-logger', 'TaskCompleted'); + mgr.register(hook); + + await mgr.onTaskCompleted( + 'parent-session-1', + '/tmp/test', + 'agent-killed', + 'killed', + 'Terminated by user', + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.status).toBe('killed'); + expect(ctx.usage).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Notification +// --------------------------------------------------------------------------- + +describe('Notification hook', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fire info-level notification', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('notify-info', 'Notification'); + mgr.register(hook); + + await mgr.onNotification( + 'session-1', + '/tmp/test', + 'info', + 'Tool Write completed', + { toolName: 'Write', isError: false }, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('Notification'); + expect(ctx.level).toBe('info'); + expect(ctx.message).toBe('Tool Write completed'); + expect(ctx.metadata).toEqual({ toolName: 'Write', isError: false }); + }); + + it('should fire warn-level notification', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('notify-warn', 'Notification'); + mgr.register(hook); + + await mgr.onNotification( + 'session-2', + '/tmp', + 'warn', + 'Budget exceeded at $5.50', + { totalCost: 5.50, maxBudgetUsd: 5 }, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.level).toBe('warn'); + }); + + it('should fire error-level notification', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('notify-error', 'Notification'); + mgr.register(hook); + + await mgr.onNotification( + 'session-3', + '/tmp', + 'error', + 'Context compaction failed', + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.level).toBe('error'); + expect(ctx.metadata).toBeUndefined(); + }); + + it('should not throw on missing metadata', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('notify-minimal', 'Notification'); + mgr.register(hook); + + await mgr.onNotification('session-1', '/tmp', 'info', 'Simple message'); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Integration: Multiple hooks per event +// --------------------------------------------------------------------------- + +describe('Multiple hooks per event', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should execute all registered hooks for TaskCreated', async () => { + const mgr = createSilentHookManager(); + const h1 = createSpyHook('tracker-1', 'TaskCreated'); + const h2 = createSpyHook('tracker-2', 'TaskCreated'); + mgr.register(h1.hook); + mgr.register(h2.hook); + + await mgr.onTaskCreated('session-1', '/tmp', 'agent-1', 'subagent', 'Task'); + + await vi.waitFor(() => { + expect(h1.spy).toHaveBeenCalledTimes(1); + expect(h2.spy).toHaveBeenCalledTimes(1); + }, { timeout: 1000 }); + }); + + it('should execute hooks with priority ordering', async () => { + const mgr = createSilentHookManager(); + const executionOrder: string[] = []; + + const lowPriority: Hook = { + id: 'low', + event: 'Notification', + priority: 0, + handler: async () => { executionOrder.push('low'); return {}; }, + }; + const highPriority: Hook = { + id: 'high', + event: 'Notification', + priority: 100, + handler: async () => { executionOrder.push('high'); return {}; }, + }; + + mgr.register(lowPriority); + mgr.register(highPriority); + + await mgr.onNotification('session-1', '/tmp', 'info', 'test'); + + await vi.waitFor(() => { + expect(executionOrder.length).toBe(2); + }, { timeout: 1000 }); + + // Higher priority hooks are registered first in the bucket + // but execute in parallel — we just verify both ran + expect(executionOrder).toContain('high'); + expect(executionOrder).toContain('low'); + }); + + it('should skip disabled hooks', async () => { + const mgr = createSilentHookManager(); + const enabled = createSpyHook('enabled', 'Notification'); + const spy = vi.fn(); + const disabled: Hook = { + id: 'disabled', + event: 'Notification', + enabled: false, + handler: spy, + }; + + mgr.register(enabled.hook); + mgr.register(disabled); + + await mgr.onNotification('session-1', '/tmp', 'info', 'test'); + + await vi.waitFor(() => { + expect(enabled.spy).toHaveBeenCalledTimes(1); + }, { timeout: 1000 }); + + // Disabled hook should NOT have been called + expect(spy).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Backward compatibility: old events still work +// --------------------------------------------------------------------------- + +describe('Backward compatibility', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should still fire PostToolUse (old event) correctly', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('post-tool', 'PostToolUse'); + mgr.register(hook); + + await mgr.onPostToolUse( + 'session-1', + '/tmp', + 'Read', + { file_path: '/tmp/test.txt' }, + 'file contents', + true, + 150, + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('PostToolUse'); + expect(ctx.toolName).toBe('Read'); + expect(ctx.success).toBe(true); + }); + + it('should still fire Stop (old event) correctly', async () => { + const mgr = createSilentHookManager(); + const { hook, spy } = createSpyHook('stop-hook', 'Stop'); + mgr.register(hook); + + await mgr.onStop( + 'session-1', + '/tmp', + 10, + [{ role: 'assistant', summary: 'Done' }], + ); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + + const ctx = spy.mock.calls[0]?.[0]; + expect(ctx.event).toBe('Stop'); + expect(ctx.turnCount).toBe(10); + }); +}); diff --git a/packages/core/src/__tests__/placeholder.test.ts b/packages/core/src/__tests__/placeholder.test.ts new file mode 100644 index 0000000..b6a642b --- /dev/null +++ b/packages/core/src/__tests__/placeholder.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + ToolRegistry, + QueryEngine, + SessionManager, + CheckpointManager, + PermissionEngine, + SystemPromptAssembler, + classifyError, + computeBackoff, + delay, + withRetry, + MaxTurnsExceededError, + BudgetExceededError, + StopRequestedError, + FatalAPIError, + ContextOverflowError, + classifyTaskMode, +} from '../index.js'; +import type { + ToolEntry, + ToolCategory, + QueryConfig, + CallModelParams, + QueryEngineConfig, + QueryEngineEvent, + Checkpoint, + CheckpointCreateOptions, + CheckpointRestoreResult, + ClassificationContext, + PromptPart, + AssemblyContext, + SystemPrompt, + ErrorCategory, + ClassifiedError, + RetryConfig, +} from '../index.js'; + +describe('@coder/core', () => { + it('should export ToolRegistry', () => { + expect(ToolRegistry).toBeDefined(); + }); + + it('should export QueryEngine', () => { + expect(QueryEngine).toBeDefined(); + }); + + it('should export SessionManager', () => { + expect(SessionManager).toBeDefined(); + }); + + it('should export CheckpointManager', () => { + expect(CheckpointManager).toBeDefined(); + }); + + it('should export PermissionEngine', () => { + expect(PermissionEngine).toBeDefined(); + }); + + it('should export SystemPromptAssembler', () => { + expect(SystemPromptAssembler).toBeDefined(); + }); + + it('should export classifyError', () => { + expect(classifyError).toBeDefined(); + expect(typeof classifyError).toBe('function'); + }); + + it('should export computeBackoff', () => { + expect(computeBackoff).toBeDefined(); + expect(typeof computeBackoff).toBe('function'); + }); + + it('should export delay', () => { + expect(delay).toBeDefined(); + expect(typeof delay).toBe('function'); + }); + + it('should export withRetry', () => { + expect(withRetry).toBeDefined(); + expect(typeof withRetry).toBe('function'); + }); + + it('should export classifyTaskMode', () => { + expect(classifyTaskMode).toBeDefined(); + expect(typeof classifyTaskMode).toBe('function'); + }); + + it('should export MaxTurnsExceededError', () => { + const err = new MaxTurnsExceededError(10); + expect(err).toBeInstanceOf(Error); + expect(err.maxTurns).toBe(10); + }); + + it('should export BudgetExceededError', () => { + const err = new BudgetExceededError(5.50); + expect(err).toBeInstanceOf(Error); + expect(err.totalCost).toBe(5.50); + }); + + it('should export StopRequestedError', () => { + const err = new StopRequestedError('user interrupt'); + expect(err).toBeInstanceOf(Error); + expect(err.reason).toBe('user interrupt'); + }); + + it('should export FatalAPIError', () => { + const err = new FatalAPIError('auth failed', 'auth'); + expect(err).toBeInstanceOf(Error); + expect(err.category).toBe('auth'); + }); + + it('should export ContextOverflowError', () => { + const err = new ContextOverflowError(0.96); + expect(err).toBeInstanceOf(Error); + expect(err.ratio).toBe(0.96); + }); + + // Verify type exports are importable (compile-time check) + it('should have ToolEntry type available', () => { + const entry: ToolEntry = { definition: { name: 'test', description: '', inputSchema: { type: 'object' }, riskLevel: 'safe' as const }, instance: {} as any }; + expect(entry.definition.name).toBe('test'); + }); +}); diff --git a/packages/core/src/__tests__/query.test.ts b/packages/core/src/__tests__/query.test.ts new file mode 100644 index 0000000..fc09b20 --- /dev/null +++ b/packages/core/src/__tests__/query.test.ts @@ -0,0 +1,426 @@ +/** + * query.test.ts — Agent Loop integration tests + * + * Tests the full Agent Loop (query() async generator) with mocked + * dependencies. Covers the main flow: user input → LLM (tool_use) → + * tool execution → loop → end_turn, plus exit conditions and error recovery. + */ + +import { describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { query, type QueryConfig, type CallModelParams } from '../query.js'; +import { ToolRegistry } from '../tool-registry.js'; +import { PermissionEngine } from '../permission/engine.js'; +import { SessionManager } from '../session.js'; +import { CheckpointManager } from '../checkpoint.js'; +import { BaseTool, RiskLevel, PermissionMode } from '@coder/shared'; +import type { + ToolDefinition, + ToolContext, + StreamEvent, + AssistantMessage, + QueryMessage, +} from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Mock Tool +// --------------------------------------------------------------------------- + +class MockEchoTool extends BaseTool<{ message: string }, string> { + get definition(): ToolDefinition { + return { + name: 'Echo', + description: 'Echo a message back', + inputSchema: { + type: 'object', + properties: { message: { type: 'string' } }, + required: ['message'], + }, + riskLevel: RiskLevel.SAFE, + }; + } + + async execute(input: { message: string }, _ctx: ToolContext): Promise { + return `Echo: ${input.message}`; + } +} + +class MockFailingTool extends BaseTool, string> { + get definition(): ToolDefinition { + return { + name: 'Failing', + description: 'Always fails', + inputSchema: { type: 'object', properties: {} }, + riskLevel: RiskLevel.SAFE, + }; + } + + async execute(_input: Record, _ctx: ToolContext): Promise { + throw new Error('Simulated tool failure'); + } +} + +// --------------------------------------------------------------------------- +// Mock model helpers +// --------------------------------------------------------------------------- + +/** + * Create a mock callModel that returns a simple text response (end_turn). + */ +function createMockTextModel(response: string) { + return async function* (_params: CallModelParams): AsyncGenerator { + yield { type: 'message_start', message: { model: 'mock', usage: { input_tokens: 10, output_tokens: 5 } } }; + yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }; + yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: response } }; + yield { type: 'content_block_stop', index: 0 }; + yield { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: response.length } }; + // message_stop with assistant message + yield { + type: 'message_stop', + message: { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: response }], + stopReason: 'end_turn' as const, + usage: { input_tokens: 10, output_tokens: response.length }, + }, + }; + }; +} + +/** + * Create a mock callModel that returns a tool_use response. + * The query function collects tool_use blocks from the message_stop event's message content. + */ +function createMockToolUseModel(toolName: string, toolInput: Record) { + return async function* (_params: CallModelParams): AsyncGenerator { + yield { type: 'message_start', message: { model: 'mock', usage: { input_tokens: 10, output_tokens: 5 } } }; + yield { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'tool_001', name: toolName, input: {} } }; + yield { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: JSON.stringify(toolInput) } }; + yield { type: 'content_block_stop', index: 0 }; + yield { type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null }, usage: { output_tokens: 50 } }; + // Critical: message_stop with assistant message containing tool_use blocks + yield { + type: 'message_stop', + message: { + role: 'assistant' as const, + content: [ + { type: 'tool_use' as const, id: 'tool_001', name: toolName, input: toolInput }, + ], + stopReason: 'tool_use' as const, + usage: { input_tokens: 10, output_tokens: 50 }, + }, + }; + }; +} + +/** + * Create a mock callModel that throws an error. + */ +function createMockErrorModel(errorMessage: string) { + return async function* (_params: CallModelParams): AsyncGenerator { + throw new Error(errorMessage); + }; +} + +// --------------------------------------------------------------------------- +// Test setup helpers +// --------------------------------------------------------------------------- + +function createQueryConfig(overrides: Partial & { callModel: QueryConfig['callModel'] }): QueryConfig { + const sessionId = randomUUID(); + const toolRegistry = new ToolRegistry(); + toolRegistry.register(new MockEchoTool()); + toolRegistry.register(new MockFailingTool()); + + const sessionManager = new SessionManager(); + sessionManager.create({ cwd: '/tmp/test', title: 'Test session' }); + + return { + sessionId, + cwd: '/tmp/test', + messages: [], + systemPrompt: { prompt: 'You are a test assistant.', parts: [], estimatedTokens: 10 }, + toolRegistry, + permissionEngine: new PermissionEngine('/tmp/test'), + sessionManager, + checkpointManager: new CheckpointManager(), + abortController: new AbortController(), + maxTurns: 10, + contextBudget: 180_000, + compactThreshold: 0.7, + ...overrides, + }; +} + +/** Collect all messages from the query generator. */ +async function collectQueryMessages(config: QueryConfig): Promise { + const messages: QueryMessage[] = []; + try { + for await (const msg of query(config)) { + messages.push(msg); + } + } catch { + // Catch to still return collected messages + } + return messages; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Agent Loop (query)', () => { + describe('basic flow: text response', () => { + it('should yield assistant message on end_turn', async () => { + const config = createQueryConfig({ + callModel: createMockTextModel('Hello, how can I help?'), + }); + + const messages = await collectQueryMessages(config); + + const assistantMsgs = messages.filter((m) => m.type === 'assistant'); + expect(assistantMsgs.length).toBeGreaterThanOrEqual(0); + + const streamEvents = messages.filter((m) => m.type === 'stream_event'); + expect(streamEvents.length).toBeGreaterThan(0); + }); + + it('should yield stream events with text deltas', async () => { + const config = createQueryConfig({ + callModel: createMockTextModel('Test response'), + }); + + const messages = await collectQueryMessages(config); + + const streamEvents = messages.filter((m) => m.type === 'stream_event'); + expect(streamEvents.length).toBeGreaterThan(0); + + const hasMessageStart = streamEvents.some( + (m) => m.type === 'stream_event' && m.event.type === 'message_start', + ); + expect(hasMessageStart).toBe(true); + + const hasTextDelta = streamEvents.some( + (m) => + m.type === 'stream_event' && + m.event.type === 'content_block_delta' && + m.event.delta.type === 'text_delta' && + m.event.delta.text === 'Test response', + ); + expect(hasTextDelta).toBe(true); + }); + }); + + describe('tool use flows', () => { + it('should execute tools and yield progress + user messages with tool results', async () => { + const config = createQueryConfig({ + callModel: createMockToolUseModel('Echo', { message: 'hello world' }), + maxTurns: 3, + }); + + const messages = await collectQueryMessages(config); + + // Should have progress events + const progressMsgs = messages.filter( + (m) => m.type === 'system' && m.subtype === 'progress', + ); + expect(progressMsgs.length).toBeGreaterThan(0); + + // Should have a user message with tool results + const userMsgs = messages.filter((m) => m.type === 'user'); + expect(userMsgs.length).toBeGreaterThan(0); + + // The user message should contain tool_result blocks + const toolResultMsg = userMsgs[0]; + if (toolResultMsg && toolResultMsg.message.content && Array.isArray(toolResultMsg.message.content)) { + const toolResults = toolResultMsg.message.content.filter( + (b) => b.type === 'tool_result', + ); + expect(toolResults.length).toBe(1); + expect(toolResults[0]!.is_error).toBe(false); + } + }); + + it('should handle tool execution errors', async () => { + const config = createQueryConfig({ + callModel: createMockToolUseModel('Failing', {}), + maxTurns: 3, + }); + + const messages = await collectQueryMessages(config); + + const userMsgs = messages.filter((m) => m.type === 'user'); + const toolResultMsg = userMsgs[0]; + if (toolResultMsg && toolResultMsg.message.content && Array.isArray(toolResultMsg.message.content)) { + const errorResults = toolResultMsg.message.content.filter( + (b) => b.type === 'tool_result' && b.is_error, + ); + expect(errorResults.length).toBe(1); + } + }); + }); + + describe('exit conditions', () => { + it('should exit when maxTurns is reached', async () => { + const config = createQueryConfig({ + callModel: createMockToolUseModel('Echo', { message: 'test' }), + maxTurns: 2, + }); + + const messages = await collectQueryMessages(config); + + const errorMsgs = messages.filter( + (m) => m.type === 'system' && m.subtype === 'error', + ); + expect(errorMsgs.length).toBeGreaterThan(0); + + const maxTurnsError = errorMsgs.find( + (m) => m.subtype === 'error' && m.error && m.error.code === 'MAX_TURNS', + ); + expect(maxTurnsError).toBeDefined(); + }); + + it('should stop when aborted mid-execution', async () => { + const abortController = new AbortController(); + const config = createQueryConfig({ + callModel: createMockTextModel('Hello'), + maxTurns: 5, + abortController, + }); + + // Abort right after starting + setTimeout(() => abortController.abort(), 10); + + const messages = await collectQueryMessages(config); + + // Should exit (may or may not have errors) + expect(messages.length).toBeGreaterThanOrEqual(0); + }); + + it('should exit when stop_reason is end_turn (no more tool calls)', async () => { + const config = createQueryConfig({ + callModel: createMockTextModel('All done!'), + }); + + const messages = await collectQueryMessages(config); + + const maxTurnsErrors = messages.filter( + (m) => m.type === 'system' && m.subtype === 'error' && m.error && m.error.code === 'MAX_TURNS', + ); + expect(maxTurnsErrors.length).toBe(0); + }); + }); + + describe('error recovery', () => { + it('should catch API errors and yield error system message', async () => { + const config = createQueryConfig({ + callModel: createMockErrorModel('API connection failed'), + }); + + const messages = await collectQueryMessages(config); + + const errorMsgs = messages.filter( + (m) => m.type === 'system' && m.subtype === 'error', + ); + expect(errorMsgs.length).toBeGreaterThan(0); + + const apiError = errorMsgs.find( + (m) => m.subtype === 'error' && m.error && m.error.code === 'API_ERROR', + ); + expect(apiError).toBeDefined(); + }); + }); + + describe('cost tracking', () => { + it('should yield cost_update stream events', async () => { + const config = createQueryConfig({ + callModel: createMockTextModel('Response'), + }); + + const messages = await collectQueryMessages(config); + + const costEvents = messages.filter( + (m) => + m.type === 'stream_event' && + m.event.type === 'cost_update', + ); + // cost_update is yielded after message_stop processing + expect(costEvents.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('termination guarantees', () => { + it('should terminate within 20 yields for a simple text response', async () => { + let yieldCount = 0; + const config = createQueryConfig({ + callModel: createMockTextModel('Hello!'), + }); + + for await (const _msg of query(config)) { + yieldCount++; + // Safety: fail fast if far beyond expected + if (yieldCount > 50) break; + } + + // A simple text response should produce < 15 yields + // (message_start, content_block_start, text_delta, content_block_stop, + // message_delta, assistant, cost_update, maybe system progress) + expect(yieldCount).toBeLessThan(50); + expect(yieldCount).toBeGreaterThan(0); + }); + + it('should terminate within 30 yields for a tool_use + end_turn response', async () => { + let yieldCount = 0; + const config = createQueryConfig({ + callModel: createMockToolUseModel('Echo', { message: 'hello' }), + maxTurns: 3, + }); + + for await (const _msg of query(config)) { + yieldCount++; + if (yieldCount > 100) break; + } + + // Tool use + end_turn should be well under 100 yields + expect(yieldCount).toBeLessThan(100); + expect(yieldCount).toBeGreaterThan(0); + }); + + it('should not yield more than maxTurns * 15 messages', async () => { + const maxTurns = 2; + const config = createQueryConfig({ + callModel: createMockToolUseModel('Echo', { message: 'repeated' }), + maxTurns, + }); + + let yieldCount = 0; + for await (const _msg of query(config)) { + yieldCount++; + if (yieldCount > maxTurns * 30) break; + } + + // Each turn produces at most ~15 yields (stream events + messages) + expect(yieldCount).toBeLessThan(maxTurns * 30); + }); + + it('should handle rapid abort without hanging', async () => { + const abortController = new AbortController(); + const config = createQueryConfig({ + callModel: createMockTextModel('Hello'), + maxTurns: 999, + abortController, + }); + + // Abort immediately — before the first LLM call + abortController.abort(); + + const messages: QueryMessage[] = []; + for await (const msg of query(config)) { + messages.push(msg); + } + + // Should terminate immediately (0 or very few messages) + expect(messages.length).toBeLessThan(5); + }); + }); +}); diff --git a/packages/core/src/__tests__/tool-registry.test.ts b/packages/core/src/__tests__/tool-registry.test.ts new file mode 100644 index 0000000..1e5230f --- /dev/null +++ b/packages/core/src/__tests__/tool-registry.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { ToolRegistry, CATEGORY_MAP } from '../tool-registry.js'; +import { BaseTool, RiskLevel } from '@coder/shared'; +import type { ToolDefinition, ToolContext, ToolExecutionResult } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +class MockReadTool extends BaseTool { + get definition(): ToolDefinition { + return { + name: 'Read', + description: 'Read a file', + inputSchema: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] }, + riskLevel: RiskLevel.SAFE, + }; + } + + async execute(input: unknown, _ctx: ToolContext): Promise { + const { file_path } = input as { file_path: string }; + return `Content of ${file_path}`; + } +} + +class MockWriteTool extends BaseTool { + get definition(): ToolDefinition { + return { + name: 'Write', + description: 'Write a file', + inputSchema: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] }, + riskLevel: RiskLevel.MUTATION, + }; + } + + async execute(input: unknown, _ctx: ToolContext): Promise { + const { file_path } = input as { file_path: string }; + return `Wrote ${file_path}`; + } +} + +class MockBashTool extends BaseTool { + get definition(): ToolDefinition { + return { + name: 'Bash', + description: 'Execute a bash command', + inputSchema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] }, + riskLevel: RiskLevel.DESTRUCTIVE, + }; + } + + async execute(_input: unknown, _ctx: ToolContext): Promise { + return 'executed'; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ToolRegistry', () => { + let registry: ToolRegistry; + + beforeEach(() => { + registry = new ToolRegistry(); + }); + + describe('register', () => { + it('should register a single tool', () => { + const tool = new MockReadTool(); + registry.register(tool); + expect(registry.size).toBe(1); + expect(registry.get('Read')).toBeDefined(); + }); + + it('should throw on duplicate registration', () => { + registry.register(new MockReadTool()); + expect(() => registry.register(new MockReadTool())).toThrow('Tool already registered: Read'); + }); + }); + + describe('registerAll', () => { + it('should register multiple tools', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool()]); + expect(registry.size).toBe(2); + }); + }); + + describe('unregister', () => { + it('should remove a tool', () => { + registry.register(new MockReadTool()); + expect(registry.unregister('Read')).toBe(true); + expect(registry.size).toBe(0); + }); + + it('should return false for non-existent tool', () => { + expect(registry.unregister('Nope')).toBe(false); + }); + }); + + describe('get', () => { + it('should get a registered tool by name', () => { + registry.register(new MockReadTool()); + const entry = registry.get('Read'); + expect(entry).toBeDefined(); + expect(entry!.definition.name).toBe('Read'); + }); + + it('should return undefined for unknown tool', () => { + expect(registry.get('Unknown')).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('should return all registered tools', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool()]); + const all = registry.getAll(); + expect(all).toHaveLength(2); + }); + + it('should return empty array when no tools registered', () => { + expect(registry.getAll()).toEqual([]); + }); + }); + + describe('getDefinitions', () => { + it('should return tool definitions', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool()]); + const defs = registry.getDefinitions(); + expect(defs).toHaveLength(2); + expect(defs[0]!.name).toBeDefined(); + expect(defs[0]!.riskLevel).toBeDefined(); + }); + }); + + describe('getDefinitionsForMode', () => { + it('should filter to SAFE tools in plan mode', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool(), new MockBashTool()]); + const defs = registry.getDefinitionsForMode('plan'); + expect(defs).toHaveLength(1); + expect(defs[0]!.name).toBe('Read'); + }); + + it('should return all tools in ask mode', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool(), new MockBashTool()]); + const defs = registry.getDefinitionsForMode('ask'); + expect(defs).toHaveLength(3); + }); + + it('should return all tools in auto mode', () => { + registry.registerAll([new MockReadTool(), new MockBashTool()]); + const defs = registry.getDefinitionsForMode('auto'); + expect(defs).toHaveLength(2); + }); + }); + + describe('getByCategory', () => { + it('should filter by file_system category', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool(), new MockBashTool()]); + const fileTools = registry.getByCategory('file_system'); + expect(fileTools).toHaveLength(2); + }); + + it('should filter by shell category', () => { + registry.registerAll([new MockReadTool(), new MockBashTool()]); + const shellTools = registry.getByCategory('shell'); + expect(shellTools).toHaveLength(1); + expect(shellTools[0]!.definition.name).toBe('Bash'); + }); + + it('should return empty for unknown category', () => { + registry.register(new MockReadTool()); + expect(registry.getByCategory('browser')).toHaveLength(0); + }); + }); + + describe('getByRiskLevel', () => { + it('should filter by SAFE risk level', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool(), new MockBashTool()]); + const safe = registry.getByRiskLevel(RiskLevel.SAFE); + expect(safe).toHaveLength(1); + expect(safe[0]!.definition.name).toBe('Read'); + }); + + it('should filter by MUTATION risk level', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool()]); + const mutation = registry.getByRiskLevel(RiskLevel.MUTATION); + expect(mutation).toHaveLength(1); + expect(mutation[0]!.definition.name).toBe('Write'); + }); + + it('should filter by DESTRUCTIVE risk level', () => { + registry.registerAll([new MockReadTool(), new MockBashTool()]); + const destructive = registry.getByRiskLevel(RiskLevel.DESTRUCTIVE); + expect(destructive).toHaveLength(1); + expect(destructive[0]!.definition.name).toBe('Bash'); + }); + }); + + describe('execute', () => { + it('should execute a registered tool', async () => { + registry.register(new MockReadTool()); + const result = await registry.execute('Read', { file_path: '/tmp/test.txt' }, { + sessionId: 's1', cwd: '/tmp', + }); + expect(result.success).toBe(true); + expect(result.output).toContain('/tmp/test.txt'); + }); + + it('should return error for unknown tool', async () => { + const result = await registry.execute('Unknown', {}, { + sessionId: 's1', cwd: '/tmp', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown tool'); + }); + }); + + describe('iteration', () => { + it('should be iterable', () => { + registry.registerAll([new MockReadTool(), new MockWriteTool()]); + const names: string[] = []; + for (const entry of registry) { + names.push(entry.definition.name); + } + expect(names.sort()).toEqual(['Read', 'Write']); + }); + }); +}); + +describe('CATEGORY_MAP', () => { + it('should map Read to file_system', () => { + expect(CATEGORY_MAP['Read']).toBe('file_system'); + }); + + it('should map Bash to shell', () => { + expect(CATEGORY_MAP['Bash']).toBe('shell'); + }); + + it('should map Grep to search', () => { + expect(CATEGORY_MAP['Grep']).toBe('search'); + }); + + it('should map Git to version_control', () => { + expect(CATEGORY_MAP['Git']).toBe('version_control'); + }); +}); diff --git a/packages/core/src/budget-store.ts b/packages/core/src/budget-store.ts new file mode 100644 index 0000000..72465b3 --- /dev/null +++ b/packages/core/src/budget-store.ts @@ -0,0 +1,345 @@ +/** + * budget-store.ts — Tool Result Budget: disk offload for large outputs + * + * Prevents large tool outputs from consuming the LLM context budget. + * Three-layer strategy: + * + * Level 1 — Single result >50K chars: + * Write to disk, replace with preview + file_id reference + * + * Level 2 — Aggregate >200K chars: + * Batch-offload all results, produce index summary + * + * Level 3 — Three-zone freeze: + * mustReapply: always include in API calls + * frozen: disk-offloaded, skipped in API calls + * fresh: new results, passed to API (may be Level-1 truncated) + * + * Design principles: + * - Async non-blocking: disk writes are synchronous for now (kept simple) + * but the caller fires them in a fire-and-forget style + * - Memory-safe: large content is written to disk, not held in memory + * - Path: ~/.coder/tool-results//.txt + * - fileId format: tool__ + * + * Tool Result Budget stage in the context compactor pipeline. + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Single result above this threshold triggers offload */ +const SINGLE_RESULT_THRESHOLD = 50 * 1024; // 50KB chars + +/** Aggregate tool results above this trigger batch offload */ +const AGGREGATE_THRESHOLD = 200 * 1024; // 200KB chars + +/** Number of preview characters to keep in-memory */ +const PREVIEW_LENGTH = 500; + +/** Default cleanup age: 7 days */ +const DEFAULT_CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BudgetEntry { + fileId: string; + sessionId: string; + toolUseId: string; + toolName: string; + originalSize: number; + truncated: boolean; + filePath: string; // Absolute disk path + preview: string; // First PREVIEW_LENGTH chars +} + +export interface MaybeOffloadResult { + content: string; // Replacement content (preview + ref or original) + entry?: BudgetEntry; // Non-null if the result was offloaded +} + +// --------------------------------------------------------------------------- +// BudgetStore +// --------------------------------------------------------------------------- + +export class BudgetStore { + private baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = baseDir ?? join(homedir(), '.coder', 'tool-results'); + } + + // ----------------------------------------------------------------------- + // Level 1: Single-result offload + // ----------------------------------------------------------------------- + + /** + * If `result` exceeds SINGLE_RESULT_THRESHOLD (50KB), write it to disk + * and return a truncated preview. Otherwise return the original content. + * + * @param toolUseId — tool_use block ID from the assistant message + * @param toolName — tool name (e.g. "Bash", "Grep", "Read") + * @param result — the raw tool output string + * @param sessionId — current session ID + */ + maybeOffload( + toolUseId: string, + toolName: string, + result: string, + sessionId: string, + ): MaybeOffloadResult { + const size = Buffer.byteLength(result, 'utf-8'); + + // Under threshold — return as-is + if (size <= SINGLE_RESULT_THRESHOLD) { + return { content: result }; + } + + // ── Offload: write to disk, keep preview ────────────────────────── + const fileId = `tool_${toolUseId}_${Date.now()}`; + const sessionDir = this.ensureSessionDir(sessionId); + const filePath = join(sessionDir, `${fileId}.txt`); + + const preview = result.slice(0, PREVIEW_LENGTH); + const truncatedContent = this.buildTruncatedMessage(preview, fileId, size); + + const entry: BudgetEntry = { + fileId, + sessionId, + toolUseId, + toolName, + originalSize: size, + truncated: true, + filePath, + preview, + }; + + // Write full result to disk (synchronous for simplicity — called + // from the Agent Loop synchronously so we don't race with cleanup). + try { + writeFileSync(filePath, result, 'utf-8'); + } catch (err) { + // Disk write failed — fall back to original content + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[budget-store] write failed for ${fileId}: ${msg}\n`); + return { content: result }; + } + + return { content: truncatedContent, entry }; + } + + // ----------------------------------------------------------------------- + // Level 2: Aggregate offload + // ----------------------------------------------------------------------- + + /** + * Check whether the combined tool results exceed the aggregate threshold + * (200KB). If so, all results should be batch-offloaded. + */ + shouldAggregateOffload(results: string[], threshold?: number): boolean { + const total = results.reduce((sum, r) => sum + Buffer.byteLength(r, 'utf-8'), 0); + return total > (threshold ?? AGGREGATE_THRESHOLD); + } + + /** + * Batch-offload all results and return an index summary. + * + * @returns replacement content (index summary) and list of all entries + */ + batchOffload( + results: Array<{ toolUseId: string; toolName: string; content: string }>, + sessionId: string, + ): { content: string; entries: BudgetEntry[] } { + const entries: BudgetEntry[] = []; + const indexLines: string[] = [ + `[BudgetStore] ${results.length} tool results offloaded to disk (aggregate threshold exceeded):`, + '', + ]; + + for (const r of results) { + const size = Buffer.byteLength(r.content, 'utf-8'); + const fileId = `tool_${r.toolUseId}_${Date.now()}`; + const sessionDir = this.ensureSessionDir(sessionId); + const filePath = join(sessionDir, `${fileId}.txt`); + + const preview = r.content.slice(0, PREVIEW_LENGTH); + + entries.push({ + fileId, + sessionId, + toolUseId: r.toolUseId, + toolName: r.toolName, + originalSize: size, + truncated: true, + filePath, + preview, + }); + + try { + writeFileSync(filePath, r.content, 'utf-8'); + } catch { + // Fall back gracefully + } + + indexLines.push( + ` [${r.toolName}] ${fileId} — ${(size / 1024).toFixed(1)}KB — use BudgetStore.readFull("${fileId}") to retrieve`, + ); + } + + indexLines.push(''); + indexLines.push('Preview of first result:'); + indexLines.push(entries[0]?.preview ?? '(empty)'); + + return { content: indexLines.join('\n'), entries }; + } + + // ----------------------------------------------------------------------- + // Read full result + // ----------------------------------------------------------------------- + + /** + * Read the full (untruncated) tool result from disk. + * + * @returns full content string, or null if not found / already cleaned up + */ + readFull(fileId: string, sessionId?: string): string | null { + // If sessionId is provided, try that session dir first + if (sessionId) { + const path = join(this.baseDir, sessionId, `${fileId}.txt`); + if (existsSync(path)) { + try { + return readFileSync(path, 'utf-8'); + } catch { + // Fall through to scan + } + } + } + + // Scan all session dirs + if (!existsSync(this.baseDir)) return null; + + try { + for (const dir of readdirSync(this.baseDir, { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + const path = join(this.baseDir, dir.name, `${fileId}.txt`); + if (existsSync(path)) { + return readFileSync(path, 'utf-8'); + } + } + } catch { + return null; + } + + return null; + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + /** + * Remove tool result files older than maxAge (default: 7 days). + * + * @returns number of files deleted + */ + cleanup(maxAge = DEFAULT_CLEANUP_AGE_MS): number { + if (!existsSync(this.baseDir)) return 0; + + const cutoff = Date.now() - maxAge; + let deleted = 0; + + try { + for (const dir of readdirSync(this.baseDir, { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + const sessionDir = join(this.baseDir, dir.name); + + try { + for (const file of readdirSync(sessionDir)) { + if (!file.endsWith('.txt')) continue; + const filePath = join(sessionDir, file); + + // Parse timestamp from fileId: tool__ + const tsMatch = file.match(/_(\d{13})\.txt$/); + if (tsMatch) { + const ts = parseInt(tsMatch[1]!, 10); + if (ts < cutoff) { + try { + unlinkSync(filePath); + deleted++; + } catch { + // Already deleted or permission error + } + } + } + } + + // Remove empty session dirs + const remaining = readdirSync(sessionDir); + if (remaining.length === 0) { + try { + unlinkSync(sessionDir); + } catch { + // May still have hidden files + } + } + } catch { + // Skip malformed session dirs + } + } + } catch { + // Base dir issues + return deleted; + } + + return deleted; + } + + // ----------------------------------------------------------------------- + // Zone helpers + // ----------------------------------------------------------------------- + + /** + * Check whether a tool result has been offloaded (i.e. its content is + * a truncated preview rather than the full output). + * + * Heuristic: if the content starts with "[BudgetStore]" or contains a + * file_id reference, it's been offloaded. + */ + isOffloaded(content: string): boolean { + return content.startsWith('[BudgetStore]') || content.includes('(truncated — use BudgetStore'); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private ensureSessionDir(sessionId: string): string { + const dir = join(this.baseDir, sessionId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; + } + + private buildTruncatedMessage(preview: string, fileId: string, originalSize: number): string { + const sizeStr = + originalSize >= 1024 * 1024 + ? `${(originalSize / (1024 * 1024)).toFixed(1)}MB` + : `${(originalSize / 1024).toFixed(1)}KB`; + + return [ + `[BudgetStore] Output truncated (${sizeStr}) — use BudgetStore.readFull("${fileId}") to retrieve full content`, + '', + preview, + '', + `... ${sizeStr} truncated ...`, + ].join('\n'); + } +} diff --git a/packages/core/src/checkpoint.ts b/packages/core/src/checkpoint.ts new file mode 100644 index 0000000..d846947 --- /dev/null +++ b/packages/core/src/checkpoint.ts @@ -0,0 +1,543 @@ +/** + * checkpoint.ts — Git snapshot checkpoint + Auto file-change tracking + * + * Two subsystems coexist: + * 1. Manual Git stash snapshots (create/restore) — for pre-destructive ops + * 2. Auto file-change checkpoints (autoCreate/list/get/cleanup) — for /rewind + * + * Auto-checkpoints are created in fire-and-forget mode after every Write/Edit. + * They persist as JSONL lines in ~/.coder/checkpoints/.jsonl. + * + * Content truncation rules: + * · ≤10KB → full content stored + * · >10KB → first 5KB + last 5KB with "[... truncated ...]" marker + * · >1MB → only SHA-256 hash stored (content discarded) + * + * FileHistory-based checkpoint mechanism. + */ + +import { exec } from 'node:child_process'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, createReadStream, statSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID, createHash } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Types (manual Git checkpoint) +// --------------------------------------------------------------------------- + +export interface Checkpoint { + id: string; + sessionId: string; + createdAt: string; + description: string; + /** Git commit hash at checkpoint time */ + commitHash?: string; + /** Files changed since this checkpoint */ + changedFiles: string[]; + /** Stash reference (if using git stash) */ + stashRef?: string; +} + +export interface CheckpointCreateOptions { + sessionId: string; + cwd: string; + description?: string; +} + +export interface CheckpointRestoreResult { + success: boolean; + checkpointId: string; + restoredFiles: string[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// Types (auto file-change checkpoint) +// --------------------------------------------------------------------------- + +export interface AutoCheckpointEntry { + id: string; + sessionId: string; + turnNumber: number; + timestamp: Date; + toolName: string; // 'Write' | 'Edit' + filePath: string; // Absolute path to the modified file + contentBefore?: string; // File content before the change (truncated per rules) + contentAfter?: string; // File content after the change (truncated per rules) + diff?: string; // Unified diff + contentHash?: string; // SHA-256 when content was too large (>1MB) + message?: string; // Auto-generated summary +} + +export interface AutoCheckpointCreateInput { + sessionId: string; + turnNumber: number; + toolName: string; + filePath: string; + cwd: string; + /** Content before tool execution (the Read returned this) */ + contentBefore?: string; + /** Read content from disk after execution (default: true) */ + readAfter?: boolean; +} + +export interface AutoCheckpointListOptions { + limit?: number; + before?: Date; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TEN_KB = 10 * 1024; +const ONE_MB = 1024 * 1024; +const DEFAULT_CLEANUP_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// --------------------------------------------------------------------------- +// Checkpoint Manager +// --------------------------------------------------------------------------- + +export class CheckpointManager { + private checkpoints: Map = new Map(); + + // ========================================================================= + // Manual Git checkpoint API (existing, unchanged) + // ========================================================================= + + /** + * Create a Git stash-based snapshot. + */ + async create(options: CheckpointCreateOptions): Promise { + const id = randomUUID(); + const createdAt = new Date().toISOString(); + + const checkpoint: Checkpoint = { + id, + sessionId: options.sessionId, + createdAt, + description: options.description ?? `Checkpoint ${id.slice(0, 8)}`, + changedFiles: [], + }; + + try { + // Get current commit hash + const commitHash = await this.gitExec(options.cwd, 'git rev-parse HEAD'); + + // Create stash of current changes (including untracked) + // Only stash if there are changes + const status = await this.gitExec(options.cwd, 'git status --porcelain'); + if (status.trim()) { + const stashResult = await this.gitExec(options.cwd, 'git stash create'); + checkpoint.stashRef = stashResult.trim(); + } + + // Get list of changed files + const diffFiles = await this.gitExec(options.cwd, 'git diff --name-only HEAD'); + const untrackedFiles = await this.gitExec(options.cwd, 'git ls-files --others --exclude-standard'); + checkpoint.changedFiles = [ + ...diffFiles.trim().split('\n').filter(Boolean), + ...untrackedFiles.trim().split('\n').filter(Boolean), + ]; + + checkpoint.commitHash = commitHash.trim(); + } catch (err) { + // Non-git directory — create an empty checkpoint + checkpoint.commitHash = undefined; + } + + // Persist to disk + this.saveCheckpoint(options.sessionId, checkpoint); + this.checkpoints.set(id, checkpoint); + + return checkpoint; + } + + /** + * Restore to a previous checkpoint using git stash pop. + */ + async restore(checkpointId: string, cwd: string): Promise { + const checkpoint = this.checkpoints.get(checkpointId); + if (!checkpoint) { + return { success: false, checkpointId, restoredFiles: [], error: 'Checkpoint not found' }; + } + + try { + // Discard current changes + await this.gitExec(cwd, 'git checkout -- .'); + await this.gitExec(cwd, 'git clean -fd'); + + // Pop the stash if available + if (checkpoint.stashRef) { + await this.gitExec(cwd, `git stash pop ${checkpoint.stashRef}`); + } + + return { + success: true, + checkpointId, + restoredFiles: checkpoint.changedFiles, + }; + } catch (err) { + return { + success: false, + checkpointId, + restoredFiles: [], + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * List all Git-stash checkpoints for a session. + */ + listGitCheckpoints(sessionId: string): Checkpoint[] { + return this.list(sessionId); + } + + /** + * Load checkpoints from disk for a session. + */ + loadFromDisk(sessionId: string): Checkpoint[] { + const dir = getSessionDir(sessionId); + const path = join(dir, 'checkpoints.json'); + + if (!existsSync(path)) return []; + + try { + const data = JSON.parse(readFileSync(path, 'utf-8')) as Checkpoint[]; + for (const ck of data) { + this.checkpoints.set(ck.id, ck); + } + return data; + } catch { + return []; + } + } + + // ========================================================================= + // Auto file-change checkpoint API (NEW) + // ========================================================================= + + /** + * Automatically create a file-change checkpoint after Write/Edit success. + * + * Non-blocking by design — callers should fire-and-forget. + * All errors are caught and logged to stderr (never thrown). + * + * Content truncation: + * · ≤10KB → full content + * · >10KB → first 5KB + last 5KB + * · >1MB → SHA-256 hash only + */ + async autoCreate(input: AutoCheckpointCreateInput): Promise { + const id = randomUUID(); + const timestamp = new Date(); + + const entry: AutoCheckpointEntry = { + id, + sessionId: input.sessionId, + turnNumber: input.turnNumber, + timestamp, + toolName: input.toolName, + filePath: input.filePath, + }; + + try { + // ── Read file content before (if provided) ────────────────────── + if (input.contentBefore !== undefined) { + entry.contentBefore = this.truncateContent(input.contentBefore, input.filePath); + } + + // ── Read file content after (from disk) ───────────────────────── + if (input.readAfter !== false && existsSync(input.filePath)) { + const stat = statSync(input.filePath); + if (stat.size > ONE_MB) { + // Too large — store hash only + entry.contentHash = await this.sha256File(input.filePath); + entry.message = `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB), hash: ${entry.contentHash.slice(0, 16)}`; + } else { + const raw = readFileSync(input.filePath, 'utf-8'); + entry.contentAfter = this.truncateContent(raw, input.filePath); + } + } + + // ── Generate diff ─────────────────────────────────────────────── + if (entry.contentBefore !== undefined && entry.contentAfter !== undefined) { + // Lazy-import diff to avoid circular dependency issues at module load + const { unifiedDiff } = await import('@coder/shared'); + const diff = unifiedDiff( + entry.contentBefore, + entry.contentAfter, + `--- a/${input.filePath}`, + `+++ b/${input.filePath}`, + ); + if (diff) { + // Truncate diff to reasonable size (50KB max) + entry.diff = diff.length > 50 * 1024 + ? diff.slice(0, 25 * 1024) + '\n[... diff truncated ...]\n' + diff.slice(-25 * 1024) + : diff; + } + } else if (entry.contentHash) { + entry.diff = `[binary / large file — hash: ${entry.contentHash}]`; + } + + // ── Auto-generate message ─────────────────────────────────────── + if (!entry.message) { + const fileName = input.filePath.split('/').pop() ?? input.filePath; + const changeDesc = entry.diff + ? `(${entry.diff.split('\n').filter(l => l.startsWith('+')).length} additions, ${entry.diff.split('\n').filter(l => l.startsWith('-')).length} deletions)` + : ''; + entry.message = `${input.toolName}: ${fileName} ${changeDesc}`.trim(); + } + + // ── Persist to JSONL ──────────────────────────────────────────── + this.appendAutoCheckpoint(input.sessionId, entry); + + // ── Periodic cleanup ──────────────────────────────────────────── + void this.cleanup(input.sessionId).catch(() => { + // Cleanup failures are non-fatal + }); + + return entry; + } catch (err) { + // Silent failure — autoCreate must never throw + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[checkpoint] autoCreate failed for ${input.filePath}: ${msg}\n`); + return null; + } + } + + /** + * List auto-checkpoints for a session (newest first). + */ + async listAutoCheckpoints( + sessionId: string, + options?: AutoCheckpointListOptions, + ): Promise { + const path = getAutoCheckpointPath(sessionId); + if (!existsSync(path)) return []; + + const entries: AutoCheckpointEntry[] = []; + + try { + // Read JSONL line by line, newest first (reverse) + const allLines = readFileSync(path, 'utf-8') + .split('\n') + .filter(Boolean); + + for (let i = allLines.length - 1; i >= 0; i--) { + const line = allLines[i]!; + try { + const entry = JSON.parse(line) as AutoCheckpointEntry; + // Parse timestamp back to Date + entry.timestamp = new Date(entry.timestamp); + + // Filter by 'before' option + if (options?.before && entry.timestamp >= options.before) continue; + + entries.push(entry); + + // Respect limit + if (options?.limit && entries.length >= options.limit) break; + } catch { + // Skip malformed lines + } + } + } catch { + return []; + } + + return entries; + } + + /** + * Get a single auto-checkpoint by ID. + * + * Scans the JSONL file — efficient for occasional lookups. + * For frequent access, consider an in-memory index. + */ + async getAutoCheckpoint(id: string): Promise { + // We need the sessionId to know which file to scan. + // Strategy: load from memory first, then scan all session files. + // For now, scan the last 5 session dirs (most likely to hit). + + const checkpointsDir = join(homedir(), '.coder', 'checkpoints'); + if (!existsSync(checkpointsDir)) return null; + + const files = readdirSync(checkpointsDir) + .filter(f => f.endsWith('.jsonl')) + .sort() + .reverse() + .slice(0, 10); // Scan last 10 sessions + + for (const file of files) { + const path = join(checkpointsDir, file); + try { + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean); + // Search from end (newest first) + for (let i = lines.length - 1; i >= 0; i--) { + const entry = JSON.parse(lines[i]!) as AutoCheckpointEntry; + if (entry.id === id) { + entry.timestamp = new Date(entry.timestamp); + return entry; + } + } + } catch { + // Skip malformed files + } + } + + return null; + } + + /** + * Clean up auto-checkpoints older than maxAge (default: 30 days). + * + * Returns the number of entries deleted. + * Called automatically after each autoCreate, and can be called manually. + */ + async cleanup(sessionId?: string, maxAge = DEFAULT_CLEANUP_AGE_MS): Promise { + const checkpointsDir = join(homedir(), '.coder', 'checkpoints'); + if (!existsSync(checkpointsDir)) return 0; + + const cutoff = Date.now() - maxAge; + let deleted = 0; + + const files = sessionId + ? [`${sessionId}.jsonl`].filter(f => existsSync(join(checkpointsDir, f))) + : readdirSync(checkpointsDir).filter(f => f.endsWith('.jsonl')); + + for (const file of files) { + const path = join(checkpointsDir, file); + try { + const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean); + const kept: string[] = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as AutoCheckpointEntry; + const ts = new Date(entry.timestamp).getTime(); + if (ts < cutoff) { + deleted++; + } else { + kept.push(line); + } + } catch { + // Malformed line — drop it + deleted++; + } + } + + if (kept.length === 0) { + // Remove empty file + unlinkSync(path); + } else { + writeFileSync(path, kept.join('\n') + '\n', 'utf-8'); + } + } catch { + // File may have been deleted between check and read + } + } + + return deleted; + } + + // ----------------------------------------------------------------------- + // Public: list (backward-compatible with old API) + // ----------------------------------------------------------------------- + + /** + * List all Git-stash checkpoints for a session. + * + * NOTE: This returns only manual Git checkpoints. + * Use listAutoCheckpoints() for auto file-change checkpoints. + */ + list(sessionId: string): Checkpoint[] { + return Array.from(this.checkpoints.values()) + .filter((c) => c.sessionId === sessionId) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** + * Truncate file content according to rules: + * ≤10KB → return as-is + * >10KB → first 5KB + last 5KB with marker + */ + private truncateContent(content: string, _filePath: string): string { + const len = Buffer.byteLength(content, 'utf-8'); + if (len <= TEN_KB) return content; + + const first = content.slice(0, 5 * 1024); + const last = content.slice(-5 * 1024); + return `${first}\n\n[... ${((len - TEN_KB) / 1024).toFixed(1)}KB truncated ...]\n\n${last}`; + } + + /** + * Compute SHA-256 hash of a file (for large files >1MB). + */ + private sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk as Buffer)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); + } + + /** + * Append an auto-checkpoint entry as a JSONL line. + */ + private appendAutoCheckpoint(sessionId: string, entry: AutoCheckpointEntry): void { + const dir = join(homedir(), '.coder', 'checkpoints'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const path = join(dir, `${sessionId}.jsonl`); + const line = JSON.stringify(entry) + '\n'; + appendFileSync(path, line, 'utf-8'); + } + + private saveCheckpoint(sessionId: string, checkpoint: Checkpoint): void { + const dir = getSessionDir(sessionId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const path = join(dir, 'checkpoints.json'); + const existing = this.loadFromDisk(sessionId); + existing.unshift(checkpoint); // Newest first + + writeFileSync(path, JSON.stringify(existing, null, 2), 'utf-8'); + } + + private gitExec(cwd: string, command: string): Promise { + return new Promise((resolve, reject) => { + exec(command, { cwd, timeout: 10_000 }, (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + }); + }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getSessionDir(sessionId: string): string { + return join(homedir(), '.coder', 'sessions', sessionId); +} + +function getAutoCheckpointPath(sessionId: string): string { + return join(homedir(), '.coder', 'checkpoints', `${sessionId}.jsonl`); +} diff --git a/packages/core/src/context/compactor.ts b/packages/core/src/context/compactor.ts new file mode 100644 index 0000000..ae1b1e6 --- /dev/null +++ b/packages/core/src/context/compactor.ts @@ -0,0 +1,829 @@ +/** + * compactor.ts — Context compaction with dual strategy (Snip + Summarize) + * + * Manages context window pressure by compacting message history when the + * token budget threshold is exceeded. + * + * Strategies: + * - "none": No compaction needed (pressure < 40%) + * - "snip": Drop oldest messages, keep system context + recent N messages + * - "summarize": Use LLM to generate a conversation summary (replaces history) + * - "error": Context overflow — cannot compact further + * + * Architecture reference: ARCHITECTURE.md §4.5 + */ + +import type { Message, ContentBlock, StreamEvent } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type CompactStrategy = 'none' | 'snip' | 'summarize' | 'error'; + +// --------------------------------------------------------------------------- +// Microcompact types (zero-LLM lightweight cleanup) +// --------------------------------------------------------------------------- + +export type MicrocompactStrategy = 'none' | 'time_based' | 'cache_edit'; + +export interface MicrocompactResult { + messages: Message[]; + removedCount: number; + savedTokens: number; + strategy: MicrocompactStrategy; +} + +/** Idle threshold for time-based microcompact: 60 minutes */ +export const MICROCOMPACT_IDLE_THRESHOLD_MS = 60 * 60 * 1000; + +/** Number of recent tool-result turns to keep during cleanup */ +export const MICROCOMPACT_KEEP_RECENT_TOOL_TURNS = 3; + +export interface TokenBudget { + /** Current estimated token count */ + current: number; + /** Maximum allowed tokens (context window budget) */ + max: number; + /** Ratio of current / max (0–1) */ + ratio: number; +} + +export interface CompactorConfig { + /** Token thresholds for strategy selection */ + thresholds: { + /** Below this ratio → "none" strategy (default: 0.4) */ + safe: number; + /** Below this ratio → "snip" strategy (default: 0.6) */ + snip: number; + /** Below this ratio → "summarize" strategy (default: 0.85) */ + summarize: number; + /** Above this ratio → "error" (cannot compact further, default: 0.95) */ + overflow: number; + }; + /** Maximum number of turns to keep when snipping */ + maxTurnsToKeep: number; + /** Minimum number of messages to keep (system prompt + recent) */ + minMessagesToKeep: number; + /** Function to estimate tokens in a message array */ + estimateTokens: (messages: Message[]) => number; + /** Function to call the LLM for summarization */ + summarizeModel?: (params: { system: string; messages: Message[]; tools: unknown[]; signal: AbortSignal }) => AsyncGenerator; + /** System prompt to prepend to summaries */ + systemPromptForSummary?: string; + /** Whether summarization is enabled (false → always use snip) */ + summarizeEnabled: boolean; + /** + * Optional PreCompact hook callback. + * Called before compaction starts. Receives the pre-compaction context. + * Returned `injectContext` is inserted as a system message before the + * compacted messages. + */ + onPreCompact?: (ctx: { + messageCount: number; + currentTokens: number; + budgetTokens: number; + strategy: CompactStrategy; + }) => Promise<{ injectContext: string }>; +} + +export interface CompactResult { + /** Strategy that was used */ + strategy: CompactStrategy; + /** Token count before compaction */ + beforeTokens: number; + /** Token count after compaction */ + afterTokens: number; + /** The compacted message array */ + messages: Message[]; + /** Summary text (only set for "summarize" strategy) */ + summary?: string; + /** Number of messages dropped or summarized */ + messagesRemoved: number; +} + +export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { + thresholds: { + safe: 0.4, + snip: 0.6, + summarize: 0.85, + overflow: 0.95, + }, + maxTurnsToKeep: 15, + minMessagesToKeep: 10, + estimateTokens: defaultEstimateTokens, + summarizeEnabled: true, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Default token estimation: roughly ~3.5 characters per token. + */ +function defaultEstimateTokens(messages: Message[]): number { + let total = 0; + for (const msg of messages) { + if (typeof msg.content === 'string') { + total += Math.ceil(msg.content.length / 3.5); + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + const text = + block.text ?? + block.content ?? + (block.input ? JSON.stringify(block.input) : ''); + total += Math.ceil(String(text).length / 3.5); + } + } + } + return total; +} + +// --------------------------------------------------------------------------- +// Compactor +// --------------------------------------------------------------------------- + +export class Compactor { + private config: CompactorConfig; + private lastStrategy: CompactStrategy = 'none'; + private compactionCount: number = 0; + private accumulatedSummary: string = ''; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_COMPACTOR_CONFIG, ...config }; + } + + /** + * Compute the current token budget from a message array. + */ + computeBudget(messages: Message[], contextBudget: number): TokenBudget { + const current = this.config.estimateTokens(messages); + return { + current, + max: contextBudget, + ratio: contextBudget > 0 ? current / contextBudget : 0, + }; + } + + /** + * Determine which compaction strategy to use based on the token budget ratio. + */ + selectStrategy(budget: TokenBudget): CompactStrategy { + const { thresholds } = this.config; + + if (budget.ratio <= thresholds.safe) { + return 'none'; + } + if (budget.ratio <= thresholds.snip) { + return 'snip'; + } + if (budget.ratio <= thresholds.summarize) { + return this.config.summarizeEnabled ? 'summarize' : 'snip'; + } + if (budget.ratio <= thresholds.overflow) { + // Already at critical levels — try aggressive snip first + return 'snip'; + } + return 'error'; + } + + /** + * Compact a message array to fit within the context budget. + * + * Entry point — selects the right strategy and returns compacted messages. + */ + async compact( + messages: Message[], + contextBudget: number, + ): Promise { + const budget = this.computeBudget(messages, contextBudget); + const strategy = this.selectStrategy(budget); + + this.lastStrategy = strategy; + + // === PreCompact hook === + let injectContext = ''; + if (this.config.onPreCompact) { + try { + const hookResult = await this.config.onPreCompact({ + messageCount: messages.length, + currentTokens: budget.current, + budgetTokens: budget.max, + strategy, + }); + injectContext = hookResult.injectContext; + } catch { + // Hook errors are non-fatal during compaction + } + } + + // If hook injected context, add it as a system message before compacting + if (injectContext) { + const hookMsg: Message = { + role: 'system', + content: `[PreCompact hook context]\n${injectContext}`, + }; + messages = [hookMsg, ...messages]; + } + + switch (strategy) { + case 'none': + return { + strategy: 'none', + beforeTokens: budget.current, + afterTokens: budget.current, + messages, + messagesRemoved: 0, + }; + + case 'snip': + return this.snip(messages, budget); + + case 'summarize': + return this.summarize(messages, budget); + + case 'error': + return { + strategy: 'error', + beforeTokens: budget.current, + afterTokens: budget.current, + messages, + messagesRemoved: 0, + }; + } + } + + /** + * Check if compaction is needed for the given messages. + */ + needsCompaction(messages: Message[], contextBudget: number): boolean { + const budget = this.computeBudget(messages, contextBudget); + return budget.ratio > this.config.thresholds.safe; + } + + // ----------------------------------------------------------------------- + // Microcompact — zero-LLM, zero-cost lightweight context cleanup + // ----------------------------------------------------------------------- + + /** + * Microcompact: zero-LLM-call, zero-cost lightweight context cleanup. + * + * Two sub-strategies that run before full-scale compaction: + * + * **A: Time-Based Cleanup** — When idle > 60min: + * - Clear old tool_result blocks (preserve last 3 turns) + * - Clear system messages before the last compact_boundary + * + * **B: Cache Edit** — Leverage prompt caching: + * - Identify cacheable prefix (system prompt + tools definitions) + * - Strip stale tool_result blocks before the cache boundary + * - Mark cacheable messages for the callModel caller + * + * Returns the cleaned messages (may be unchanged if no cleanup performed). + * + * @param messages — current message array + * @param lastInteractionTime — timestamp (ms) of last user interaction + * @param cacheablePrefixTokens — estimated token count of cacheable prefix + */ + async microcompact( + messages: Message[], + lastInteractionTime: number, + cacheablePrefixTokens?: number, + ): Promise { + const now = Date.now(); + const beforeTokens = this.config.estimateTokens(messages); + + // ── Strategy A: Time-based cleanup (idle > 60min) ───────────────── + if (now - lastInteractionTime > MICROCOMPACT_IDLE_THRESHOLD_MS) { + return this.microcompactTimeBased(messages, beforeTokens); + } + + // ── Strategy B: Cache edit ──────────────────────────────────────── + if (cacheablePrefixTokens && cacheablePrefixTokens > 0) { + return this.microcompactCacheEdit(messages, beforeTokens, cacheablePrefixTokens); + } + + return { + messages, + removedCount: 0, + savedTokens: 0, + strategy: 'none', + }; + } + + // ----------------------------------------------------------------------- + // Microcompact Strategy A: Time-Based Cleanup + // ----------------------------------------------------------------------- + + /** + * Clean up old tool results and stale system messages when the session + * has been idle for >60 minutes. + * + * Preservation rules: + * - Keep all non-tool-result, non-system messages unchanged + * - Keep tool_results from the most recent N turns (N=MICROCOMPACT_KEEP_RECENT_TOOL_TURNS) + * - Keep system messages AFTER the last compact_boundary + * - Remove older tool_results and pre-boundary system messages + */ + private microcompactTimeBased( + messages: Message[], + beforeTokens: number, + ): MicrocompactResult { + const keepTurns = MICROCOMPACT_KEEP_RECENT_TOOL_TURNS; + let removedCount = 0; + + // ── Find the last compact_boundary ────────────────────────────── + let lastBoundaryIndex = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === 'system' && typeof msg.content === 'string') { + if (msg.content.includes('[compact_boundary]') || msg.content.includes('[Context compacted:')) { + lastBoundaryIndex = i; + break; + } + } + } + + // ── Count turns from the end to find the cutoff ───────────────── + let turnCount = 0; + let keepFromIndex = messages.length - 1; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === 'assistant') { + turnCount++; + } + // Include the user message that contains tool_results for this turn + if (turnCount >= keepTurns) { + // Search back to include the user message with tool results for this turn + keepFromIndex = i; + break; + } + } + + // ── Don't cross the compact_boundary ───────────────────────────── + if (lastBoundaryIndex > keepFromIndex) { + keepFromIndex = lastBoundaryIndex; + } + + // ── Build cleaned message list ─────────────────────────────────── + const cleaned: Message[] = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + + if (i < keepFromIndex) { + // Check if this message is safe to remove + if (msg.role === 'system' && i <= lastBoundaryIndex) { + // Old system message before boundary — remove + removedCount++; + continue; + } + + if (msg.role === 'user' && typeof msg.content !== 'string' && Array.isArray(msg.content)) { + // This is likely a tool_result-bearing user message. + // Check if ALL content blocks are tool_result — if so, and it's old, remove. + const allToolResults = msg.content.every( + (block) => block.type === 'tool_result', + ); + if (allToolResults) { + removedCount++; + continue; + } + } + } + + cleaned.push(msg); + } + + // If nothing was removed, return as-is + if (removedCount === 0) { + return { + messages, + removedCount: 0, + savedTokens: 0, + strategy: 'none', + }; + } + + const afterTokens = this.config.estimateTokens(cleaned); + const savedTokens = Math.max(0, beforeTokens - afterTokens); + + // Insert a cleanup note at the keep boundary + const cleanupNote: Message = { + role: 'system', + content: `[Microcompact: time-based cleanup — ${removedCount} old tool result and system messages removed after ${Math.round((Date.now() - (Date.now() - MICROCOMPACT_IDLE_THRESHOLD_MS - 1)) / 60000)}min idle period. Saved ~${savedTokens.toLocaleString()} tokens.]`, + }; + // Insert after the last system message or at the beginning + let insertAt = 0; + for (let i = cleaned.length - 1; i >= 0; i--) { + if (cleaned[i]!.role === 'system') { + insertAt = i + 1; + break; + } + } + cleaned.splice(insertAt, 0, cleanupNote); + + return { + messages: cleaned, + removedCount, + savedTokens, + strategy: 'time_based', + }; + } + + // ----------------------------------------------------------------------- + // Microcompact Strategy B: Cache Edit + // ----------------------------------------------------------------------- + + /** + * Leverage prompt caching by removing stale tool_results that sit before + * the cacheable prefix boundary. + * + * When the model's prompt cache covers the system prompt + tools definitions + * (the "cacheable prefix"), tool_results before this boundary are already + * cached and stale — removing them saves tokens without cache miss penalty. + * + * @param messages — current message array + * @param beforeTokens — pre-computed token count + * @param cacheablePrefixTokens — estimated tokens of cacheable prefix + */ + private microcompactCacheEdit( + messages: Message[], + beforeTokens: number, + cacheablePrefixTokens: number, + ): MicrocompactResult { + let removedCount = 0; + let tokensScanned = 0; + let cacheBoundaryIndex = -1; + + // ── Find the cache boundary ────────────────────────────────────── + // Walk forward through messages, summing token estimates until we + // reach the cacheablePrefixTokens threshold. The first message + // beyond this boundary is where caching starts. + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + tokensScanned += this.config.estimateTokens([msg]); + + if (tokensScanned >= cacheablePrefixTokens) { + cacheBoundaryIndex = i; + break; + } + } + + // If no boundary found (all messages fit in cache prefix), nothing to do + if (cacheBoundaryIndex < 0) { + return { + messages, + removedCount: 0, + savedTokens: 0, + strategy: 'none', + }; + } + + // ── Remove stale tool_results before the cache boundary ───────── + // Strategy: for user messages containing ONLY tool_result blocks + // that sit entirely before the cache boundary, remove them. + // Mixed messages (text + tool_results) are preserved. + const cleaned: Message[] = []; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]!; + + if (i < cacheBoundaryIndex && + msg.role === 'user' && + typeof msg.content !== 'string' && + Array.isArray(msg.content)) { + const allToolResults = msg.content.every( + (block) => block.type === 'tool_result', + ); + if (allToolResults) { + removedCount++; + continue; + } + } + + cleaned.push(msg); + } + + if (removedCount === 0) { + return { + messages, + removedCount: 0, + savedTokens: 0, + strategy: 'none', + }; + } + + const afterTokens = this.config.estimateTokens(cleaned); + const savedTokens = Math.max(0, beforeTokens - afterTokens); + + return { + messages: cleaned, + removedCount, + savedTokens, + strategy: 'cache_edit', + }; + } + + // ----------------------------------------------------------------------- + // Snip Strategy — drop oldest messages + // ----------------------------------------------------------------------- + + /** + * Snip the oldest messages to reduce context size. + * + * Preserves: + * - System-level messages (role: 'system') + * - Most recent N messages (up to maxTurnsToKeep × 2 messages per turn) + */ + private snip(messages: Message[], budget: TokenBudget): CompactResult { + const { maxTurnsToKeep, minMessagesToKeep } = this.config; + + // Separate system messages from conversation messages + const systemMessages = messages.filter((m) => m.role === 'system'); + const conversationMessages = messages.filter((m) => m.role !== 'system'); + + // Count turns in conversation (each assistant message = 1 turn) + let turnCount = 0; + let keepFromIndex = conversationMessages.length; + + for (let i = conversationMessages.length - 1; i >= 0; i--) { + const msg = conversationMessages[i]; + if (msg && msg.role === 'assistant') { + turnCount++; + } + if (turnCount >= maxTurnsToKeep) { + keepFromIndex = i; + break; + } + } + + // Don't drop below minimum + const maxDrop = conversationMessages.length - minMessagesToKeep; + if (keepFromIndex > maxDrop) { + keepFromIndex = Math.max(0, maxDrop); + } + + const keptConversation = conversationMessages.slice(keepFromIndex); + const droppedCount = conversationMessages.length - keptConversation.length; + + // Construct boundary note if we dropped messages + const compactedMessages: Message[] = [...systemMessages]; + + if (droppedCount > 0) { + const boundaryNote: Message = { + role: 'system', + content: `[Context compacted: ${droppedCount} earlier messages were trimmed to stay within the ${budget.max.toLocaleString()}-token budget. Key context from earlier in the conversation has been preserved.]`, + }; + compactedMessages.push(boundaryNote); + this.compactionCount++; + } + + compactedMessages.push(...keptConversation); + + const afterTokens = this.config.estimateTokens(compactedMessages); + + return { + strategy: 'snip', + beforeTokens: budget.current, + afterTokens, + messages: compactedMessages, + messagesRemoved: droppedCount, + }; + } + + // ----------------------------------------------------------------------- + // Summarize Strategy — LLM-based conversation summarization + // ----------------------------------------------------------------------- + + /** + * Summarize older messages using an LLM call, then concatenate with recent + * messages. Falls back to "snip" if summarization is unavailable. + */ + private async summarize( + messages: Message[], + budget: TokenBudget, + ): Promise { + if (!this.config.summarizeModel) { + // No summarization model available → fall back to snip + return this.snip(messages, budget); + } + + const systemMessages = messages.filter((m) => m.role === 'system'); + const conversationMessages = messages.filter((m) => m.role !== 'system'); + + // Split at midpoint: first half for summarization, second half kept as-is + const midpoint = Math.floor(conversationMessages.length / 2); + const toSummarize = conversationMessages.slice(0, midpoint); + const recentMessages = conversationMessages.slice(midpoint); + + if (toSummarize.length === 0) { + return this.snip(messages, budget); + } + + try { + const summary = await this.generateSummary(toSummarize); + + // Build new message array: system + accumulated summary + recent + const compactedMessages: Message[] = [...systemMessages]; + + // Add previous accumulated summary if any (compounding) + if (this.accumulatedSummary) { + compactedMessages.push({ + role: 'system', + content: `## Previous Conversation Summary\n${this.accumulatedSummary}`, + }); + } + + // Merge new summary into accumulated + this.accumulatedSummary = this.mergeSummaries( + this.accumulatedSummary, + summary, + ); + + // Add latest summary + compactedMessages.push({ + role: 'system', + content: `## Conversation Summary (Compacted Turn)\n${summary}\n\n[${toSummarize.length} earlier messages have been summarized to conserve context. The most recent ${recentMessages.length} messages are preserved in full below.]`, + }); + + compactedMessages.push(...recentMessages); + + this.compactionCount++; + + const afterTokens = this.config.estimateTokens(compactedMessages); + + return { + strategy: 'summarize', + beforeTokens: budget.current, + afterTokens, + messages: compactedMessages, + summary, + messagesRemoved: toSummarize.length, + }; + } catch { + // Summarization failed → fall back to snip + return this.snip(messages, budget); + } + } + + /** + * Generate a summary of a set of messages using the LLM. + */ + private async generateSummary(messages: Message[]): Promise { + if (!this.config.summarizeModel) { + throw new Error('No summarizeModel configured'); + } + + const messagesText = this.messagesToText(messages); + + const summaryPrompt = + this.config.systemPromptForSummary ?? + `You are a conversation summarizer. Create a structured, concise summary of the conversation below. + +Focus on: +1. **Tasks and Requests**: What the user asked for +2. **Decisions Made**: Key technical choices and their rationale +3. **Files Modified**: Which files were changed and why +4. **Errors Encountered**: Problems that arose and their resolutions +5. **Pending Work**: What remains to be done + +Keep the summary under 500 words. Use bullet points for clarity.`; + + const summaryMessages: Message[] = [ + { + role: 'user', + content: `Please summarize this conversation segment:\n\n${messagesText}`, + }, + ]; + + // Call the summarization model + const stream = this.config.summarizeModel({ + system: summaryPrompt, + messages: summaryMessages, + tools: [], + signal: new AbortController().signal, + }); + + let summary = ''; + for await (const event of stream) { + if ('type' in event) { + const streamEvent = event as StreamEvent; + if (streamEvent.type === 'content_block_delta') { + const delta = streamEvent.delta as { type: string; text?: string }; + if (delta.type === 'text_delta' && delta.text) { + summary += delta.text; + } + } + } + } + + return summary || 'Summary generation produced no output.'; + } + + /** + * Convert messages to a readable text format for summarization. + */ + private messagesToText(messages: Message[]): string { + const lines: string[] = []; + + for (const msg of messages) { + const role = msg.role.toUpperCase(); + + if (typeof msg.content === 'string') { + lines.push(`[${role}]: ${msg.content.slice(0, 500)}`); + if (msg.content.length > 500) { + lines.push(' ... (truncated)'); + } + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + switch (block.type) { + case 'text': + lines.push( + `[${role} / text]: ${(block.text ?? '').slice(0, 300)}`, + ); + break; + case 'tool_use': + lines.push( + `[${role} / tool_use]: ${block.name}(${block.input ? JSON.stringify(block.input).slice(0, 200) : ''})`, + ); + break; + case 'tool_result': + if (block.is_error) { + lines.push( + `[${role} / tool_result error]: ${String(block.content ?? '').slice(0, 200)}`, + ); + } else { + lines.push( + `[${role} / tool_result]: ${String(block.content ?? '').slice(0, 200)}`, + ); + } + break; + case 'thinking': + lines.push(`[${role} / thinking]: (omitted)`); + break; + default: + break; + } + } + } + } + + return lines.join('\n'); + } + + /** + * Merge a new summary into the accumulated summary. + * Preserves key information while keeping the combined summary concise. + */ + private mergeSummaries(existing: string, newSummary: string): string { + if (!existing) return newSummary; + + // Simple merge: append new summary to existing, with a separator + const existingWords = existing.split(/\s+/).length; + const newWords = newSummary.split(/\s+/).length; + + // If combined > 800 words, truncate the older summary + if (existingWords + newWords > 800) { + const existingLines = existing.split('\n'); + const truncated = existingLines.slice( + 0, + Math.floor(existingLines.length * 0.4), + ); + return `${truncated.join('\n')}\n\n---\n## More Recent\n${newSummary}`; + } + + return `${existing}\n\n---\n## Update\n${newSummary}`; + } + + // ----------------------------------------------------------------------- + // Accessors + // ----------------------------------------------------------------------- + + /** Get the last compaction strategy used. */ + getLastStrategy(): CompactStrategy { + return this.lastStrategy; + } + + /** Get the number of times compaction has been triggered. */ + getCompactionCount(): number { + return this.compactionCount; + } + + /** Get the accumulated summary (across multiple compaction rounds). */ + getAccumulatedSummary(): string { + return this.accumulatedSummary; + } + + /** Reset the compactor state (for new sessions). */ + reset(): void { + this.lastStrategy = 'none'; + this.compactionCount = 0; + this.accumulatedSummary = ''; + } + + /** Get the current config (read-only). */ + getConfig(): Readonly { + return this.config; + } +} diff --git a/packages/core/src/context/token-budget.ts b/packages/core/src/context/token-budget.ts new file mode 100644 index 0000000..db138dc --- /dev/null +++ b/packages/core/src/context/token-budget.ts @@ -0,0 +1,230 @@ +/** + * token-budget.ts — Token estimation utilities + * + * Provides a standalone, reusable token estimator for the Agent Loop's + * context window management. Used by both query.ts (inline compaction + * checks) and compactor.ts (strategy selection). + * + * Estimation method: + * Character-based with per-field weighting: + * - Plain text: ~3.5 chars/token (baseline for English prose) + * - JSON/structured fields: ~2.5 chars/token (denser token packing) + * - Code blocks: ~2.0 chars/token (code is more token-dense) + * + * This is heuristic, not exact. For precise token counts, use the + * provider's tokenizer (e.g., tiktoken for OpenAI-compatible APIs). + * + * Architecture reference: ARCHITECTURE.md §4.5 + */ + +import type { Message, ContentBlock } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// TokenBudget type (canonical definition) +// --------------------------------------------------------------------------- + +export interface TokenBudget { + /** Current estimated token count of the message array */ + current: number; + /** Maximum allowed tokens for the context window */ + max: number; + /** Ratio of current / max (0–1) */ + ratio: number; + /** Percentage used (0–100), for display */ + percent: number; +} + +// --------------------------------------------------------------------------- +// Token estimation weights +// --------------------------------------------------------------------------- + +/** + * Estimated characters per token for different content types. + * Based on empirical observation of Claude's tokenizer behavior. + */ +const CHARS_PER_TOKEN = { + /** Plain English prose text */ + text: 3.5, + /** JSON strings, structured tool input/output */ + json: 2.5, + /** Code blocks (source files, diffs, shell output) */ + code: 2.0, + /** Default fallback */ + default: 3.0, +} as const; + +// --------------------------------------------------------------------------- +// Token estimation functions +// --------------------------------------------------------------------------- + +/** + * Estimate token count for a plain string. + */ +export function estimateStringTokens( + text: string, + contentType: 'text' | 'json' | 'code' = 'text', +): number { + if (!text) return 0; + const divisor = CHARS_PER_TOKEN[contentType]; + return Math.ceil(text.length / divisor); +} + +/** + * Estimate token count for a ContentBlock. + * + * Weights per block type: + * - text: standard text rate + * - tool_use: json rate (structured input) + * - tool_result: code rate (tool output is often code/diff-like) + * - thinking: text rate + * - image: 85 tokens (rough estimate for small images) + */ +export function estimateBlockTokens(block: ContentBlock): number { + switch (block.type) { + case 'text': { + const text = block.text ?? ''; + return estimateStringTokens(text, 'text'); + } + + case 'tool_use': { + let total = 0; + // Tool name + id overhead: ~10 tokens + total += 10; + // Input is structured JSON + if (block.input) { + total += estimateStringTokens( + JSON.stringify(block.input), + 'json', + ); + } + return total; + } + + case 'tool_result': { + let total = 0; + const content = + typeof block.content === 'string' + ? block.content + : block.content + ? JSON.stringify(block.content) + : ''; + // Tool results are often code/diffs — use code rate + total += estimateStringTokens(content, 'code'); + // Error flag overhead: ~5 tokens + if (block.is_error) total += 5; + return total; + } + + case 'thinking': { + const thinking = block.thinking ?? ''; + return estimateStringTokens(thinking, 'text'); + } + + case 'image': { + // Image size varies, but 85 tokens is a reasonable default + // for a small screenshot. Large images can use 200+ tokens. + return block.source?.data + ? Math.ceil(String(block.source.data).length / 50) + : 85; + } + + default: + return 0; + } +} + +/** + * Estimate token count for a single Message. + * + * Accounts for: + * - Role tag overhead (~4 tokens per message for API metadata) + * - Content blocks (weighted by type) + * - String content fallback + */ +export function estimateMessageTokens(message: Message): number { + // Base overhead per message (role tag, formatting tokens) + let tokens = 4; + + if (typeof message.content === 'string') { + tokens += estimateStringTokens(message.content, 'text'); + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + tokens += estimateBlockTokens(block); + } + } + + return tokens; +} + +/** + * Estimate total token count for an array of messages. + * + * This is the main entry point — used by query.ts and compactor.ts + * to decide whether context compaction is needed. + */ +export function estimateTokens(messages: Message[]): number { + let total = 0; + for (const msg of messages) { + total += estimateMessageTokens(msg); + } + // Add 2% buffer for inter-message formatting tokens + return Math.ceil(total * 1.02); +} + +// --------------------------------------------------------------------------- +// TokenBudget factory +// --------------------------------------------------------------------------- + +/** + * Create a TokenBudget from a message array and a maximum budget. + * + * Usage: + * const budget = createTokenBudget(messages, 180_000); + * if (budget.ratio > 0.6) { /* trigger compaction *\/ } + */ +export function createTokenBudget( + messages: Message[], + maxTokens: number, +): TokenBudget { + const current = estimateTokens(messages); + const safeMax = Math.max(1, maxTokens); + return { + current, + max: maxTokens, + ratio: current / safeMax, + percent: Math.min(100, Math.round((current / safeMax) * 100)), + }; +} + +/** + * Create a budget with a pre-computed token count. + */ +export function createTokenBudgetFromCount( + currentTokens: number, + maxTokens: number, +): TokenBudget { + const safeMax = Math.max(1, maxTokens); + return { + current: currentTokens, + max: maxTokens, + ratio: currentTokens / safeMax, + percent: Math.min(100, Math.round((currentTokens / safeMax) * 100)), + }; +} + +/** + * Check whether the token budget has been exceeded. + */ +export function isBudgetExceeded(budget: TokenBudget): boolean { + return budget.ratio >= 1; +} + +/** + * Check whether compaction is recommended (budget > safe threshold). + */ +export function needsCompaction( + budget: TokenBudget, + threshold = 0.6, +): boolean { + return budget.ratio > threshold; +} diff --git a/packages/core/src/cron-scheduler.ts b/packages/core/src/cron-scheduler.ts new file mode 100644 index 0000000..cac39ad --- /dev/null +++ b/packages/core/src/cron-scheduler.ts @@ -0,0 +1,363 @@ +/** + * cron-scheduler.ts — Cron task scheduler + * + * Manages timed prompts from ~/.coder/scheduled_tasks.json. Parses cron + * expressions, computes next-run times, and fires prompts by enqueueing + * them into the active Agent Loop via a registered callback. + * + * Features: + * - Loads persistent (durable=true) tasks from disk on startup + * - Computes next fire time for each task using the cron expression + * - Schedules timeouts to fire prompts at the correct time + * - Deletes one-shot tasks after they fire + * - Reference: Claude Code's CronCreate/CronDelete/CronList tools + * + * Architecture: The scheduler is a module-level singleton. Register it + * via getCronScheduler() / setCronScheduler(). The QueryEngine or engine + * factory should create and store a scheduler instance at boot. + */ + +import { EventEmitter } from 'node:events'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +/** + * Scheduled task descriptor — defined here to avoid cross-package imports. + * Mirrors the interface from @coder/tools cron-create.ts. + */ +export interface ScheduledTask { + id: string; + cron: string; + prompt: string; + recurring: boolean; + durable: boolean; + createdAt: string; + nextRun: string; + lastFired?: string; +} + +// --------------------------------------------------------------------------- +// Helpers (re-implemented here to avoid cross-package import cycles) +// --------------------------------------------------------------------------- + +function cronMatchesField(field: string, value: number): boolean { + if (field === '*') return true; + if (field.startsWith('*/')) { + const step = parseInt(field.slice(2), 10); + return value % step === 0; + } + for (const part of field.split(',')) { + const trimmed = part.trim(); + if (trimmed.includes('-')) { + const [start, end] = trimmed.split('-'); + if (value >= parseInt(start!, 10) && value <= parseInt(end!, 10)) return true; + } else { + if (parseInt(trimmed, 10) === value) return true; + } + } + return false; +} + +function cronComputeNext(cron: string, from: Date): Date { + const fields = cron.trim().split(/\s+/); + const candidate = new Date(from); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + const maxIterations = 366 * 24 * 60; + for (let i = 0; i < maxIterations; i++) { + if ( + cronMatchesField(fields[0]!, candidate.getMinutes()) && + cronMatchesField(fields[1]!, candidate.getHours()) && + cronMatchesField(fields[2]!, candidate.getDate()) && + cronMatchesField(fields[3]!, candidate.getMonth() + 1) && + cronMatchesField(fields[4]!, candidate.getDay()) + ) { + return candidate; + } + candidate.setMinutes(candidate.getMinutes() + 1); + } + throw new Error('Could not compute next run within 366 days'); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CronSchedulerConfig { + /** Path to scheduled_tasks.json (default: ~/.coder/scheduled_tasks.json) */ + tasksPath?: string; + /** Callback invoked when a task fires */ + onFire?: (task: ScheduledTask) => void; + /** Whether to auto-start scheduling (default: true) */ + autoStart?: boolean; +} + +/** + * Callback signature: receives the task that fired and executes its prompt. + * The callback should enqueue the prompt into the active Agent Loop. + */ +export type CronFireCallback = (task: ScheduledTask) => void; + +// --------------------------------------------------------------------------- +// CronScheduler +// --------------------------------------------------------------------------- + +export class CronScheduler extends EventEmitter { + private tasks: Map = new Map(); + private timers: Map = new Map(); + private tasksPath: string; + private onFire: CronFireCallback | null; + private running = false; + + constructor(config: CronSchedulerConfig = {}) { + super(); + this.tasksPath = config.tasksPath ?? path.join(homedir(), '.coder', 'scheduled_tasks.json'); + this.onFire = config.onFire ?? null; + if (config.autoStart !== false) { + this.start(); + } + } + + // ------------------------------------------------------------------- + // Public: Lifecycle + // ------------------------------------------------------------------- + + /** + * Load durable tasks from disk and begin scheduling. + */ + start(): void { + if (this.running) return; + this.running = true; + this.loadFromDisk(); + this.scheduleAll(); + } + + /** + * Stop all timers and clear in-memory state. + */ + stop(): void { + this.running = false; + for (const [, timer] of this.timers) { + clearTimeout(timer); + } + this.timers.clear(); + } + + /** + * Register a fire callback — called when a task's time arrives. + */ + setFireCallback(cb: CronFireCallback): void { + this.onFire = cb; + } + + // ------------------------------------------------------------------- + // Public: Task management + // ------------------------------------------------------------------- + + /** + * Add a new task to the scheduler. Schedules a timer for it. + * If durable, also persists to disk. + */ + addTask(task: ScheduledTask): void { + this.tasks.set(task.id, task); + if (task.durable) { + this.persistToDisk(); + } + this.scheduleTask(task); + } + + /** + * Remove a task by ID. Clears its timer and removes from disk. + */ + removeTask(id: string): boolean { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + const existed = this.tasks.delete(id); + if (existed) { + this.persistToDisk(); + } + return existed; + } + + /** + * List all active tasks. + */ + listTasks(): ScheduledTask[] { + return Array.from(this.tasks.values()); + } + + /** + * Get a specific task by ID. + */ + getTask(id: string): ScheduledTask | undefined { + return this.tasks.get(id); + } + + /** + * Reload tasks from disk (useful after external modifications). + */ + reload(): void { + this.loadFromDisk(); + // Reschedule all + for (const [, timer] of this.timers) { + clearTimeout(timer); + } + this.timers.clear(); + this.scheduleAll(); + } + + // ------------------------------------------------------------------- + // Private: Scheduling + // ------------------------------------------------------------------- + + /** + * Schedule a timer for a single task. + */ + private scheduleTask(task: ScheduledTask): void { + // Clear any existing timer for this task + const existing = this.timers.get(task.id); + if (existing) { + clearTimeout(existing); + } + + const now = new Date(); + let nextRun: Date; + try { + nextRun = cronComputeNext(task.cron, now); + } catch { + // Invalid cron — skip scheduling + return; + } + + const delayMs = Math.max(0, nextRun.getTime() - now.getTime()); + + // Clamp to 32-bit signed int max for setTimeout safety (~24.8 days) + const MAX_TIMEOUT = 2147483647; + const effectiveDelay = Math.min(delayMs, MAX_TIMEOUT + 1000); + + const timer = setTimeout(() => { + this.fireTask(task); + }, effectiveDelay); + + // Allow the process to exit even if timers are pending + timer.unref(); + + this.timers.set(task.id, timer); + } + + /** + * Schedule timers for all loaded tasks. + */ + private scheduleAll(): void { + for (const [, task] of this.tasks) { + this.scheduleTask(task); + } + } + + /** + * Fire a task: invoke the callback, handle one-shot deletion, and reschedule recurring. + */ + private fireTask(task: ScheduledTask): void { + this.timers.delete(task.id); + + // Invoke the fire callback + if (this.onFire) { + try { + this.onFire(task); + } catch { + // Callback errors should not kill the scheduler + } + } + + this.emit('fire', task); + + // Update metadata + task.lastFired = new Date().toISOString(); + + if (!task.recurring) { + // One-shot: delete after firing + this.tasks.delete(task.id); + if (task.durable) { + this.persistToDisk(); + } + return; + } + + // Recurring: compute next run and reschedule + try { + task.nextRun = cronComputeNext(task.cron, new Date()).toISOString(); + } catch { + this.tasks.delete(task.id); + return; + } + + this.scheduleTask(task); + if (task.durable) { + this.persistToDisk(); + } + } + + // ------------------------------------------------------------------- + // Private: Persistence + // ------------------------------------------------------------------- + + private loadFromDisk(): void { + try { + if (!fs.existsSync(this.tasksPath)) return; + const raw = fs.readFileSync(this.tasksPath, 'utf-8'); + const loaded = JSON.parse(raw) as ScheduledTask[]; + for (const task of loaded) { + if (task.durable) { + this.tasks.set(task.id, task); + } + } + } catch { + // Corrupted file or permissions — start fresh + } + } + + private persistToDisk(): void { + try { + const dir = path.dirname(this.tasksPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const durableTasks = Array.from(this.tasks.values()).filter((t) => t.durable); + // Write atomically: tmp file then rename + const tmpPath = this.tasksPath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(durableTasks, null, 2), 'utf-8'); + fs.renameSync(tmpPath, this.tasksPath); + } catch { + // Best-effort persistence + } + } +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +let _scheduler: CronScheduler | null = null; + +export function getCronScheduler(): CronScheduler | null { + return _scheduler; +} + +export function setCronScheduler(scheduler: CronScheduler): void { + _scheduler = scheduler; + // Expose on globalThis for tool integration + (globalThis as Record).__coderCronScheduler = scheduler; +} + +export function resetCronScheduler(): void { + if (_scheduler) { + _scheduler.stop(); + } + _scheduler = null; + delete (globalThis as Record).__coderCronScheduler; +} diff --git a/packages/core/src/error-recovery.ts b/packages/core/src/error-recovery.ts new file mode 100644 index 0000000..244161d --- /dev/null +++ b/packages/core/src/error-recovery.ts @@ -0,0 +1,265 @@ +/** + * error-recovery.ts — Error classification and retry strategy + * + * Categorizes API errors into retryable vs. fatal, implements + * exponential backoff with jitter, and provides the `withRetry` wrapper. + * + * LLM API error recovery with retry pattern: + * - retryable: rate_limit, server_error, overloaded + * - non-retryable: invalid_request, auth, permission, context_too_large + */ + +// --------------------------------------------------------------------------- +// Error Classification +// --------------------------------------------------------------------------- + +export type ErrorCategory = + | 'rate_limit' + | 'server_error' + | 'overloaded' + | 'network' + | 'invalid_request' + | 'auth' + | 'permission' + | 'context_too_large' + | 'timeout' + | 'aborted' + | 'unknown'; + +export interface ClassifiedError { + category: ErrorCategory; + retryable: boolean; + message: string; + original: Error; +} + +const RETRYABLE_CATEGORIES = new Set([ + 'rate_limit', + 'server_error', + 'overloaded', + 'network', + 'timeout', +]); + +/** + * Classify an error by analyzing its message and properties. + */ +export function classifyError(error: Error): ClassifiedError { + const message = error.message.toLowerCase(); + + // Rate limiting + if ( + message.includes('rate limit') || + message.includes('429') || + message.includes('too many requests') || + message.includes('ratelimit') || + message.includes('quota') + ) { + return { category: 'rate_limit', retryable: true, message: error.message, original: error }; + } + + // Server errors + if ( + message.includes('internal server error') || + message.includes('500') || + message.includes('502') || + message.includes('503') || + message.includes('504') || + message.includes('server error') + ) { + return { category: 'server_error', retryable: true, message: error.message, original: error }; + } + + // Overloaded + if ( + message.includes('overloaded') || + message.includes('capacity') || + message.includes('busy') + ) { + return { category: 'overloaded', retryable: true, message: error.message, original: error }; + } + + // Network + if ( + message.includes('econnrefused') || + message.includes('enotfound') || + message.includes('econnreset') || + message.includes('etimedout') || + message.includes('network') || + message.includes('fetch failed') || + message.includes('socket hang up') + ) { + return { category: 'network', retryable: true, message: error.message, original: error }; + } + + // Timeout + if ( + message.includes('timeout') || + message.includes('timed out') || + message.includes('aborted') + ) { + return { category: 'timeout', retryable: true, message: error.message, original: error }; + } + + // Aborted (user interrupt) + if (error.name === 'AbortError' || message.includes('abort')) { + return { category: 'aborted', retryable: false, message: error.message, original: error }; + } + + // Invalid request + if ( + message.includes('invalid') || + message.includes('400') || + message.includes('bad request') + ) { + return { category: 'invalid_request', retryable: false, message: error.message, original: error }; + } + + // Auth + if ( + message.includes('401') || + message.includes('403') || + message.includes('unauthorized') || + message.includes('forbidden') || + message.includes('auth') + ) { + return { category: 'auth', retryable: false, message: error.message, original: error }; + } + + // Context too large + if ( + message.includes('prompt too long') || + message.includes('context length') || + message.includes('too many tokens') || + message.includes('413') + ) { + return { category: 'context_too_large', retryable: false, message: error.message, original: error }; + } + + return { category: 'unknown', retryable: false, message: error.message, original: error }; +} + +// --------------------------------------------------------------------------- +// Retry Strategy +// --------------------------------------------------------------------------- + +export interface RetryConfig { + maxRetries: number; + baseDelayMs: number; + maxDelayMs: number; + retryableCategories: Set; +} + +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelayMs: 1000, + maxDelayMs: 30_000, + retryableCategories: RETRYABLE_CATEGORIES, +}; + +/** + * Compute exponential backoff delay with jitter. + * + * delay = min(baseDelay * 2^attempt + random_jitter, maxDelay) + */ +export function computeBackoff(attempt: number, config: RetryConfig = DEFAULT_RETRY_CONFIG): number { + const baseDelay = config.baseDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * baseDelay * 0.5; + return Math.min(baseDelay + jitter, config.maxDelayMs); +} + +/** + * Wait for a specified number of milliseconds. + */ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// withRetry +// --------------------------------------------------------------------------- + +/** + * Execute an async function with automatic retry on transient failures. + * + * Retries on: rate_limit, server_error, overloaded, network, timeout + * Does not retry on: invalid_request, auth, permission, context_too_large, aborted + * + * @param fn - The async function to execute + * @param config - Retry configuration (optional) + * @returns The result of the function + * @throws The last error if all retries are exhausted + */ +export async function withRetry( + fn: () => Promise, + config?: Partial, +): Promise { + const cfg = { ...DEFAULT_RETRY_CONFIG, ...config }; + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const classified = classifyError(lastError); + + // Don't retry if not retryable or max attempts reached + if (!classified.retryable || attempt >= cfg.maxRetries) { + throw lastError; + } + + // Don't retry if category not in allowed list + if (cfg.retryableCategories && !cfg.retryableCategories.has(classified.category)) { + throw lastError; + } + + const waitMs = computeBackoff(attempt, cfg); + await delay(waitMs); + } + } + + throw lastError ?? new Error('withRetry: unexpected error'); +} + +// --------------------------------------------------------------------------- +// Specific Error Classes +// --------------------------------------------------------------------------- + +export class MaxTurnsExceededError extends Error { + constructor(public readonly maxTurns: number) { + super(`Exceeded maximum of ${maxTurns} turns`); + this.name = 'MaxTurnsExceededError'; + } +} + +export class BudgetExceededError extends Error { + constructor(public readonly totalCost: number) { + super(`Budget exceeded at $${totalCost.toFixed(2)}`); + this.name = 'BudgetExceededError'; + } +} + +export class StopRequestedError extends Error { + constructor(public readonly reason: string) { + super(`Stop requested: ${reason}`); + this.name = 'StopRequestedError'; + } +} + +export class FatalAPIError extends Error { + constructor( + message: string, + public readonly category: ErrorCategory, + ) { + super(message); + this.name = 'FatalAPIError'; + } +} + +export class ContextOverflowError extends Error { + constructor(public readonly ratio: number) { + super(`Context overflow: ${(ratio * 100).toFixed(0)}% of budget`); + this.name = 'ContextOverflowError'; + } +} diff --git a/packages/core/src/hooks/manager.ts b/packages/core/src/hooks/manager.ts new file mode 100644 index 0000000..d27da45 --- /dev/null +++ b/packages/core/src/hooks/manager.ts @@ -0,0 +1,1391 @@ +/** + * hooks/manager.ts — HookManager + * + * Loads and executes hooks registered for lifecycle events. + * Hooks can be: + * 1. Shell commands (string) — executed as child processes via execFile + * 2. JavaScript functions — executed in-process with a timeout wrapper + * + * Hook files live in ~/.coder/hooks/ as JSON config files. Each file describes + * one or more hooks with their event, handler, and configuration. + * + * Architecture reference: ARCHITECTURE.md §4.6 + */ + +import { execFile } from 'node:child_process'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { promisify } from 'node:util'; + +import type { + Hook, + HookEvent, + HookContext, + HookResult, + HookManagerLike, +} from './types.js'; + +import { + createEmptyAggregatedResult, + aggregateHookResults, +} from './types.js'; + +import type { + HookExecutionResult, + AggregatedHookResult, + SessionStartContext, +} from './types.js'; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default timeout for hook execution (30 seconds) */ +const DEFAULT_HOOK_TIMEOUT_MS = 30_000; + +/** Directory where hook JSON configuration files are stored */ +const HOOKS_DIR = join(homedir(), '.coder', 'hooks'); + +// --------------------------------------------------------------------------- +// Hook file format (~/.coder/hooks/*.json) +// --------------------------------------------------------------------------- + +interface HookFileEntry { + id: string; + event: HookEvent; + description?: string; + command?: string; + handler?: string; + timeout?: number; + enabled?: boolean; + priority?: number; +} + +interface HookFile { + hooks: HookFileEntry[]; +} + +// --------------------------------------------------------------------------- +// HookManager +// --------------------------------------------------------------------------- + +export class HookManager implements HookManagerLike { + private hooks: Map = new Map(); + private hooksByEvent: Map = new Map(); + + constructor() { + // Pre-populate event buckets + const events: HookEvent[] = [ + 'SessionStart', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', + 'Stop', 'StopFailure', + 'SubagentStart', 'SubagentStop', 'PreCompact', 'SessionEnd', + 'TaskCreated', 'TaskCompleted', 'Notification', + 'UserPromptSubmit', 'PreMessage', 'PostMessage', + 'PostCompact', 'InstructionsLoaded', 'PermissionRequest', + 'PermissionDenied', 'WorktreeCreate', 'WorktreeRemove', 'PostToolBatch', + 'ConfigChange', 'Setup', 'CwdChanged', 'UserPromptExpansion', + ]; + for (const event of events) { + this.hooksByEvent.set(event, []); + } + + // Load hooks from disk + this.loadFromDisk(); + } + + // --------------------------------------------------------------------------- + // HookManagerLike implementation + // --------------------------------------------------------------------------- + + /** + * Register a new hook at runtime. + */ + register(hook: Hook): void { + this.hooks.set(hook.id, hook); + const bucket = this.hooksByEvent.get(hook.event); + if (bucket) { + bucket.push(hook); + // Sort by priority descending (higher = runs first) + bucket.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + } + } + + /** + * Remove a hook by ID. + */ + unregister(hookId: string): void { + const hook = this.hooks.get(hookId); + if (!hook) return; + + this.hooks.delete(hookId); + + const bucket = this.hooksByEvent.get(hook.event); + if (bucket) { + const idx = bucket.findIndex((h) => h.id === hookId); + if (idx !== -1) bucket.splice(idx, 1); + } + } + + /** + * Execute all registered hooks for an event (async). + * + * Hooks are executed in parallel for efficiency. Each hook has its own + * timeout. Results are aggregated and returned. + */ + async execute( + event: HookEvent, + ctx: Partial, + ): Promise { + const bucket = this.hooksByEvent.get(event); + if (!bucket || bucket.length === 0) return []; + + const enabledHooks = bucket.filter((h) => h.enabled !== false); + if (enabledHooks.length === 0) return []; + + const fullCtx: HookContext = { + event, + sessionId: ctx.sessionId ?? '', + cwd: ctx.cwd ?? process.cwd(), + timestamp: new Date(), + ...ctx, + } as HookContext; + + const results = await Promise.all( + enabledHooks.map((hook) => this.executeOne(hook, fullCtx)), + ); + + return results + .filter((r): r is HookResult => r !== null); + } + + /** + * Execute hooks synchronously for the SessionStart event. + * + * Shell-based hooks are skipped (cannot run synchronously). + * In-process function hooks are called directly. + */ + executeSync( + event: 'SessionStart', + ctx: Partial, + ): HookResult[] { + const bucket = this.hooksByEvent.get(event); + if (!bucket || bucket.length === 0) return []; + + const results: HookResult[] = []; + + for (const hook of bucket) { + if (hook.enabled === false) continue; + + // Only in-process function handlers can run synchronously + if (typeof hook.handler !== 'function') continue; + + const fullCtx: SessionStartContext = { + event: 'SessionStart', + sessionId: ctx.sessionId ?? '', + cwd: ctx.cwd ?? process.cwd(), + timestamp: new Date(), + ...ctx, + }; + + try { + const result = hook.handler(fullCtx); + // If it returns a promise, skip (cannot await synchronously) + if (result instanceof Promise) continue; + results.push(result); + } catch { + // Hook errors are non-fatal + } + } + + return results; + } + + /** + * Execute all hooks for an event and return an aggregated summary. + * This is the main entry point used by query.ts and compactor.ts. + */ + async executeAndAggregate( + event: HookEvent, + ctx: Partial, + ): Promise { + const bucket = this.hooksByEvent.get(event); + if (!bucket || bucket.length === 0) return createEmptyAggregatedResult(); + + const enabledHooks = bucket.filter((h) => h.enabled !== false); + if (enabledHooks.length === 0) return createEmptyAggregatedResult(); + + const fullCtx: HookContext = { + event, + sessionId: ctx.sessionId ?? '', + cwd: ctx.cwd ?? process.cwd(), + timestamp: new Date(), + ...ctx, + } as HookContext; + + const executions = await Promise.all( + enabledHooks.map((hook) => this.executeOneWithTiming(hook, fullCtx)), + ); + + return aggregateHookResults(executions); + } + + /** + * List all registered hooks. + */ + list(): Hook[] { + return Array.from(this.hooks.values()); + } + + /** + * Get hooks registered for a specific event. + */ + listByEvent(event: HookEvent): Hook[] { + return this.hooksByEvent.get(event) ?? []; + } + + // --------------------------------------------------------------------------- + // Lifecycle convenience methods (used by query.ts) + // --------------------------------------------------------------------------- + + /** + * Execute PreToolUse hooks. Returns whether the tool should be blocked. + */ + async onPreToolUse( + sessionId: string, + cwd: string, + toolName: string, + input: unknown, + ): Promise<{ blocked: boolean; reason?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'PreToolUse', + toolName, + input, + rawInput: input, + }; + const aggregated = await this.executeAndAggregate('PreToolUse', ctx); + return { blocked: aggregated.blocked, reason: aggregated.blockReason }; + } + + /** + * Execute PostToolUse hooks. Returns injectable context and metadata. + */ + async onPostToolUse( + sessionId: string, + cwd: string, + toolName: string, + input: unknown, + result: unknown, + success: boolean, + durationMs: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'PostToolUse', + toolName, + input, + result, + success, + durationMs, + }; + await this.execute('PostToolUse', ctx); + } + + /** + * Execute Stop hooks at the end of each turn. + */ + async onStop( + sessionId: string, + cwd: string, + turnCount: number, + recentMessages: Array<{ role: string; summary: string }>, + ): Promise<{ shouldStop: boolean }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'Stop', + turnCount, + recentMessages, + }; + const aggregated = await this.executeAndAggregate('Stop', ctx); + return { shouldStop: aggregated.shouldStop }; + } + + /** + * Execute PreCompact hooks before context compaction. + */ + async onPreCompact( + sessionId: string, + cwd: string, + messageCount: number, + currentTokens: number, + budgetTokens: number, + strategy: string, + ): Promise<{ injectContext: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'PreCompact', + messageCount, + currentTokens, + budgetTokens, + strategy: strategy as 'snip' | 'auto' | 'summarize', + }; + const aggregated = await this.executeAndAggregate('PreCompact', ctx); + return { injectContext: aggregated.injectContext }; + } + + /** + * Execute SessionStart hooks. Called during system prompt assembly. + */ + async onSessionStart( + sessionId: string, + cwd: string, + ): Promise<{ injectContext: string; systemPromptAdditions: string[] }> { + const ctx: Partial = { + event: 'SessionStart', + sessionId, + cwd, + timestamp: new Date(), + }; + + // Try sync first for in-process hooks + const syncResults = this.executeSync('SessionStart', ctx); + + // Then async for shell-based hooks + const asyncResults = await this.execute('SessionStart', ctx); + + const allResults = [...syncResults, ...asyncResults]; + let injectContext = ''; + const systemPromptAdditions: string[] = []; + + for (const r of allResults) { + if (r.injectContext) { + injectContext += (injectContext ? '\n\n' : '') + r.injectContext; + } + if (r.systemPromptAdditions) { + systemPromptAdditions.push(...r.systemPromptAdditions); + } + } + + return { injectContext, systemPromptAdditions }; + } + + /** + * Execute SessionEnd hooks during session shutdown. + */ + async onSessionEnd( + sessionId: string, + cwd: string, + turnCount: number, + totalCost: number, + totalTokens: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'SessionEnd', + turnCount, + totalCost, + totalTokens, + }; + await this.execute('SessionEnd', ctx); + } + + /** + * Execute SubagentStart hooks when a sub-agent/Worker is launched. + * + * Called by the SubagentBus / runAgent callback at the start of + * background agent execution. Allows hooks to log, monitor, or inject + * context for the sub-agent session. + */ + async onSubagentStart( + sessionId: string, + cwd: string, + subagentName: string, + subagentPrompt: string, + allowedTools: string[] = [], + ): Promise<{ injectContext: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'SubagentStart', + subagentName, + subagentPrompt, + allowedTools, + }; + const aggregated = await this.executeAndAggregate('SubagentStart', ctx); + return { injectContext: aggregated.injectContext }; + } + + /** + * Execute SubagentStop hooks when a sub-agent/Worker completes or fails. + * + * Called by the SubagentBus / runAgent callback when background agent + * execution finishes (success, error, or abort). Provides summary info + * including success status, output summary, token usage, and duration. + */ + async onSubagentStop( + sessionId: string, + cwd: string, + subagentName: string, + success: boolean, + summary: string, + tokenUsage: number, + durationMs: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'SubagentStop', + subagentName, + success, + summary, + tokenUsage, + durationMs, + }; + await this.execute('SubagentStop', ctx); + } + + // --------------------------------------------------------------------------- + // Phase 5: New Hook convenience methods (Sprint 7) + // --------------------------------------------------------------------------- + + /** + * Execute PostToolUseFailure hooks when a tool throws an exception. + * + * Called in the catch block of tool execution in query.ts. + * Non-blockable — the tool has already failed, this hook is for + * observability (logging, monitoring, desktop notifications). + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param toolName - Name of the tool that failed + * @param input - The tool input that caused the failure + * @param error - The caught error object + */ + async onPostToolUseFailure( + sessionId: string, + cwd: string, + toolName: string, + input: unknown, + error: Error, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'PostToolUseFailure', + toolName, + input, + error: { + message: error.message, + stack: error.stack, + }, + }; + // Fire-and-forget — PostToolUseFailure errors should not block the loop + this.execute('PostToolUseFailure', ctx).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + /** + * Execute StopFailure hooks when an API error terminates the Agent Loop. + * + * Called in the API error catch block in query.ts. + * Non-blockable — the loop has already failed, this hook is for + * diagnostics and error reporting. + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param error - The API error that caused the failure + * @param turnCount - Turns completed before the failure + */ + async onStopFailure( + sessionId: string, + cwd: string, + error: { message: string; code?: string; status?: number }, + turnCount: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'StopFailure', + error, + turnCount, + }; + // Fire-and-forget — StopFailure should not delay termination + this.execute('StopFailure', ctx).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + /** + * Execute TaskCreated hooks when a background task or sub-agent is spawned. + * + * Called by the SubagentBus / runAgent callback at task creation time. + * Non-blockable — the task is already queued, this hook is for tracking + * and observability. + * + * @param sessionId - Parent session identifier + * @param cwd - Working directory + * @param taskId - Unique task identifier + * @param taskType - Type of task: 'subagent', 'cron', or 'background' + * @param prompt - Task description / prompt (truncated to 500 chars) + * @param toolSet - Allowed tool names (empty = unrestricted) + */ + async onTaskCreated( + sessionId: string, + cwd: string, + taskId: string, + taskType: 'subagent' | 'cron' | 'background', + prompt?: string, + toolSet?: string[], + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'TaskCreated', + taskId, + taskType, + prompt, + toolSet, + }; + // Fire-and-forget — TaskCreated should not block the spawn + this.execute('TaskCreated', ctx).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + /** + * Execute TaskCompleted hooks when a background task or sub-agent finishes. + * + * Called by the SubagentBus / runAgent callback on completion, failure, + * or kill. Non-blockable — the task has already ended, this hook is for + * observability. + * + * @param sessionId - Parent session identifier + * @param cwd - Working directory + * @param taskId - Unique task identifier + * @param status - Final status: 'completed', 'failed', or 'killed' + * @param summary - Human-readable summary of what the task accomplished + * @param usage - Resource usage metrics (tokens, tool calls, duration) + */ + async onTaskCompleted( + sessionId: string, + cwd: string, + taskId: string, + status: 'completed' | 'failed' | 'killed', + summary?: string, + usage?: { tokens: number; toolCalls: number; durationMs: number }, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'TaskCompleted', + taskId, + status, + summary, + usage, + }; + // Fire-and-forget — TaskCompleted should not block post-cleanup + this.execute('TaskCompleted', ctx).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + /** + * Execute Notification hooks for system-level events. + * + * Called at key nodes in query.ts: tool completion, compaction completion, + * error yield. Non-blockable — purely informational, allows hooks to log, + * send desktop notifications, etc. + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param level - Severity: 'info', 'warn', or 'error' + * @param message - Human-readable notification message + * @param metadata - Optional structured data (tool name, error details, etc.) + */ + async onNotification( + sessionId: string, + cwd: string, + level: 'info' | 'warn' | 'error', + message: string, + metadata?: Record, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'Notification', + level, + message, + metadata, + }; + // Fire-and-forget — Notification should never block the loop + this.execute('Notification', ctx).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + // --------------------------------------------------------------------------- + // Phase 5 Batch 2: UserPromptSubmit / PreMessage / PostMessage (P0) + // --------------------------------------------------------------------------- + + /** + * Execute UserPromptSubmit hooks when the user submits a prompt. + * + * Called by QueryEngine.submitMessage() BEFORE the user input enters + * the Agent Loop. This is a BLOCKABLE event — hooks can intercept + * dangerous commands, augment the prompt, or reject the submission. + * + * Timeout: 5 seconds (shorter than default — this blocks the user). + * On timeout, the submission proceeds (blocked=false) to avoid UX hang. + * + * @returns blocked=true with reason if any hook blocks the submission + * @returns augmentedPrompt if any hook provides a replacement prompt + */ + async onUserPromptSubmit( + sessionId: string, + cwd: string, + prompt: string, + metadata?: { model?: string; provider?: string }, + ): Promise<{ blocked: boolean; blockReason?: string; augmentedPrompt?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'UserPromptSubmit', + prompt, + metadata, + }; + // Use 5s total timeout — blocks user input, must be responsive + const aggregated = await this.executeAndAggregateWithTimeout('UserPromptSubmit', ctx, 5_000); + return { + blocked: aggregated.blocked, + blockReason: aggregated.blockReason, + augmentedPrompt: aggregated.augmentedPrompt, + }; + } + + /** + * Execute PreMessage hooks before messages are sent to the LLM API. + * + * Called in query.ts after system prompt assembly and before callModel(). + * BLOCKABLE — hooks can prevent the API call or modify messages/prompt. + * + * Performance: only the last 10 messages are passed to shell hooks + * (truncation happens in executeShellHook). Function hooks get the full + * array for in-process inspection. + * + * NOTE: modifiedMessages is not yet supported in HookResult because + * message modification requires deep serialization. Hooks can use + * injectContext and modifiedSystemPrompt to influence the LLM call. + * + * @returns blocked=true to cancel the API call + * @returns injectContext to prepend to the system prompt + * @returns modifiedSystemPrompt to replace the system prompt entirely + */ + async onPreMessage( + sessionId: string, + cwd: string, + messages: Array<{ role: string; summary: string }>, + systemPrompt: string, + model: string, + turnCount: number, + ): Promise<{ blocked: boolean; blockReason?: string; injectContext?: string; modifiedSystemPrompt?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'PreMessage', + // Only pass message summaries for shell hook safety; function hooks + // that need full messages should use direct sessionManager access. + messages: messages.slice(-10) as unknown[] as import('@coder/shared').Message[], + systemPrompt, + model, + turnCount, + }; + const aggregated = await this.executeAndAggregateWithTimeout('PreMessage', ctx, 5_000); + return { + blocked: aggregated.blocked, + blockReason: aggregated.blockReason, + injectContext: aggregated.injectContext || undefined, + modifiedSystemPrompt: aggregated.modifiedSystemPrompt, + }; + } + + /** + * Execute PostMessage hooks after an LLM response is received. + * + * Called in query.ts after the assistant message is assembled and + * emitted. NON-BLOCKABLE — the response has already been consumed. + * Hooks can extract knowledge for memory or inject context for the + * next turn. + * + * @returns injectContext to prepend to the next turn's system prompt + * @returns saveToMemory text to persist to the memory store + */ + async onPostMessage( + sessionId: string, + cwd: string, + messageContent: string, + model: string, + turnCount: number, + usage: { input_tokens: number; output_tokens: number }, + messages: Array<{ role: string; summary: string }>, + ): Promise<{ injectContext?: string; saveToMemory?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'PostMessage', + // Use a lightweight message representation to avoid serializing + // full AssistantMessage (which contains ContentBlock[] arrays). + message: { role: 'assistant', content: messageContent, usage } as unknown as import('@coder/shared').AssistantMessage, + messages: messages.slice(-10) as unknown[] as import('@coder/shared').Message[], + model, + turnCount, + usage, + }; + const aggregated = await this.executeAndAggregateWithTimeout('PostMessage', ctx, 10_000); + return { + injectContext: aggregated.injectContext || undefined, + saveToMemory: aggregated.saveToMemory, + }; + } + + // --------------------------------------------------------------------------- + // Phase 5 P1: Important Scenario Hook Methods (Sprint 7) + // --------------------------------------------------------------------------- + + /** + * Execute PostCompact hooks after context compaction completes. + * Non-blockable — compaction has already finished. + */ + async onPostCompact( + sessionId: string, + cwd: string, + strategy: string, + beforeTokens: number, + afterTokens: number, + messagesRemoved: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'PostCompact', + strategy, + beforeTokens, + afterTokens, + messagesRemoved, + }; + this.execute('PostCompact', ctx).catch(() => {}); + } + + /** + * Execute InstructionsLoaded hooks after system prompt assembly. + * Non-blockable — the prompt is already assembled. + */ + async onInstructionsLoaded( + sessionId: string, + cwd: string, + sources: string[], + totalTokens: number, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'InstructionsLoaded', + sources, + totalTokens, + }; + this.execute('InstructionsLoaded', ctx).catch(() => {}); + } + + /** + * Execute PermissionRequest hooks before a permission decision. + * BLOCKABLE — hooks can override the permission result. + * + * @returns permissionOverride: 'auto-approve' or 'auto-deny' to override + */ + async onPermissionRequest( + sessionId: string, + cwd: string, + toolName: string, + input: unknown, + riskLevel: string, + originalBehavior: 'approve' | 'deny' | 'ask_user', + ): Promise<{ permissionOverride?: 'auto-approve' | 'auto-deny' }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'PermissionRequest', + toolName, + input, + riskLevel, + originalBehavior, + }; + const aggregated = await this.executeAndAggregateWithTimeout('PermissionRequest', ctx, 3_000); + return { + permissionOverride: aggregated.permissionOverride, + }; + } + + /** + * Execute PermissionDenied hooks when a tool permission is denied. + * Non-blockable — the denial has already occurred. + */ + async onPermissionDenied( + sessionId: string, + cwd: string, + toolName: string, + input: unknown, + reason: string, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'PermissionDenied', + toolName, + input, + reason, + }; + this.execute('PermissionDenied', ctx).catch(() => {}); + } + + /** + * Execute WorktreeCreate hooks before creating a git worktree. + * BLOCKABLE — hooks can prevent worktree creation. + */ + async onWorktreeCreate( + sessionId: string, + cwd: string, + name: string, + baseRef: string, + ): Promise<{ blocked: boolean; blockReason?: string; worktreeName?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'WorktreeCreate', + name, + baseRef, + }; + const aggregated = await this.executeAndAggregateWithTimeout('WorktreeCreate', ctx, 5_000); + return { + blocked: aggregated.blocked, + blockReason: aggregated.blockReason, + worktreeName: aggregated.worktreeName, + }; + } + + /** + * Execute WorktreeRemove hooks when a worktree is being removed. + * Non-blockable — the removal has already been decided. + */ + async onWorktreeRemove( + sessionId: string, + cwd: string, + name: string, + kept: boolean, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'WorktreeRemove', + name, + kept, + }; + this.execute('WorktreeRemove', ctx).catch(() => {}); + } + + /** + * Execute PostToolBatch hooks after a batch of tools completes. + * Non-blockable — tool execution has finished. Hooks can inspect + * batch results for diagnostics or logging. + */ + async onPostToolBatch( + sessionId: string, + cwd: string, + toolResults: Array<{ + toolName: string; + success: boolean; + durationMs: number; + summary: string; + }>, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'PostToolBatch', + toolResults, + }; + this.execute('PostToolBatch', ctx).catch(() => {}); + } + + // --------------------------------------------------------------------------- + // Phase 5 P2: Configuration & Environment Hook Methods (Sprint 7) + // --------------------------------------------------------------------------- + + /** + * Execute ConfigChange hooks when configuration changes. + * + * Called when config options are modified (model switch, permission mode + * change, etc.). Non-blockable — the config has already been applied. + * Hooks can log changes or trigger side effects. + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param changedKeys - Which config fields changed + * @param newValues - New values for changed keys + * @param previousValues - Previous values (where tracked, optional) + */ + async onConfigChange( + sessionId: string, + cwd: string, + changedKeys: string[], + newValues: Record, + previousValues?: Record, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'ConfigChange', + changedKeys, + newValues, + previousValues, + }; + this.execute('ConfigChange', ctx).catch(() => {}); + } + + /** + * Execute Setup hooks when a fresh session is created for the first time. + * + * Called by QueryEngine.init() on the first session. Non-blockable — + * the session already exists. Hooks can run one-time setup tasks like + * creating directories, installing dependencies, etc. + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param isFresh - Whether this is a fresh session (no prior message history) + * @param model - Current model + * @param provider - Current provider (optional) + */ + async onSetup( + sessionId: string, + cwd: string, + isFresh: boolean, + model?: string, + provider?: string, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'Setup', + isFresh, + model, + provider, + }; + this.execute('Setup', ctx).catch(() => {}); + } + + /** + * Execute CwdChanged hooks when the working directory changes. + * + * Called when cd command or --cwd flag changes the directory. + * Non-blockable — the directory has already changed. Hooks can + * reload project-specific config or update the environment. + * + * @param sessionId - Current session identifier + * @param cwd - The NEW working directory (after change) + * @param previousCwd - The previous working directory + */ + async onCwdChanged( + sessionId: string, + cwd: string, + previousCwd: string, + ): Promise { + const ctx: Partial = { + sessionId, + cwd, + event: 'CwdChanged', + previousCwd, + newCwd: cwd, + }; + this.execute('CwdChanged', ctx).catch(() => {}); + } + + /** + * Execute UserPromptExpansion hooks after UserPromptSubmit and prompt + * expansion, before the expanded prompt enters the Agent Loop. + * + * Called by QueryEngine.submitMessage() after UserPromptSubmit. + * BLOCKABLE — hooks can intercept the final expanded prompt before + * it's sent to the model. + * + * @param sessionId - Current session identifier + * @param cwd - Working directory + * @param originalPrompt - The original user input before expansion + * @param expandedPrompt - The expanded/augmented prompt (may be same as original) + * @returns blocked=true if any hook blocks + * @returns expandedPromptOverride if any hook provides a replacement + */ + async onUserPromptExpansion( + sessionId: string, + cwd: string, + originalPrompt: string, + expandedPrompt: string, + ): Promise<{ blocked: boolean; blockReason?: string; expandedPromptOverride?: string }> { + const ctx: Partial = { + sessionId, + cwd, + event: 'UserPromptExpansion', + originalPrompt, + expandedPrompt, + }; + const aggregated = await this.executeAndAggregateWithTimeout('UserPromptExpansion', ctx, 5_000); + return { + blocked: aggregated.blocked, + blockReason: aggregated.blockReason, + expandedPromptOverride: aggregated.expandedPromptOverride, + }; + } + + // --------------------------------------------------------------------------- + // Phase 5 P2: HTTP and MCP Tool Hook Support (Sprint 7) + // --------------------------------------------------------------------------- + + /** + * Register an HTTP-based hook. The handler is an HttpHookConfig object + * that specifies a URL to POST hook context to. + */ + registerHttpHook(hook: Hook): void { + if (hook.type !== 'http') { + throw new Error(`registerHttpHook requires hook type 'http', got '${hook.type ?? 'undefined'}'`); + } + this.register(hook); + } + + /** + * Execute an HTTP hook by POSTing the context to the configured URL. + * + * Implements a POST-only protocol: serializes the HookContext as JSON + * and sends it in the request body. The response body is parsed as + * JSON and treated as a HookResult. + * + * Timeout is controlled by HttpHookConfig.timeout (defaults to 5000ms). + */ + private async executeHttpHook( + config: import('@coder/shared').HttpHookConfig, + ctx: HookContext, + timeoutMs: number, + ): Promise { + const url = config.url; + const method = config.method ?? 'POST'; + const headers: Record = { + 'Content-Type': 'application/json', + ...config.headers, + }; + const body = config.includeContext !== false ? JSON.stringify(ctx) : undefined; + const requestTimeout = config.timeout ?? timeoutMs; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), requestTimeout); + + const response = await fetch(url, { + method, + headers, + body, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; // Non-2xx → treated as hook failure (non-fatal) + } + + const text = await response.text(); + if (text.trim()) { + try { + return JSON.parse(text) as HookResult; + } catch { + // Not valid JSON — treat the output as injectContext + return { injectContext: text.trim() }; + } + } + + return null; + } catch { + // Network error, timeout, DNS failure — all non-fatal + return null; + } + } + + // --------------------------------------------------------------------------- + // Private: hook execution with timeout + // --------------------------------------------------------------------------- + + /** + * Like executeAndAggregate, but with a configurable overall timeout. + * Used by blockable hooks where we must not hang the user. + */ + private async executeAndAggregateWithTimeout( + event: HookEvent, + ctx: Partial, + timeoutMs: number, + ): Promise { + const bucket = this.hooksByEvent.get(event); + if (!bucket || bucket.length === 0) return createEmptyAggregatedResult(); + + const enabledHooks = bucket.filter((h) => h.enabled !== false); + if (enabledHooks.length === 0) return createEmptyAggregatedResult(); + + const fullCtx: HookContext = { + event, + sessionId: ctx.sessionId ?? '', + cwd: ctx.cwd ?? process.cwd(), + timestamp: new Date(), + ...ctx, + } as HookContext; + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + // On timeout, return empty results — the operation proceeds (not blocked) + resolve([]); + }, timeoutMs); + }); + + const executionPromise = Promise.all( + enabledHooks.map((hook) => this.executeOneWithTiming(hook, fullCtx)), + ); + + const executions = await Promise.race([executionPromise, timeoutPromise]); + return aggregateHookResults(executions); + } + + // --------------------------------------------------------------------------- + // Private: hook execution + // --------------------------------------------------------------------------- + + /** + * Execute a single hook and return its result (null if it fails/times out). + */ + private async executeOne( + hook: Hook, + ctx: HookContext, + ): Promise { + try { + const timeoutMs = hook.timeout ?? DEFAULT_HOOK_TIMEOUT_MS; + + if (hook.type === 'http' && typeof hook.handler === 'object' && 'url' in hook.handler) { + return await this.executeHttpHook( + hook.handler as import('@coder/shared').HttpHookConfig, + ctx, + timeoutMs, + ); + } + + if (typeof hook.handler === 'string') { + return await this.executeShellHook(hook.handler, ctx, timeoutMs); + } + + if (typeof hook.handler === 'function') { + return await this.executeFunctionHook(hook.handler, ctx, timeoutMs); + } + + return null; + } catch { + // Hook errors are non-fatal — never crash the agent loop + return null; + } + } + + /** + * Execute a single hook and return a timed execution result. + */ + private async executeOneWithTiming( + hook: Hook, + ctx: HookContext, + ): Promise { + const start = Date.now(); + let result: HookResult | null = null; + let error: string | undefined; + let timedOut = false; + + try { + result = await this.executeOne(hook, ctx); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + } + + const durationMs = Date.now() - start; + const timeoutMs = hook.timeout ?? DEFAULT_HOOK_TIMEOUT_MS; + + // Detect if it timed out by checking if result is null and duration ~= timeout + if (!result && !error && durationMs >= timeoutMs - 100) { + timedOut = true; + error = `Hook "${hook.id}" timed out after ${timeoutMs}ms`; + } + + return { + hookId: hook.id, + event: hook.event, + result, + durationMs, + error, + timedOut, + }; + } + + /** + * Execute a shell command as a hook handler. + * + * Passes the hook context as JSON via stdin. Expects JSON on stdout. + * Falls back to treating stdout as `{ injectContext: stdout }` if JSON + * parsing fails. + */ + private async executeShellHook( + command: string, + ctx: HookContext, + timeoutMs: number, + ): Promise { + const ctxJson = JSON.stringify(ctx); + + try { + const { stdout, stderr } = await execFileAsync('sh', ['-c', command], { + timeout: timeoutMs, + maxBuffer: 1024 * 1024, // 1MB + env: { + ...process.env, + CODER_HOOK_EVENT: ctx.event, + CODER_HOOK_CTX: ctxJson, + }, + }); + + // Try to parse stdout as JSON HookResult + if (stdout.trim()) { + try { + return JSON.parse(stdout.trim()) as HookResult; + } catch { + // Not valid JSON — treat the output as injectContext + return { injectContext: stdout.trim() }; + } + } + + // If stdout is empty but stderr has content, log it but don't fail + if (stderr.trim()) { + return { + metadata: { stderr: stderr.trim() }, + }; + } + + return null; + } catch (err: unknown) { + const e = err as { code?: string; killed?: boolean; signal?: string }; + // Timeout or other process error — non-fatal + if (e.killed || e.signal === 'SIGTERM') { + return null; // Timed out — handled by executeOneWithTiming + } + // Shell command returned non-zero exit code — also non-fatal + return null; + } + } + + /** + * Execute a JavaScript function as a hook handler with a timeout wrapper. + */ + private async executeFunctionHook( + handler: (ctx: HookContext) => Promise, + ctx: HookContext, + timeoutMs: number, + ): Promise { + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(null), timeoutMs); + }); + + const result = await Promise.race([ + handler(ctx).catch(() => null), + timeoutPromise, + ]); + + return result; + } + + // --------------------------------------------------------------------------- + // Private: disk loading + // --------------------------------------------------------------------------- + + /** + * Load hook configuration files from ~/.coder/hooks/. + * + * Each file is a JSON file with a `hooks` array. + * Example: + * { + * "hooks": [ + * { + * "id": "pre-tool-logger", + * "event": "PreToolUse", + * "description": "Log all tool invocations", + * "command": "echo '{\"injectContext\": \"Tool was used\"}'", + * "timeout": 5000, + * "priority": 10 + * } + * ] + * } + */ + private loadFromDisk(): void { + if (!existsSync(HOOKS_DIR)) return; + + let entries: HookFileEntry[] = []; + + try { + const dirents = readdirSync(HOOKS_DIR); + for (const dirent of dirents) { + if (!dirent.endsWith('.json')) continue; + const filePath = join(HOOKS_DIR, dirent); + + try { + // Skip directories and non-files + if (!statSync(filePath).isFile()) continue; + + const raw = readFileSync(filePath, 'utf-8'); + const parsed: HookFile = JSON.parse(raw); + + if (Array.isArray(parsed.hooks)) { + entries = entries.concat(parsed.hooks); + } + } catch { + // Skip unparseable files + } + } + } catch { + // Directory listing failed — no hooks loaded + return; + } + + for (const entry of entries) { + if (!entry.id || !entry.event) continue; + + // Determine the handler: command (shell string) or handler (function name) + // At load time, only shell commands are available. JS functions must be + // registered programmatically via register(). + const handler: string = entry.command ?? entry.handler ?? ''; + if (!handler) continue; + + const hook: Hook = { + id: entry.id, + event: entry.event as HookEvent, + description: entry.description, + handler, + timeout: entry.timeout ?? DEFAULT_HOOK_TIMEOUT_MS, + enabled: entry.enabled !== false, + priority: entry.priority ?? 0, + }; + + this.register(hook); + } + } +} diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts new file mode 100644 index 0000000..ef35d88 --- /dev/null +++ b/packages/core/src/hooks/types.ts @@ -0,0 +1,244 @@ +/** + * hooks/types.ts — Core-specific hook types + * + * Re-exports shared hook types and adds core-specific execution result types. + * The canonical hook type definitions live in @coder/shared/src/types/hook.ts. + * + * Architecture reference: ARCHITECTURE.md §4.6 + */ + +import type { + HookEvent, + HookContext, + HookResult, + Hook, + HookManagerLike, + BaseHookContext, + SessionStartContext, + PreToolUseContext, + PostToolUseContext, + PostToolUseFailureContext, + StopContext, + StopFailureContext, + SubagentStartContext, + SubagentStopContext, + PreCompactContext, + SessionEndContext, + TaskCreatedContext, + TaskCompletedContext, + NotificationContext, + UserPromptSubmitContext, + PreMessageContext, + PostMessageContext, + PostCompactContext, + InstructionsLoadedContext, + PermissionRequestContext, + PermissionDeniedContext, + WorktreeCreateContext, + WorktreeRemoveContext, + PostToolBatchContext, + ConfigChangeContext, + SetupContext, + CwdChangedContext, + UserPromptExpansionContext, +} from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Re-exports from shared +// --------------------------------------------------------------------------- + +export type { + HookEvent, + HookContext, + HookResult, + Hook, + HookManagerLike, + BaseHookContext, + SessionStartContext, + PreToolUseContext, + PostToolUseContext, + PostToolUseFailureContext, + StopContext, + StopFailureContext, + SubagentStartContext, + SubagentStopContext, + PreCompactContext, + SessionEndContext, + TaskCreatedContext, + TaskCompletedContext, + NotificationContext, + UserPromptSubmitContext, + PreMessageContext, + PostMessageContext, + PostCompactContext, + InstructionsLoadedContext, + PermissionRequestContext, + PermissionDeniedContext, + WorktreeCreateContext, + WorktreeRemoveContext, + PostToolBatchContext, + ConfigChangeContext, + SetupContext, + CwdChangedContext, + UserPromptExpansionContext, +}; + +// --------------------------------------------------------------------------- +// Core-specific hook types +// --------------------------------------------------------------------------- + +/** + * Result of executing a single hook. + */ +export interface HookExecutionResult { + /** The hook that was executed */ + hookId: string; + /** The event being handled */ + event: HookEvent; + /** The result returned by the handler (null if it timed out) */ + result: HookResult | null; + /** Execution duration in milliseconds */ + durationMs: number; + /** Error message if execution failed */ + error?: string; + /** Whether the hook timed out */ + timedOut: boolean; +} + +/** + * Aggregated result of executing all hooks for an event. + * Used by query.ts to decide whether to block/stop/continue. + */ +export interface AggregatedHookResult { + /** Individual hook execution results, in priority order */ + results: HookExecutionResult[]; + /** Whether any hook blocked the operation */ + blocked: boolean; + /** The reason for blocking (from the highest-priority blocking hook) */ + blockReason?: string; + /** Whether any hook requested the agent to stop (Stop event) */ + shouldStop: boolean; + /** Aggregated context to inject into the system prompt */ + injectContext: string; + /** Aggregated metadata (last-write-wins per key across hooks) */ + metadata: Record; + + // ── UserPromptSubmit ───────────────────────────────────────────── + /** Augmented prompt from hooks (last non-null wins) */ + augmentedPrompt?: string; + + // ── PreMessage ─────────────────────────────────────────────────── + /** Modified system prompt from hooks (last non-null wins) */ + modifiedSystemPrompt?: string; + + // ── PostMessage ────────────────────────────────────────────────── + /** Memory save entries from hooks (concatenated) */ + saveToMemory?: string; + + // ── PermissionRequest ──────────────────────────────────────────── + /** Permission override from hooks (last non-null wins) */ + permissionOverride?: 'auto-approve' | 'auto-deny'; + + // ── WorktreeCreate ─────────────────────────────────────────────── + /** Override worktree name from hooks (last non-null wins) */ + worktreeName?: string; + + // ── UserPromptExpansion ─────────────────────────────────────────── + /** Override the expanded prompt (UserPromptExpansion, last non-null wins) */ + expandedPromptOverride?: string; +} + +/** + * Create an empty aggregated hook result. + */ +export function createEmptyAggregatedResult(): AggregatedHookResult { + return { + results: [], + blocked: false, + shouldStop: false, + injectContext: '', + metadata: {}, + augmentedPrompt: undefined, + modifiedSystemPrompt: undefined, + saveToMemory: undefined, + permissionOverride: undefined, + worktreeName: undefined, + expandedPromptOverride: undefined, + }; +} + +/** + * Reduce individual HookExecutionResults into an AggregatedHookResult. + * Blocking takes precedence (first encountered blocks). + * injectedContext is concatenated from all non-null results. + */ +export function aggregateHookResults( + results: HookExecutionResult[], +): AggregatedHookResult { + const aggregated: AggregatedHookResult = { + results, + blocked: false, + shouldStop: false, + injectContext: '', + metadata: {}, + augmentedPrompt: undefined, + modifiedSystemPrompt: undefined, + saveToMemory: undefined, + permissionOverride: undefined, + worktreeName: undefined, + expandedPromptOverride: undefined, + }; + + for (const r of results) { + if (!r.result) continue; + + if (r.result.blocked && !aggregated.blocked) { + aggregated.blocked = true; + aggregated.blockReason = r.result.reason ?? `Blocked by hook "${r.hookId}"`; + } + + if (r.result.shouldStop) { + aggregated.shouldStop = true; + } + + if (r.result.injectContext) { + aggregated.injectContext += (aggregated.injectContext ? '\n\n' : '') + r.result.injectContext; + } + + if (r.result.metadata) { + Object.assign(aggregated.metadata, r.result.metadata); + } + + // UserPromptSubmit: augmentedPrompt (last non-null wins) + if (r.result.augmentedPrompt) { + aggregated.augmentedPrompt = r.result.augmentedPrompt; + } + + // PreMessage: modifiedSystemPrompt (last non-null wins) + if (r.result.modifiedSystemPrompt) { + aggregated.modifiedSystemPrompt = r.result.modifiedSystemPrompt; + } + + // PostMessage: saveToMemory (concatenated) + if (r.result.saveToMemory) { + aggregated.saveToMemory = (aggregated.saveToMemory ? aggregated.saveToMemory + '\n' : '') + r.result.saveToMemory; + } + + // PermissionRequest: permissionOverride (last non-null wins) + if (r.result.permissionOverride) { + aggregated.permissionOverride = r.result.permissionOverride; + } + + // WorktreeCreate: worktreeName (last non-null wins) + if (r.result.worktreeName) { + aggregated.worktreeName = r.result.worktreeName; + } + + // UserPromptExpansion: expandedPromptOverride (last non-null wins) + if (r.result.expandedPromptOverride) { + aggregated.expandedPromptOverride = r.result.expandedPromptOverride; + } + } + + return aggregated; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..eef620e --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,167 @@ +/** + * @coder/core — Coder Agent core runtime + * + * Phase 1: Agent Loop, QueryEngine, Session, Checkpoint, Permission, System Prompt + * Phase 2: Hooks, Context Management, Sub-Agents + */ + +// --- Agent Loop --- +export { query } from './query.js'; +export type { QueryConfig, CallModelParams } from './query.js'; + +// --- QueryEngine --- +export { QueryEngine } from './query-engine.js'; +export type { QueryEngineConfig, QueryEngineEvent } from './query-engine.js'; + +// --- Tool Registry --- +export { ToolRegistry } from './tool-registry.js'; +export type { ToolEntry, ToolCategory } from './tool-registry.js'; + +// --- Session --- +export { SessionManager } from './session.js'; + +// --- Checkpoint --- +export { CheckpointManager } from './checkpoint.js'; +export type { Checkpoint, CheckpointCreateOptions, CheckpointRestoreResult } from './checkpoint.js'; + +// --- Permission --- +export { PermissionEngine, classifyTaskMode } from './permission/engine.js'; +export type { ClassificationContext } from './permission/engine.js'; + +// --- System Prompt --- +export { SystemPromptAssembler } from './system-prompt/assembler.js'; +export type { PromptPart, AssemblyContext, SystemPrompt } from './system-prompt/assembler.js'; +export { getCoordinatorPrompt } from './system-prompt/coordinator.js'; + +// --- Rules Manager (Phase 5) --- +export { RuleManager } from './rules-manager.js'; +export type { RuleFile, ActiveRulesContext } from './rules-manager.js'; + +// --- Error Recovery --- +export { + classifyError, + computeBackoff, + delay, + withRetry, + MaxTurnsExceededError, + BudgetExceededError, + StopRequestedError, + FatalAPIError, + ContextOverflowError, +} from './error-recovery.js'; +export type { ErrorCategory, ClassifiedError, RetryConfig } from './error-recovery.js'; + +// --- Provider Adapter --- +export { + createCallModelFromProvider, + createCallModelFromConfig, + resetAdapterState, +} from './provider-adapter.js'; + +// --- Memory System --- +export { + MemoryType, + createMemoryStore, + JsonMemoryStore, + MemoryExtractor, + MemoryConsolidator, + extractKeywords, + extractMemories, + consolidateStore, +} from './memory/index.js'; +export type { + Memory, + MemoryQuery, + MemoryInput, + MemorySearchResult, + IMemoryStore, + ExtractionResult, + ExtractionOptions, + ConsolidationResult, + ConsolidationOptions, + MergeDetail, +} from './memory/index.js'; + +// --- Subagent Bus (re-exported from shared) --- +export { + SubagentBus, + getSubagentBus, + setSubagentBus, + resetSubagentBus, + formatTaskNotification, +} from '@coder/shared'; +export type { + SubagentEntry, + SubagentStatus, + SubagentSpawnOptions, + SubagentSpawnConfig, + SubagentBusConfig, + RunAgentCallback, + CompletedSubagent, +} from '@coder/shared'; + +// --- Agent Teams (re-exported from shared) --- +export { + WorkerRole, + ROLE_TOOLS, + WORKER_ROLES, + getDefaultToolsForRole, + isValidWorkerRole, + isCoordinatorRole, +} from '@coder/shared'; +export type { WorkerConfig } from '@coder/shared'; + +// --- Subagent Bus (core-side engine integration) --- +export { createRunAgentCallback, createForkAgentCallback } from './subagent-bus.js'; +export type { CreateRunAgentOptions, CreateForkAgentOptions } from './subagent-bus.js'; + +// --- Hooks --- +export { HookManager } from './hooks/manager.js'; +export { + createEmptyAggregatedResult, + aggregateHookResults, +} from './hooks/types.js'; +export type { + HookExecutionResult, + AggregatedHookResult, +} from './hooks/types.js'; + +// --- Scratchpad --- +export { Scratchpad, getScratchpad, setScratchpad } from './scratchpad.js'; +export type { VersionEntry } from './scratchpad.js'; + +// --- Context Management --- +export { Compactor, DEFAULT_COMPACTOR_CONFIG } from './context/compactor.js'; +export type { + CompactStrategy, + CompactorConfig, + CompactResult, + MicrocompactStrategy, + MicrocompactResult, +} from './context/compactor.js'; +export { MICROCOMPACT_IDLE_THRESHOLD_MS, MICROCOMPACT_KEEP_RECENT_TOOL_TURNS } from './context/compactor.js'; +export type { TokenBudget as CompactorTokenBudget } from './context/compactor.js'; +export { + estimateTokens, + estimateStringTokens, + estimateBlockTokens, + estimateMessageTokens, + createTokenBudget, + createTokenBudgetFromCount, + isBudgetExceeded, + needsCompaction, +} from './context/token-budget.js'; +export type { TokenBudget } from './context/token-budget.js'; + +// --- Tool Result Budget --- +export { BudgetStore } from './budget-store.js'; +export type { BudgetEntry, MaybeOffloadResult } from './budget-store.js'; + +// --- Cron Scheduler --- +export { + CronScheduler, + getCronScheduler, + setCronScheduler, + resetCronScheduler, +} from './cron-scheduler.js'; +export type { CronSchedulerConfig, CronFireCallback, ScheduledTask } from './cron-scheduler.js'; diff --git a/packages/core/src/memory/consolidator.ts b/packages/core/src/memory/consolidator.ts new file mode 100644 index 0000000..873ea79 --- /dev/null +++ b/packages/core/src/memory/consolidator.ts @@ -0,0 +1,262 @@ +/** + * MemoryConsolidator — Merge duplicate and similar memories. + * + * Periodically scans the memory store for near-duplicate entries + * and consolidates them into single, higher-quality memories. + * + * Architecture reference: ARCHITECTURE.md §4.10 + */ + +import type { IMemoryStore } from './store.js'; +import type { Memory, MemoryType } from './types.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ConsolidationResult { + /** Number of merge operations performed */ + mergesPerformed: number; + /** Number of memories before consolidation */ + beforeCount: number; + /** Number of memories after consolidation */ + afterCount: number; + /** Details of each merge */ + merges: MergeDetail[]; +} + +export interface MergeDetail { + /** The surviving memory ID */ + kept: string; + /** IDs of memories merged into the survivor */ + merged: string[]; + /** Reason for the merge */ + reason: string; +} + +export interface ConsolidationOptions { + /** Minimum similarity threshold for merging (0-1, default: 0.7) */ + similarityThreshold?: number; + /** Maximum memories to scan (default: all) */ + maxScan?: number; + /** Dry run: report what would be merged without changing anything */ + dryRun?: boolean; +} + +// --------------------------------------------------------------------------- +// MemoryConsolidator +// --------------------------------------------------------------------------- + +export class MemoryConsolidator { + private store: IMemoryStore; + private similarityThreshold: number; + + constructor(store: IMemoryStore, similarityThreshold = 0.7) { + this.store = store; + this.similarityThreshold = similarityThreshold; + } + + /** + * Consolidate duplicate/similar memories. + * + * Algorithm: + * 1. Group memories by type + * 2. Within each type group, find pairs with high similarity + * 3. Merge pairs: keep the higher-importance memory, merge content + * 4. Update keywords and importance from merged content + */ + consolidate(options: ConsolidationOptions = {}): ConsolidationResult { + const threshold = options.similarityThreshold ?? this.similarityThreshold; + const allMemories = this.store.list(options.maxScan); + const beforeCount = allMemories.length; + + const merges: MergeDetail[] = []; + const mergedIds = new Set(); + + // Group by type + const byType = new Map(); + for (const m of allMemories) { + const list = byType.get(m.type) ?? []; + list.push(m); + byType.set(m.type, list); + } + + // Within each type, find similar pairs + for (const [, group] of byType) { + for (let i = 0; i < group.length; i++) { + const a = group[i]!; + if (mergedIds.has(a.id)) continue; + + for (let j = i + 1; j < group.length; j++) { + const b = group[j]!; + if (mergedIds.has(b.id)) continue; + + const similarity = this.computeSimilarity(a, b); + if (similarity >= threshold) { + // Merge b into a (keep higher-importance as survivor) + const [survivor, merged] = + a.importance >= b.importance + ? [a, b] as [Memory, Memory] + : [b, a] as [Memory, Memory]; + + // Perform the merge + this.mergeMemories(survivor, merged); + + mergedIds.add(merged.id); + merges.push({ + kept: survivor.id, + merged: [merged.id], + reason: `Similarity ${similarity.toFixed(2)} (${ + a.content.slice(0, 40) + }... ↔ ${b.content.slice(0, 40)}...)`, + }); + } + } + } + } + + // Delete merged memories (unless dry run) + if (!options.dryRun) { + for (const id of mergedIds) { + this.store.delete(id); + } + } + + const afterCount = beforeCount - mergedIds.size; + + return { + mergesPerformed: merges.length, + beforeCount, + afterCount, + merges, + }; + } + + /** + * Get a summary of all memories grouped by type. + */ + getSummary(): Map { + const all = this.store.list(); + const byType = new Map(); + + for (const m of all) { + const list = byType.get(m.type) ?? []; + list.push(m); + byType.set(m.type, list); + } + + const summary = new Map(); + for (const [type, memories] of byType) { + // Collect top keywords across all memories of this type + const kwFreq = new Map(); + for (const m of memories) { + for (const kw of m.keywords) { + kwFreq.set(kw, (kwFreq.get(kw) ?? 0) + 1); + } + } + + const topKeywords = Array.from(kwFreq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([kw]) => kw); + + summary.set(type, { count: memories.length, topKeywords }); + } + + return summary; + } + + // ── Internal ──────────────────────────────────────────────────── + + private computeSimilarity(a: Memory, b: Memory): number { + // Keyword overlap (weight: 0.4) + const kwScore = keywordJaccardSimilarity(a.keywords, b.keywords); + + // Content word overlap (weight: 0.4) + const contentScore = wordJaccardSimilarity(a.content, b.content); + + // Source diversity bonus: same-type memories from different sessions + // are more likely to be independent than true duplicates + const sourcePenalty = a.source === b.source ? 0 : 0.1; + + // Importance: two low-importance items more likely to be merged + const importanceFactor = 1 - Math.abs(a.importance - b.importance) * 0.5; + + return (kwScore * 0.4 + contentScore * 0.4 - sourcePenalty) * importanceFactor; + } + + private mergeMemories(survivor: Memory, merged: Memory): void { + // Combine content (avoid duplication) + const combinedContent = mergeContent(survivor.content, merged.content); + + // Combine keywords (unique, sorted by frequency) + const combinedKeywords = [...new Set([...survivor.keywords, ...merged.keywords])]; + + // Average importance, biased toward survivor + const combinedImportance = Math.min( + 1, + survivor.importance * 0.7 + merged.importance * 0.3 + 0.05, + ); + + // Update the survivor in the store + this.store.update(survivor.id, { + content: combinedContent, + keywords: combinedKeywords, + importance: combinedImportance, + accessCount: survivor.accessCount + merged.accessCount, + }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function keywordJaccardSimilarity(a: string[], b: string[]): number { + if (a.length === 0 && b.length === 0) return 0; + const setA = new Set(a.map((k) => k.toLowerCase())); + const setB = new Set(b.map((k) => k.toLowerCase())); + const intersection = new Set([...setA].filter((k) => setB.has(k))); + const union = new Set([...setA, ...setB]); + return intersection.size / Math.max(1, union.size); +} + +function wordJaccardSimilarity(a: string, b: string): number { + const normalize = (s: string) => + s + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const wordsA = new Set(normalize(a).split(' ').filter((w) => w.length > 2)); + const wordsB = new Set(normalize(b).split(' ').filter((w) => w.length > 2)); + + if (wordsA.size === 0 || wordsB.size === 0) return 0; + + const intersection = new Set([...wordsA].filter((w) => wordsB.has(w))); + const union = new Set([...wordsA, ...wordsB]); + + return intersection.size / union.size; +} + +function mergeContent(a: string, b: string): string { + // If one contains the other, keep the longer one + if (a.includes(b)) return a; + if (b.includes(a)) return b; + + // Otherwise, concatenate with separator + const combined = `${a}\n${b}`; + return combined.length > 1000 ? combined.slice(0, 997) + '...' : combined; +} + +/** + * Convenience: consolidate a store in one call. + */ +export function consolidateStore( + store: IMemoryStore, + options?: ConsolidationOptions, +): ConsolidationResult { + const consolidator = new MemoryConsolidator(store); + return consolidator.consolidate(options); +} diff --git a/packages/core/src/memory/extractor.ts b/packages/core/src/memory/extractor.ts new file mode 100644 index 0000000..209cd52 --- /dev/null +++ b/packages/core/src/memory/extractor.ts @@ -0,0 +1,346 @@ +/** + * MemoryExtractor — Extract memories from session transcripts. + * + * Uses heuristic pattern matching to identify facts worth remembering: + * - User preferences and corrections + * - Project architecture decisions + * - Recurring patterns and feedback + * + * Architecture reference: ARCHITECTURE.md §4.10 + */ + +import type { IMemoryStore } from './store.js'; +import { extractKeywords } from './store.js'; +import { MemoryType, type MemoryInput } from './types.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExtractionResult { + /** Extracted memories (already deduplicated) */ + memories: MemoryInput[]; + /** Number of candidate facts evaluated */ + candidatesEvaluated: number; + /** Number rejected by dedup check */ + duplicatesFound: number; +} + +export interface ExtractionOptions { + /** Session ID for source tracking */ + sessionId: string; + /** Working directory */ + cwd?: string; + /** Minimum confidence threshold for extraction (0-1) */ + minConfidence?: number; + /** Skip dedup check (for testing) */ + skipDedup?: boolean; +} + +// --------------------------------------------------------------------------- +// Extraction Patterns +// --------------------------------------------------------------------------- + +interface ExtractionPattern { + /** Regex to match in the transcript */ + regex: RegExp; + /** Memory type to assign */ + type: MemoryType; + /** Extract the memory content from regex match groups */ + extract: (match: RegExpMatchArray) => string | null; + /** Importance score 0-1 */ + importance: number; +} + +const EXTRACTION_PATTERNS: ExtractionPattern[] = [ + // ── User Preferences ───────────────────────────────────────── + { + regex: /(?:i prefer|i like|i want|use)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.USER_PROFILE, + extract: (m) => `User prefers: ${m[1]?.trim()}`, + importance: 0.7, + }, + { + regex: /(?:my|our)\s+(?:convention|style|pattern|standard)\s+(?:is|:)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.USER_PROFILE, + extract: (m) => `Coding convention: ${m[1]?.trim()}`, + importance: 0.8, + }, + { + regex: /(?:actually|no,?\s)(?:let'?s|use|do|try)\s+(.+?)(?:instead)?(?:\.|$|\n)/gi, + type: MemoryType.FEEDBACK, + extract: (m) => `Correction: ${m[0]?.trim()}`, + importance: 0.85, + }, + { + regex: /(?:don'?t|never|stop|avoid)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.FEEDBACK, + extract: (m) => `Negative feedback: ${m[0]?.trim()}`, + importance: 0.8, + }, + + // ── Project Context ────────────────────────────────────────── + { + regex: /(?:we|i)'?(?:'ve| have)?\s+(?:decided|chosen|switched)\s+(?:to|on)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.PROJECT_CONTEXT, + extract: (m) => `Decision: ${m[0]?.trim()}`, + importance: 0.75, + }, + { + regex: /(?:the|our)\s+(?:architecture|tech stack|stack)\s+(?:is|uses|consists of)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.PROJECT_CONTEXT, + extract: (m) => `Architecture: ${m[0]?.trim()}`, + importance: 0.8, + }, + { + regex: /(?:using|built with|powered by)\s+(.+?)(?:for|to|\.|$|\n)/gi, + type: MemoryType.PROJECT_CONTEXT, + extract: (m) => `Tech: ${m[0]?.trim()}`, + importance: 0.5, + }, + { + regex: /(?:the|this)\s+(?:project|repo|codebase)\s+(?:is|uses|follows)\s+(.+?)(?:\.|$|\n)/gi, + type: MemoryType.PROJECT_CONTEXT, + extract: (m) => `Project: ${m[0]?.trim()}`, + importance: 0.7, + }, + + // ── Reference ──────────────────────────────────────────────── + { + regex: /https?:\/\/[^\s)]+(?:[^\s.,;)])/gi, + type: MemoryType.REFERENCE, + extract: (m) => `URL reference: ${m[0]?.trim()}`, + importance: 0.3, + }, + { + regex: /(?:key|api|secret|endpoint|token)\s+(?:is|:|\s+=\s+)\s*(?:https?:\/\/|[a-zA-Z0-9\-_.]+\.[a-z]{2,})[^\s,.)]*/gi, + type: MemoryType.REFERENCE, + extract: (m) => `Config reference: ${m[0]?.trim()}`, + importance: 0.4, + }, +]; + +// --------------------------------------------------------------------------- +// MemoryExtractor +// --------------------------------------------------------------------------- + +export class MemoryExtractor { + private store: IMemoryStore; + private minConfidence: number; + + constructor(store: IMemoryStore, minConfidence = 0.4) { + this.store = store; + this.minConfidence = minConfidence; + } + + /** + * Extract memories from a session transcript. + * + * Steps: + * 1. Apply heuristic extraction patterns to find candidate facts + * 2. Score candidates by pattern confidence + * 3. Check for duplicates against existing memories + * 4. Return deduplicated results (caller decides whether to save) + */ + extract(transcript: string, options: ExtractionOptions): ExtractionResult { + const candidates: MemoryInput[] = []; + const sessionId = options.sessionId; + const cwd = options.cwd; + + // Apply extraction patterns + for (const pattern of EXTRACTION_PATTERNS) { + // Reset regex lastIndex for global patterns + pattern.regex.lastIndex = 0; + + let match: RegExpMatchArray | null; + while ((match = pattern.regex.exec(transcript)) !== null) { + const content = pattern.extract(match); + if (!content || content.length < 10 || content.length > 500) continue; + if (pattern.importance < (options.minConfidence ?? this.minConfidence)) continue; + + candidates.push({ + type: pattern.type, + content, + keywords: extractKeywords(content), + source: sessionId, + cwd, + importance: pattern.importance, + }); + } + } + + // Extract recurring significant terms as potential memories + const termMemories = this.extractRecurringTerms(transcript, sessionId, cwd); + candidates.push(...termMemories); + + // Dedup + let duplicatesFound = 0; + const deduped: MemoryInput[] = []; + + for (const candidate of candidates) { + if (options.skipDedup) { + deduped.push(candidate); + continue; + } + + const isDup = this.isDuplicate(candidate); + if (isDup) { + duplicatesFound++; + continue; + } + + deduped.push(candidate); + } + + return { + memories: deduped, + candidatesEvaluated: candidates.length, + duplicatesFound, + }; + } + + /** + * Extract and save memories from a transcript in one step. + */ + extractAndSave(transcript: string, options: ExtractionOptions): MemoryInput[] { + const result = this.extract(transcript, options); + + for (const memory of result.memories) { + this.store.save(memory); + } + + return result.memories; + } + + /** + * Check if a candidate memory is a duplicate of an existing one. + * Uses keyword overlap and content similarity. + */ + isDuplicate(candidate: MemoryInput): boolean { + const existing = this.store.search( + candidate.keywords?.slice(0, 5).join(' ') ?? candidate.content, + 10, + ); + + for (const { memory } of existing) { + if (memory.source === candidate.source) continue; // Same session OK + + // High keyword overlap → likely duplicate + const overlap = keywordOverlap(candidate.keywords ?? [], memory.keywords); + if (overlap > 0.6) return true; + + // Near-identical content (after normalization) + const similarity = contentSimilarity(candidate.content, memory.content); + if (similarity > 0.8) return true; + } + + return false; + } + + /** + * Extract recurring significant terms from the transcript. + * Terms that appear 3+ times across different contexts are worth remembering. + */ + private extractRecurringTerms( + transcript: string, + sessionId: string, + cwd?: string, + ): MemoryInput[] { + const words = transcript + .toLowerCase() + .replace(/[^a-z0-9\s\-_.\/#@]/gi, ' ') + .split(/\s+/) + .filter((w) => w.length > 4); + + const freq = new Map(); + for (const w of words) { + freq.set(w, (freq.get(w) ?? 0) + 1); + } + + const memories: MemoryInput[] = []; + for (const [word, count] of freq) { + if (count >= 3 && !isCommonTerm(word)) { + const context = findContextForTerm(transcript, word); + memories.push({ + type: MemoryType.PROJECT_CONTEXT, + content: `Recurring term "${word}" (${count}x): ${context}`, + keywords: [word], + source: sessionId, + cwd, + importance: Math.min(0.6, 0.2 + count * 0.1), + }); + } + } + + return memories.slice(0, 5); // Max 5 recurring term memories + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function keywordOverlap(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) return 0; + const setA = new Set(a.map((k) => k.toLowerCase())); + const setB = new Set(b.map((k) => k.toLowerCase())); + let overlap = 0; + for (const k of setA) { + if (setB.has(k)) overlap++; + } + return overlap / Math.max(setA.size, setB.size); +} + +function contentSimilarity(a: string, b: string): number { + const normalize = (s: string) => + s.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim(); + + const na = normalize(a); + const nb = normalize(b); + if (na === nb) return 1; + if (na.length === 0 || nb.length === 0) return 0; + + // Simple Jaccard similarity of words + const wordsA = new Set(na.split(' ')); + const wordsB = new Set(nb.split(' ')); + const intersection = new Set([...wordsA].filter((w) => wordsB.has(w))); + const union = new Set([...wordsA, ...wordsB]); + + return intersection.size / union.size; +} + +function isCommonTerm(word: string): boolean { + const common = new Set([ + 'there', 'their', 'which', 'could', 'would', 'should', 'about', + 'after', 'before', 'during', 'where', 'while', 'because', 'though', + 'these', 'those', 'other', 'every', 'first', 'second', 'number', + ]); + return common.has(word); +} + +function findContextForTerm(transcript: string, term: string): string { + const lines = transcript.split('\n'); + for (const line of lines) { + if (line.toLowerCase().includes(term.toLowerCase())) { + const trimmed = line.trim(); + return trimmed.length > 200 ? trimmed.slice(0, 197) + '...' : trimmed; + } + } + return term; +} + +/** + * Convenience: create an extractor using the store and run extraction. + * + * Usage: + * const store = createMemoryStore(); + * const memories = extractMemories(store, transcript, { sessionId: 'abc' }); + */ +export function extractMemories( + store: IMemoryStore, + transcript: string, + options: ExtractionOptions, +): ExtractionResult { + const extractor = new MemoryExtractor(store); + return extractor.extract(transcript, options); +} diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts new file mode 100644 index 0000000..b972b7d --- /dev/null +++ b/packages/core/src/memory/index.ts @@ -0,0 +1,40 @@ +/** + * @coder/core/memory — Barrel export for the Memory system. + * + * Exports types, store, extractor, and consolidator. + * Architecture reference: ARCHITECTURE.md §4.10 + */ + +// Types +export { + MemoryType, + type Memory, + type MemoryQuery, + type MemoryInput, + type MemorySearchResult, +} from './types.js'; + +// Store +export { + createMemoryStore, + JsonMemoryStore, + extractKeywords, + type IMemoryStore, +} from './store.js'; + +// Extractor +export { + MemoryExtractor, + extractMemories, + type ExtractionResult, + type ExtractionOptions, +} from './extractor.js'; + +// Consolidator +export { + MemoryConsolidator, + consolidateStore, + type ConsolidationResult, + type ConsolidationOptions, + type MergeDetail, +} from './consolidator.js'; diff --git a/packages/core/src/memory/store.ts b/packages/core/src/memory/store.ts new file mode 100644 index 0000000..8232b41 --- /dev/null +++ b/packages/core/src/memory/store.ts @@ -0,0 +1,451 @@ +/** + * MemoryStore — SQLite FTS5 cross-session memory with JSON fallback. + * + * Primary implementation: better-sqlite3 with WAL mode and FTS5 virtual table. + * Fallback: JSON file at ~/.coder/memory.json with keyword-based search. + * + * Architecture reference: ARCHITECTURE.md §4.10 + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +import { + MemoryType, + type Memory, + type MemoryQuery, + type MemoryInput, + type MemorySearchResult, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Store Interface +// --------------------------------------------------------------------------- + +/** + * Contract for a memory store implementation. + * + * Implementations: + * - SqliteMemoryStore — better-sqlite3 + FTS5 (when native module available) + * - JsonMemoryStore — JSON file fallback (always available) + */ +export interface IMemoryStore { + /** Full-text / keyword search with relevance scoring */ + search(query: string, limit?: number): MemorySearchResult[]; + + /** Advanced query with type filter, importance threshold, etc. */ + query(q: MemoryQuery): MemorySearchResult[]; + + /** Save a new memory (auto-generates ID and timestamps) */ + save(input: MemoryInput): Memory; + + /** Update an existing memory */ + update(id: string, updates: Partial>): Memory | null; + + /** Delete a memory by ID */ + delete(id: string): boolean; + + /** Get a memory by ID */ + get(id: string): Memory | null; + + /** Get all memories (with optional limit) */ + list(limit?: number): Memory[]; + + /** + * Select relevant memories for the current conversation context. + * Extracts keywords from messages and matches against stored memories. + */ + selectRelevant(messages: Array<{ role: string; content: string | unknown }>, limit?: number): MemorySearchResult[]; + + /** Get total memory count */ + get count(): number; +} + +// --------------------------------------------------------------------------- +// JSON File Fallback Store +// --------------------------------------------------------------------------- + +const DEFAULT_DB_PATH = join(homedir(), '.coder', 'memory.json'); + +/** + * JSON-file based memory store. + * + * Stores memories as a JSON array at ~/.coder/memory.json. + * Search uses keyword matching on content + keywords fields. + * Designed as a drop-in fallback when better-sqlite3 is unavailable. + * + * Thread-safe via atomic write (write to temp, rename, delete temp). + */ +export class JsonMemoryStore implements IMemoryStore { + private filePath: string; + private cache: Memory[] | null = null; + private cacheDirty = false; + + constructor(dbPath?: string) { + this.filePath = dbPath ?? DEFAULT_DB_PATH; + } + + // ── Search ────────────────────────────────────────────────────── + + search(query: string, limit = 10): MemorySearchResult[] { + const memories = this.loadAll(); + if (!query.trim()) { + return memories.slice(0, limit).map((m) => ({ memory: m, score: 1 })); + } + + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const scored: MemorySearchResult[] = []; + + for (const m of memories) { + const score = this.computeRelevance(m, terms); + if (score > 0) { + scored.push({ memory: m, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit); + } + + query(q: MemoryQuery): MemorySearchResult[] { + let results = this.search(q.query ?? '', q.limit ?? 10); + + // Filter by type + if (q.type) { + const types = Array.isArray(q.type) ? q.type : [q.type]; + results = results.filter((r) => types.includes(r.memory.type)); + } + + // Filter by source + if (q.source) { + results = results.filter((r) => r.memory.source === q.source); + } + + // Filter by cwd prefix + if (q.cwd) { + results = results.filter((r) => !r.memory.cwd || r.memory.cwd.startsWith(q.cwd!)); + } + + // Filter by min importance + if (q.minImportance !== undefined) { + results = results.filter((r) => r.memory.importance >= q.minImportance!); + } + + return results.slice(0, q.limit ?? 10); + } + + // ── CRUD ──────────────────────────────────────────────────────── + + save(input: MemoryInput): Memory { + const now = new Date().toISOString(); + const memory: Memory = { + id: randomUUID(), + type: input.type, + content: input.content, + keywords: input.keywords ?? extractKeywords(input.content), + source: input.source, + cwd: input.cwd, + importance: input.importance ?? 0.5, + createdAt: now, + updatedAt: now, + accessCount: 0, + }; + + const all = this.loadAll(); + all.push(memory); + this.writeAll(all); + + return memory; + } + + update(id: string, updates: Partial>): Memory | null { + const all = this.loadAll(); + const index = all.findIndex((m) => m.id === id); + if (index === -1) return null; + + const existing = all[index]!; + const updated: Memory = { + ...existing, + ...updates, + id: existing.id, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + keywords: updates.content + ? extractKeywords(updates.content) + : updates.keywords ?? existing.keywords, + }; + + all[index] = updated; + this.writeAll(all); + + return updated; + } + + delete(id: string): boolean { + const all = this.loadAll(); + const index = all.findIndex((m) => m.id === id); + if (index === -1) return false; + + all.splice(index, 1); + this.writeAll(all); + return true; + } + + get(id: string): Memory | null { + const all = this.loadAll(); + const memory = all.find((m) => m.id === id) ?? null; + + if (memory) { + this.incrementAccessCount(memory); + } + + return memory; + } + + list(limit = 100): Memory[] { + const all = this.loadAll(); + return all.slice(-limit); + } + + // ── selectRelevant ────────────────────────────────────────────── + + selectRelevant( + messages: Array<{ role: string; content: string | unknown }>, + limit = 5, + ): MemorySearchResult[] { + // Extract all text from messages + const allText = messages + .map((m) => { + if (typeof m.content === 'string') return m.content; + if (Array.isArray(m.content)) { + return (m.content as Array<{ text?: string; content?: string }>) + .map((b) => b.text ?? b.content ?? '') + .join(' '); + } + return ''; + }) + .join(' '); + + if (!allText.trim()) return []; + + // Extract significant keywords (longer words, skip common stopwords) + const queryTerms = extractSignificantTerms(allText); + if (queryTerms.length === 0) return []; + + // Search using extracted terms + const results = this.search(queryTerms.join(' '), limit * 2); + + // Boost by importance and recency + for (const r of results) { + r.score = r.score * 0.5 + r.memory.importance * 0.3 + this.recencyBoost(r.memory) * 0.2; + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, limit); + } + + // ── Count ─────────────────────────────────────────────────────── + + get count(): number { + return this.loadAll().length; + } + + // ── Internal ──────────────────────────────────────────────────── + + private loadAll(): Memory[] { + if (this.cache !== null && !this.cacheDirty) return this.cache; + + try { + if (!existsSync(this.filePath)) { + this.cache = []; + return this.cache; + } + const raw = readFileSync(this.filePath, 'utf-8'); + this.cache = JSON.parse(raw) as Memory[]; + return this.cache; + } catch { + this.cache = []; + return this.cache; + } + } + + private writeAll(memories: Memory[]): void { + const dir = dirname(this.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Atomic write: write to temp file, rename, clean up + const tmpPath = this.filePath + '.tmp'; + const bakPath = this.filePath + '.bak'; + + writeFileSync(tmpPath, JSON.stringify(memories, null, 2), 'utf-8'); + + // Backup existing file if present + if (existsSync(this.filePath)) { + try { renameSync(this.filePath, bakPath); } catch { /* ok */ } + } + + try { + renameSync(tmpPath, this.filePath); + } catch { + // Restore backup on failure + if (existsSync(bakPath)) { + try { renameSync(bakPath, this.filePath); } catch { /* ok */ } + } + throw new Error('Failed to write memory store'); + } + + // Clean up + if (existsSync(bakPath)) { + try { unlinkSync(bakPath); } catch { /* ok */ } + } + if (existsSync(tmpPath)) { + try { unlinkSync(tmpPath); } catch { /* ok */ } + } + + this.cache = memories; + this.cacheDirty = false; + } + + private computeRelevance(memory: Memory, terms: string[]): number { + const content = memory.content.toLowerCase(); + const keywords = memory.keywords.map((k) => k.toLowerCase()); + let score = 0; + + for (const term of terms) { + // Content matches (case-insensitive) + const contentMatches = (content.match(new RegExp(escapeRegex(term), 'gi')) ?? []).length; + score += contentMatches * 2; + + // Keyword matches (higher weight) + for (const kw of keywords) { + if (kw.includes(term) || term.includes(kw)) { + score += 5; + } + } + } + + // Normalize by term count + return Math.min(1, score / (terms.length * 10)); + } + + private recencyBoost(memory: Memory): number { + const age = Date.now() - new Date(memory.createdAt).getTime(); + const days = age / (24 * 60 * 60 * 1000); + // Exponential decay: 1 at creation, ~0.1 after 90 days + return Math.exp(-days / 90); + } + + private incrementAccessCount(memory: Memory): void { + memory.accessCount++; + memory.updatedAt = new Date().toISOString(); + this.cacheDirty = true; + // Defer write — will be flushed on next writeAll or we can flush now + const all = this.loadAll(); + const idx = all.findIndex((m) => m.id === memory.id); + if (idx !== -1) { + all[idx] = memory; + this.writeAll(all); + } + } +} + +// --------------------------------------------------------------------------- +// Factory — auto-detect best implementation +// --------------------------------------------------------------------------- + +let _sqliteAvailable: boolean | null = null; + +function isSqliteAvailable(): boolean { + if (_sqliteAvailable !== null) return _sqliteAvailable; + try { + // Dynamic import check — TypeScript will tree-shake this if not used + require('better-sqlite3'); + _sqliteAvailable = true; + } catch { + _sqliteAvailable = false; + } + return _sqliteAvailable; +} + +/** + * Create the best available MemoryStore implementation. + * + * Tries better-sqlite3 first; falls back to JSON file store. + * The JSON store is always available and requires no native dependencies. + */ +export function createMemoryStore(dbPath?: string): IMemoryStore { + if (isSqliteAvailable()) { + // SQLite path — defined in sqlite-store.ts (loaded lazily) + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { SqliteMemoryStore } = require('./sqlite-store.js'); + return new SqliteMemoryStore(dbPath); + } catch { + // Fall through to JSON fallback + } + } + return new JsonMemoryStore(dbPath); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract simple keywords from text content. + */ +export function extractKeywords(text: string, maxKeywords = 10): string[] { + const words = text + .toLowerCase() + .replace(/[^a-z0-9\s\-_.\/#@]/gi, ' ') + .split(/\s+/) + .filter((w) => w.length > 2) + .filter((w) => !STOPWORDS.has(w)); + + // Count frequency, pick top N + const freq = new Map(); + for (const w of words) { + freq.set(w, (freq.get(w) ?? 0) + 1); + } + + return Array.from(freq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, maxKeywords) + .map(([w]) => w); +} + +/** + * Extract significant terms from text (for selectRelevant). + * Filters out short words and common stopwords. + */ +function extractSignificantTerms(text: string, maxTerms = 8): string[] { + const words = text + .toLowerCase() + .replace(/[^a-z0-9\s\-_.\/#@]/gi, ' ') + .split(/\s+/) + .filter((w) => w.length > 3) + .filter((w) => !STOPWORDS.has(w)) + .filter((w) => !/^\d+$/.test(w)); // Skip pure numbers + + const unique = [...new Set(words)]; + return unique.slice(0, maxTerms); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const STOPWORDS = new Set([ + 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', + 'had', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'from', + 'they', 'that', 'this', 'with', 'what', 'when', 'were', 'which', + 'will', 'your', 'been', 'each', 'more', 'some', 'than', 'then', + 'into', 'like', 'just', 'also', 'over', 'such', 'only', 'other', + 'very', 'much', 'well', 'even', 'most', 'make', 'made', 'does', + 'being', 'about', 'after', 'their', 'there', 'these', 'those', + 'would', 'could', 'should', 'where', 'while', +]); diff --git a/packages/core/src/memory/types.ts b/packages/core/src/memory/types.ts new file mode 100644 index 0000000..eabe856 --- /dev/null +++ b/packages/core/src/memory/types.ts @@ -0,0 +1,90 @@ +/** + * Memory types — types for the FTS5-backed cross-session memory system. + * + * Agent memory system with SQLite FTS5 for efficient retrieval. + * Architecture reference: ARCHITECTURE.md §4.10 + */ + +// --------------------------------------------------------------------------- +// Memory Type +// --------------------------------------------------------------------------- + +export enum MemoryType { + /** User preferences, coding style, personal conventions */ + USER_PROFILE = 'user_profile', + /** Project-specific context: architecture decisions, tech stack, conventions */ + PROJECT_CONTEXT = 'project_context', + /** User corrections, feedback on agent behavior */ + FEEDBACK = 'feedback', + /** Reference material: documentation snippets, code patterns, URLs */ + REFERENCE = 'reference', +} + +// --------------------------------------------------------------------------- +// Memory +// --------------------------------------------------------------------------- + +export interface Memory { + /** Unique ID (UUID) */ + id: string; + /** Memory classification */ + type: MemoryType; + /** The memory content (plain text) */ + content: string; + /** Keywords for search indexing */ + keywords: string[]; + /** Source: session ID or 'manual' */ + source: string; + /** Working directory when the memory was created */ + cwd?: string; + /** Importance score 0-1 (higher = more important) */ + importance: number; + /** Creation timestamp (ISO 8601) */ + createdAt: string; + /** Last update timestamp (ISO 8601) */ + updatedAt: string; + /** Number of times this memory was accessed */ + accessCount: number; +} + +// --------------------------------------------------------------------------- +// Memory Search Query +// --------------------------------------------------------------------------- + +export interface MemoryQuery { + /** Free-text query for keyword / FTS5 search */ + query?: string; + /** Filter by memory type */ + type?: MemoryType | MemoryType[]; + /** Filter by source session */ + source?: string; + /** Filter by working directory prefix */ + cwd?: string; + /** Minimum importance threshold (0-1) */ + minImportance?: number; + /** Maximum number of results */ + limit?: number; +} + +// --------------------------------------------------------------------------- +// Memory Input (for creation) +// --------------------------------------------------------------------------- + +export interface MemoryInput { + type: MemoryType; + content: string; + keywords?: string[]; + source: string; + cwd?: string; + importance?: number; +} + +// --------------------------------------------------------------------------- +// Search Result +// --------------------------------------------------------------------------- + +export interface MemorySearchResult { + memory: Memory; + /** Relevance score 0-1 (1 = exact match) */ + score: number; +} diff --git a/packages/core/src/permission/engine.ts b/packages/core/src/permission/engine.ts new file mode 100644 index 0000000..3f0bdc2 --- /dev/null +++ b/packages/core/src/permission/engine.ts @@ -0,0 +1,197 @@ +/** + * PermissionEngine — Plan / Ask / Auto three-tier permission system + * + * Implements Claude Code's Plan/Ask/Auto permission model combined with + * CodeWhale's Auto Mode Classifier and RiskLevel-based decisions. + * + * Architecture reference: ARCHITECTURE.md §4.4 + * Type reference: packages/shared/src/types/permission.ts + */ + +import { + PermissionMode, + RiskLevel, + requiresApproval as checkApproval, + type PermissionCheck, + type PermissionResult, +} from '@coder/shared'; +import type { ToolDefinition } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// PermissionEngine +// --------------------------------------------------------------------------- + +export class PermissionEngine { + private mode: PermissionMode = PermissionMode.ASK; + private trustedDirectories: Set = new Set(); + private cwd: string; + + constructor(cwd: string) { + this.cwd = cwd; + } + + /** + * Check if a tool operation should be allowed. + * + * Decision flow: + * 1. Get the tool's definition to determine its RiskLevel + * 2. Check if the tool requires approval based on risk level and mode + * 3. If no approval needed → auto-approve + * 4. If approval needed → ask user (or deny in non-interactive mode) + */ + async check( + check: PermissionCheck, + tool?: ToolDefinition, + ): Promise { + const riskLevel = tool?.riskLevel ?? check.riskLevel; + + // Use the shared requiresApproval function + const trusted = this.isTrustedDirectory(this.cwd); + const approvalNeeded = checkApproval(riskLevel, this.mode, trusted); + + if (!approvalNeeded) { + return { allowed: true, behavior: 'approve' }; + } + + // In Auto mode with trusted directory, approve non-destructive + // DESTRUCTIVE operations must always ask the user, even in AUTO+trusted + if (this.mode === PermissionMode.AUTO && trusted && riskLevel !== RiskLevel.DESTRUCTIVE) { + return { allowed: true, behavior: 'approve' }; + } + + // In Plan mode, deny non-SAFE + if (this.mode === PermissionMode.PLAN) { + return { + allowed: false, + behavior: 'deny', + reason: `Tool '${check.toolName}' requires ${riskLevel} permission. Plan mode only allows SAFE operations.`, + }; + } + + // Check fine-grained approval from tool definition + if (tool?.requiresApproval?.(check.input) === false) { + return { allowed: true, behavior: 'approve' }; + } + + // Need user confirmation + return { + allowed: false, + behavior: 'ask_user', + prompt: `Allow ${check.toolName}? (${riskLevel} risk)`, + }; + } + + /** + * Quick inline check using only risk level and mode. + */ + needsApproval(riskLevel: RiskLevel): boolean { + return checkApproval(riskLevel, this.mode, this.isTrustedDirectory(this.cwd)); + } + + /** + * Get current permission mode. + */ + getMode(): PermissionMode { + return this.mode; + } + + /** + * Update permission mode at runtime. + */ + setMode(mode: PermissionMode): void { + this.mode = mode; + } + + /** + * Update the working directory. + */ + setCwd(cwd: string): void { + this.cwd = cwd; + } + + /** + * Check if a directory is trusted. + */ + isTrustedDirectory(cwd: string): boolean { + for (const dir of this.trustedDirectories) { + if (cwd.startsWith(dir)) return true; + } + return false; + } + + /** + * Add a directory to the trusted list. + */ + addTrustedDirectory(path: string): void { + this.trustedDirectories.add(path); + } + + /** + * Remove a directory from the trusted list. + */ + removeTrustedDirectory(path: string): void { + this.trustedDirectories.delete(path); + } + + /** + * Get all trusted directories. + */ + getTrustedDirectories(): string[] { + return Array.from(this.trustedDirectories); + } +} + +// --------------------------------------------------------------------------- +// Auto Mode Classifier (from CodeWhale) +// --------------------------------------------------------------------------- + +export interface ClassificationContext { + cwd: string; + isTrusted: boolean; + filesInScope: number; + hasDestructivePattern: boolean; +} + +/** + * Classify a task to determine the appropriate permission mode. + * + * Simple heuristic-based classifier: + * - Tasks with destructive patterns → ASK mode + * - Tasks in trusted directories with < 20 files → AUTO mode + * - Everything else → ASK mode + */ +export function classifyTaskMode( + task: string, + context: ClassificationContext, +): PermissionMode { + const lowerTask = task.toLowerCase(); + + // Check for destructive patterns + if ( + lowerTask.includes('delete') || + lowerTask.includes('remove') || + lowerTask.includes('force push') || + lowerTask.includes('rm -rf') || + lowerTask.includes('drop table') || + lowerTask.includes('truncate') + ) { + return PermissionMode.ASK; + } + + // Check for deployment/production patterns → ASK + if ( + lowerTask.includes('deploy') || + lowerTask.includes('production') || + lowerTask.includes('release') + ) { + return PermissionMode.ASK; + } + + // If in trusted directory with small scope → AUTO + if (context.isTrusted && context.filesInScope < 20) { + return PermissionMode.AUTO; + } + + // Default: ASK + return PermissionMode.ASK; +} diff --git a/packages/core/src/provider-adapter.ts b/packages/core/src/provider-adapter.ts new file mode 100644 index 0000000..30b19df --- /dev/null +++ b/packages/core/src/provider-adapter.ts @@ -0,0 +1,262 @@ +/** + * provider-adapter.ts — Bridge Provider.stream() → Agent Loop callModel + * + * Converts Provider's callback-based streaming to the AsyncGenerator pattern + * expected by the Agent Loop's query() function. + */ + +import type { + AssistantMessage, + ContentBlock, + CompletionUsage, + StreamEvent as SharedStreamEvent, + StopReason, +} from '@coder/shared'; +import type { JSONSchema } from '@coder/shared'; +import { createAssistantMessage, RiskLevel } from '@coder/shared'; +import type { CallModelParams } from './query.js'; +import type { Provider, ProviderConfig, ProviderResponse, ThinkingConfig, ModelConfig } from '@coder/provider'; +import type { StreamEvent as ProviderStreamEvent } from '@coder/provider'; +import { AnthropicProvider } from '@coder/provider'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function createCallModelFromProvider( + provider: Provider, + model: string, + thinking?: ThinkingConfig, + maxTokens?: number, +): (params: CallModelParams) => AsyncGenerator { + return async function* callModel( + params: CallModelParams, + ): AsyncGenerator { + const { system, messages, tools, signal } = params; + + const toolDefinitions = (tools as Array<{ + name: string; description: string; input_schema: Record; + }>).map((t) => ({ + name: t.name, description: t.description, + inputSchema: t.input_schema as JSONSchema, riskLevel: RiskLevel.MUTATION, + })); + + // Linked-list queue — O(1) enqueue and dequeue, avoids Array.shift() overhead. + // Each node holds one stream event; head/tail track the FIFO boundaries. + interface QueueNode { event: SharedStreamEvent; next: QueueNode | null; } + let head: QueueNode | null = null; + let tail: QueueNode | null = null; + let queueSize = 0; + const enqueue = (event: SharedStreamEvent): void => { + const node: QueueNode = { event, next: null }; + if (tail) { tail.next = node; } else { head = node; } + tail = node; + queueSize++; + }; + const dequeue = (): SharedStreamEvent | null => { + if (!head) return null; + const event = head.event; + head = head.next; + if (!head) tail = null; + queueSize--; + return event; + }; + let done = false; + let error: Error | null = null; + let drain: (() => void) | null = null; + const converter = createConverter(); + + const onEvent = (event: ProviderStreamEvent): void => { + const out = converter.convert(event); + if (out) { enqueue(out); if (drain) { const d = drain; drain = null; d(); } } + }; + + // Build ModelConfig with optional thinking configuration and maxTokens + const modelConfig: ModelConfig = { model }; + if (maxTokens !== undefined) { + modelConfig.maxTokens = maxTokens; + } + if (thinking && thinking.mode !== 'disabled') { + modelConfig.thinking = thinking; + } + + let response: ProviderResponse; + const streamPromise = provider + .stream(modelConfig, system, messages, toolDefinitions, onEvent) + .then((r) => { response = r; done = true; return r; }) + .catch((e: unknown) => { error = e instanceof Error ? e : new Error(String(e)); done = true; }) + .finally(() => { if (drain) { const d = drain; drain = null; d(); } }); + + const onAbort = (): void => { provider.abort(); }; + signal.addEventListener('abort', onAbort, { once: true }); + + try { + while (!done || queueSize > 0) { + if (queueSize > 0) { yield dequeue()!; } + else if (!done) { await new Promise((r) => { drain = r; }); } + } + if (error) throw error; + await streamPromise; + // Drain any stranded pending events before building the final message. + // Without this, the last text_delta content_block_delta stays stuck in + // the converter's pending queue and never reaches the consumer. + while (true) { + const flushed = converter.flush(); + if (flushed.length === 0) break; + for (const ev of flushed) yield ev; + } + if (response!) { yield converter.build(response, model); } + } finally { + signal.removeEventListener('abort', onAbort); + } + }; +} + +export function createCallModelFromConfig( + config: ProviderConfig, + model: string, +): (params: CallModelParams) => AsyncGenerator { + return createCallModelFromProvider(new AnthropicProvider(config), model); +} + +export function resetAdapterState(): void { /* no-op: state is closure-local */ } + +// --------------------------------------------------------------------------- +// Event Converter +// --------------------------------------------------------------------------- + +interface Converter { + convert(event: ProviderStreamEvent): SharedStreamEvent | null; + build(response: ProviderResponse, model: string): AssistantMessage; + /** Drain any stranded pending events (must call before build() at end of stream) */ + flush(): SharedStreamEvent[]; +} + +function createConverter(): Converter { + let blockIdx = 0; + let blockType: 'text' | 'tool_use' | 'thinking' | null = null; + let lastModel = 'unknown'; + let lastUsage: CompletionUsage = { input_tokens: 0, output_tokens: 0 }; + const pending: SharedStreamEvent[] = []; + + return { + convert(event: ProviderStreamEvent): SharedStreamEvent | null { + switch (event.type) { + case 'message_start': + lastModel = event.model; + lastUsage = { + input_tokens: event.usage?.inputTokens ?? 0, + output_tokens: event.usage?.outputTokens ?? 0, + cache_creation_input_tokens: event.usage?.cacheCreationInputTokens, + cache_read_input_tokens: event.usage?.cacheReadInputTokens, + }; + return { type: 'message_start', message: { model: event.model, usage: lastUsage } }; + + case 'text_delta': { + if (blockType !== 'text') { + // Transition from tool_use or thinking → text: close the current block. + // (text → text: no-op, blockType stays 'text') + if (blockType === 'tool_use' || blockType === 'thinking') { + pending.push({ type: 'content_block_stop', index: blockIdx }); + blockIdx++; + } + blockType = 'text'; + pending.push({ type: 'content_block_start', index: blockIdx, content_block: { type: 'text', text: '' } }); + } + if (pending.length > 0) { + pending.push({ type: 'content_block_delta', index: blockIdx, delta: { type: 'text_delta', text: event.text } }); + return pending.shift()!; + } + return { type: 'content_block_delta', index: blockIdx, delta: { type: 'text_delta', text: event.text } }; + } + + case 'tool_use_start': + // Transition from text or thinking → tool_use: close the current block first. + if (blockType === 'text' || blockType === 'thinking') { + pending.push({ type: 'content_block_stop', index: blockIdx }); + } + if (blockType !== null) blockIdx++; + blockType = 'tool_use'; + return { type: 'content_block_start', index: blockIdx, content_block: { type: 'tool_use', id: event.id, name: event.name, input: event.input ?? {} } }; + + case 'tool_use_delta': + return { type: 'content_block_delta', index: blockIdx, delta: { type: 'input_json_delta', partial_json: event.partialJson } }; + + case 'tool_use_end': + blockType = null; // Reset so next text_delta won't double-close the tool_use block + return { type: 'content_block_stop', index: blockIdx }; + + // ── Thinking events (DeepSeek R1, Claude extended thinking) ──── + // DeepSeek produces thinking blocks (internal reasoning) BEFORE text + // blocks. The thinking phase can last 10-60+ seconds. Without this + // handler, ALL thinking events hit `default: return null`, dropping + // them — the TUI receives ZERO events during the thinking phase, + // making the user think the app is stuck. + case 'thinking': { + switch (event.phase) { + case 'start': { + // Close previous block (text or tool_use) before starting thinking + if (blockType === 'text' || blockType === 'tool_use') { + pending.push({ type: 'content_block_stop', index: blockIdx }); + } + if (blockType !== null) blockIdx++; + blockType = 'thinking'; + return { + type: 'content_block_start', + index: blockIdx, + content_block: { + type: 'thinking', + thinking: event.thinking, + signature: event.signature, + } as import('@coder/shared').ContentBlock, + }; + } + case 'delta': + return { + type: 'content_block_delta', + index: blockIdx, + delta: { type: 'thinking_delta' as const, thinking: event.thinking }, + }; + case 'end': + blockType = null; + return { type: 'content_block_stop', index: blockIdx }; + } + return null; + } + + case 'message_stop': + lastUsage = { + input_tokens: event.usage?.inputTokens ?? lastUsage.input_tokens, + output_tokens: event.usage?.outputTokens ?? lastUsage.output_tokens, + cache_creation_input_tokens: event.usage?.cacheCreationInputTokens ?? lastUsage.cache_creation_input_tokens, + cache_read_input_tokens: event.usage?.cacheReadInputTokens ?? lastUsage.cache_read_input_tokens, + totalCost: event.usage?.totalCost, + }; + return { type: 'message_delta', delta: { stop_reason: (event.stopReason as StopReason) ?? 'end_turn', usage: lastUsage } }; + + default: return null; + } + }, + + flush(): SharedStreamEvent[] { + const drained = pending.splice(0); + return drained; + }, + + build(response: ProviderResponse, model: string): AssistantMessage { + const blocks: ContentBlock[] = response.content.map((b) => { + switch (b.type) { + case 'text': return { type: 'text', text: b.text }; + case 'tool_use': return { type: 'tool_use', id: b.id, name: b.name, input: b.input }; + case 'thinking': return { type: 'thinking', thinking: b.thinking, signature: b.signature }; + } + }); + return createAssistantMessage(blocks, (response.stopReason as StopReason) ?? 'end_turn', { + input_tokens: response.usage.inputTokens, output_tokens: response.usage.outputTokens, + cache_creation_input_tokens: response.usage.cacheCreationInputTokens, + cache_read_input_tokens: response.usage.cacheReadInputTokens, + totalCost: response.usage.totalCost, + }, model); + }, + }; +} diff --git a/packages/core/src/query-engine.ts b/packages/core/src/query-engine.ts new file mode 100644 index 0000000..bd1ad42 --- /dev/null +++ b/packages/core/src/query-engine.ts @@ -0,0 +1,429 @@ +/** + * QueryEngine — Session lifecycle manager + * + * Consumes the query() AsyncGenerator, manages session state, + * and provides the main entry point for user interaction. + * + * Agent QueryEngine — main entry point for agent queries. + * Architecture reference: ARCHITECTURE.md §4.1 + */ + +import type { + Session, + AssistantMessage, + UserMessage, + QueryMessage, + StreamEvent, + DeferredPermission, + ContentBlock, +} from '@coder/shared'; +import { PermissionMode, AgentError } from '@coder/shared'; +import { query, type QueryConfig, type CallModelParams } from './query.js'; +import { ToolRegistry } from './tool-registry.js'; +import { PermissionEngine } from './permission/engine.js'; +import { SystemPromptAssembler, type SystemPrompt } from './system-prompt/assembler.js'; +import { SessionManager } from './session.js'; +import { CheckpointManager } from './checkpoint.js'; +import type { Provider, ThinkingConfig } from '@coder/provider'; +import { createCallModelFromProvider } from './provider-adapter.js'; +import type { SubagentBus } from '@coder/shared'; +import type { HookManager } from './hooks/manager.js'; + +// --------------------------------------------------------------------------- +// SystemPrompt cache — avoids re-assembling identical prompts across engine +// instances. Keyed by cwd + permission mode + agent role. 30-second TTL. +// --------------------------------------------------------------------------- + +interface CachedPrompt { + prompt: SystemPrompt; + timestamp: number; +} + +const systemPromptCache = new Map(); +const PROMPT_CACHE_TTL_MS = 30_000; +const PROMPT_CACHE_MAX_SIZE = 50; + +function purgeStalePromptCache(): void { + if (systemPromptCache.size <= PROMPT_CACHE_MAX_SIZE) return; + const now = Date.now(); + for (const [key, entry] of systemPromptCache) { + if (now - entry.timestamp > PROMPT_CACHE_TTL_MS) { + systemPromptCache.delete(key); + } + } +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface QueryEngineConfig { + cwd: string; + toolRegistry: ToolRegistry; + sessionManager: SessionManager; + maxTurns?: number; + maxBudgetUsd?: number; + contextBudget?: number; + compactThreshold?: number; + customSystemPrompt?: string; + appendSystemPrompt?: string; + model?: string; + callModel?: (params: CallModelParams) => AsyncGenerator; + /** Convenience: pass a Provider to auto-bridge via provider-adapter */ + provider?: Provider; + /** Model name used when provider is set */ + providerModel?: string; + /** Optional SubagentBus for tracking background sub-agents */ + subagentBus?: SubagentBus; + /** Worker agentId — when set, drains messageQueue each turn via subagentBus */ + agentId?: string; + /** Engine mode: 'default' | 'coordinator' | 'worker' (default: 'default') */ + mode?: 'default' | 'coordinator' | 'worker'; + /** Extended thinking configuration (passed to Provider via ModelConfig.thinking) */ + thinkingConfig?: ThinkingConfig; + /** Maximum output tokens for the model (default: provider-specific fallback, 32768) */ + maxTokens?: number; + /** Optional HookManager for lifecycle hook execution (UserPromptSubmit, etc.) */ + hookManager?: HookManager; +} + +export interface QueryEngineEvent { + type: 'message' | 'error' | 'cost' | 'compact' | 'done' | 'permission_required'; + data?: unknown; + deferred?: DeferredPermission; +} + +// --------------------------------------------------------------------------- +// Mock Provider +// --------------------------------------------------------------------------- + +async function* mockCallModel(_params: CallModelParams): AsyncGenerator { + yield { type: 'message_start', message: { model: 'mock', usage: { input_tokens: 0, output_tokens: 0 } } }; + yield { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }; + const text = `I'll help you with that. Let me determine what tools to use for this task.`; + yield { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }; + yield { type: 'content_block_stop', index: 0 }; + yield { type: 'message_delta', delta: { stop_reason: 'end_turn', usage: { input_tokens: 500, output_tokens: 50 } } }; + // Yield a final AssistantMessage so the TUI bridge can emit message.complete + // and transition busy → false. Without this, the FaceTicker spins forever. + yield { + role: 'assistant', + content: [{ type: 'text', text }], + stopReason: 'end_turn', + usage: { input_tokens: 500, output_tokens: 50 }, + model: 'mock', + toolUseBlocks: [], + }; +} + +// --------------------------------------------------------------------------- +// QueryEngine +// --------------------------------------------------------------------------- + +export class QueryEngine { + private config: QueryEngineConfig; + private permissionEngine: PermissionEngine; + private abortController: AbortController | null = null; + private checkpointManager: CheckpointManager; + private systemPrompt: SystemPrompt | null = null; + + constructor(config: QueryEngineConfig) { + this.config = { + maxTurns: 100, + contextBudget: 180_000, + compactThreshold: 0.7, + model: 'deepseek-v4-pro', + ...config, + }; + this.permissionEngine = new PermissionEngine(config.cwd); + this.checkpointManager = new CheckpointManager(); + } + + async init(): Promise { + const mode = this.config.mode ?? 'default'; + const cacheKey = `${this.config.cwd}:${this.permissionEngine.getMode()}:${mode}`; + + // Serve from cache if valid — saves ~50-200ms of assembly time. + const cached = systemPromptCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < PROMPT_CACHE_TTL_MS) { + this.systemPrompt = cached.prompt; + return; + } + + const assembler = new SystemPromptAssembler(); + this.systemPrompt = await assembler.assemble({ + cwd: this.config.cwd, + permissionMode: this.permissionEngine.getMode(), + customPrompt: this.config.customSystemPrompt, + appendPrompt: this.config.appendSystemPrompt, + agentRole: mode === 'coordinator' ? 'coordinator' : mode === 'worker' ? 'worker' : 'default', + }); + + systemPromptCache.set(cacheKey, { prompt: this.systemPrompt, timestamp: Date.now() }); + purgeStalePromptCache(); + + // ── Setup hook (non-blockable, fires on first init) ───────────── + if (this.config.hookManager) { + const session = this.config.sessionManager.getActive(); + if (session && session.messages.length === 0) { + this.config.hookManager.onSetup( + session.id, + this.config.cwd, + true, + this.config.model, + this.config.providerModel, + ).catch(() => {}); + } + } + } + + async *submitMessage(userInput: string): AsyncGenerator { + // 1) Abort any in-progress query so we don't leave it un-abortable. + // After abort, yield to the event loop so the previous query() can: + // a) resolve pending DeferredPermissions (via microtask) + // b) emit error tool_results for the aborted tools + // c) have its for-await loop persist those results to the session + // This prevents orphan cleanup from injecting duplicate tool_results + // that would cause API 400 errors on subsequent requests. + if (this.abortController) { + this.abortController.abort(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + const session = this.config.sessionManager.getActive(); + this.abortController = new AbortController(); + + // 2) Scan for orphaned tool_use blocks from any prior interrupted turn + const lastMsg = session.messages.length > 0 + ? session.messages[session.messages.length - 1] + : null; + const MISSING_RESULT_PROMPT = + 'A new user message arrived while tools were executing. ' + + 'The pending tool use results are unavailable.'; + + if (lastMsg && lastMsg.role === 'assistant' && Array.isArray(lastMsg.content)) { + const toolUseBlocks = lastMsg.content.filter((b) => b.type === 'tool_use'); + if (toolUseBlocks.length > 0) { + const errorResults: ContentBlock[] = toolUseBlocks.map((b) => ({ + type: 'tool_result' as const, + tool_use_id: b.id!, + content: MISSING_RESULT_PROMPT, + is_error: true, + })); + this.config.sessionManager.addMessage({ + role: 'user', + content: errorResults, + }); + } + } + + // === UserPromptSubmit hook (blockable) === + let effectiveInput = userInput; + if (this.config.hookManager) { + const result = await this.config.hookManager.onUserPromptSubmit( + session.id, + this.config.cwd, + userInput, + { model: this.config.model, provider: this.config.providerModel }, + ); + if (result.blocked) { + yield { + type: 'error', + data: new AgentError( + result.blockReason ?? 'Prompt blocked by UserPromptSubmit hook', + 'HOOK_BLOCKED', + ), + }; + return; + } + if (result.augmentedPrompt) { + effectiveInput = result.augmentedPrompt; + } + + // ── UserPromptExpansion hook (blockable) ────────────────────── + const expansionResult = await this.config.hookManager.onUserPromptExpansion( + session.id, + this.config.cwd, + userInput, + effectiveInput, + ); + if (expansionResult.blocked) { + yield { + type: 'error', + data: new AgentError( + expansionResult.blockReason ?? 'Prompt blocked by UserPromptExpansion hook', + 'HOOK_BLOCKED', + ), + }; + return; + } + if (expansionResult.expandedPromptOverride) { + effectiveInput = expansionResult.expandedPromptOverride; + } + } + + const userMessage: UserMessage = { role: 'user', content: effectiveInput }; + this.config.sessionManager.addMessage(userMessage); + + if (!this.systemPrompt) { + await this.init(); + } + + const queryConfig: QueryConfig = { + sessionId: session.id, + cwd: this.config.cwd, + messages: [...session.messages], + systemPrompt: this.systemPrompt!, + toolRegistry: this.config.toolRegistry, + permissionEngine: this.permissionEngine, + sessionManager: this.config.sessionManager, + checkpointManager: this.checkpointManager, + abortController: this.abortController, + maxTurns: this.config.maxTurns!, + maxBudgetUsd: this.config.maxBudgetUsd, + contextBudget: this.config.contextBudget!, + compactThreshold: this.config.compactThreshold!, + callModel: this.resolveCallModel(), + subagentBus: this.config.subagentBus, + agentId: this.config.agentId, + hookManager: this.config.hookManager, + // Dynamic system prompt refresh — re-assembles each turn so that + // MEMORY, Skills, and Hooks context can evolve during conversation. + // Always creates a fresh assembler to bypass the init() cache. + refreshSystemPrompt: async () => { + const assembler = new SystemPromptAssembler(); + return assembler.assemble({ + cwd: this.config.cwd, + permissionMode: this.permissionEngine.getMode(), + customPrompt: this.config.customSystemPrompt, + appendPrompt: this.config.appendSystemPrompt, + agentRole: this.config.mode === 'coordinator' + ? 'coordinator' + : this.config.mode === 'worker' + ? 'worker' + : 'default', + }); + }, + }; + + try { + for await (const msg of query(queryConfig)) { + switch (msg.type) { + case 'stream_event': + yield { type: 'message', data: msg }; + break; + case 'assistant': + this.config.sessionManager.addMessage(msg.message); + yield { type: 'message', data: msg }; + break; + case 'user': + this.config.sessionManager.addMessage(msg.message); + yield { type: 'message', data: msg }; + break; + case 'system': + if (msg.subtype === 'compact_boundary') { + yield { type: 'compact', data: msg.compactMetadata }; + } else if (msg.subtype === 'error') { + yield { type: 'error', data: msg.error }; + } else if (msg.subtype === 'progress') { + // Forward tool progress events (started/running/completed) + // so the TUI can emit tool.start / tool.complete events. + yield { type: 'message', data: msg }; + } else if (msg.subtype === 'permission_required') { + // Pass through to the caller (e.g. TUI Gateway) so it can + // display an approval overlay and resolve via deferred.resolve() + yield { type: 'permission_required', data: msg.deferred, deferred: msg.deferred }; + } + break; + } + } + yield { type: 'done', data: { sessionId: session.id } }; + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + yield { type: 'error', data: { message: errMsg } }; + throw error; + } finally { + this.config.sessionManager.saveSession(session); + } + } + + interrupt(): void { + if (this.abortController) { + this.abortController.abort(); + } + } + + async resume(sessionId: string): Promise { + const session = this.config.sessionManager.resume(sessionId); + this.permissionEngine.setCwd(session.cwd); + this.checkpointManager.loadFromDisk(sessionId); + return session; + } + + fork(fromTurn?: number): Session { + const session = this.config.sessionManager.getActive(); + return this.config.sessionManager.fork({ sessionId: session.id, fromTurn, cwd: this.config.cwd }); + } + + rewind(toTurn: number): Session { + const session = this.config.sessionManager.getActive(); + return this.config.sessionManager.rewind(session.id, toTurn); + } + + getPermissionEngine(): PermissionEngine { + return this.permissionEngine; + } + + getSessionManager(): SessionManager { + return this.config.sessionManager; + } + + /** + * Resolve the callModel function from config. + * + * Priority: + * 1. Explicit callModel function passed in + * 2. Provider + providerModel → lazy-import the adapter + * 3. Built-in mock (always available, no API key needed) + */ + private resolveCallModel(): (params: CallModelParams) => AsyncGenerator { + if (this.config.callModel) return this.config.callModel; + if (this.config.provider && this.config.providerModel) { + return createCallModelFromProvider( + this.config.provider, + this.config.providerModel, + this.config.thinkingConfig, + this.config.maxTokens, + ); + } + return mockCallModel; + } + + setPermissionMode(mode: PermissionMode): void { + this.permissionEngine.setMode(mode); + + // ── ConfigChange hook (non-blockable) ─────────────────────────── + if (this.config.hookManager) { + try { + const session = this.config.sessionManager.getActive(); + if (session) { + this.config.hookManager.onConfigChange( + session.id, + this.config.cwd, + ['permissionMode'], + { permissionMode: mode }, + undefined, + ).catch(() => {}); + } + } catch { + // Non-blockable: session may not be active yet + } + } + } + + shutdown(): void { + this.interrupt(); + const session = this.config.sessionManager.getActive(); + this.config.sessionManager.saveSession(session); + } +} diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts new file mode 100644 index 0000000..5a38e73 --- /dev/null +++ b/packages/core/src/query.ts @@ -0,0 +1,948 @@ +/** + * query.ts — AsyncGenerator-driven Agent Loop + * + * The core of Coder Agent — an AsyncGenerator that yields messages + * (stream_event / assistant / user / system / error / progress) + * consumed by QueryEngine.submitMessage() via for-await. + * + * AsyncGenerator-driven Agent Loop. + * Architecture reference: ARCHITECTURE.md §4.1 + */ + +import type { + Message, + AssistantMessage, + UserMessage, + ContentBlock, + ToolUseBlock, + ToolResultBlock, + StreamEvent, + CompletionUsage, + StopReason, + QueryMessage, + CompactMetadata, + ToolProgress, + DeferredPermission, +} from '@coder/shared'; +import { AgentError, RiskLevel } from '@coder/shared'; +import { ToolRegistry } from './tool-registry.js'; +import { PermissionEngine } from './permission/engine.js'; +import { SessionManager } from './session.js'; +import { CheckpointManager } from './checkpoint.js'; +import type { SystemPrompt } from './system-prompt/assembler.js'; +import type { ToolContext } from '@coder/shared'; +import type { SubagentBus } from '@coder/shared'; +import { formatTaskNotification } from '@coder/shared'; +import type { HookManager } from './hooks/manager.js'; +import { BudgetStore } from './budget-store.js'; +import { Compactor } from './context/compactor.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface QueryConfig { + sessionId: string; + cwd: string; + messages: Message[]; + systemPrompt: SystemPrompt; + toolRegistry: ToolRegistry; + permissionEngine: PermissionEngine; + sessionManager: SessionManager; + checkpointManager: CheckpointManager; + abortController: AbortController; + maxTurns: number; + maxBudgetUsd?: number; + contextBudget: number; + compactThreshold: number; + callModel: (params: CallModelParams) => AsyncGenerator; + /** Optional SubagentBus for tracking background sub-agents */ + subagentBus?: SubagentBus; + /** Optional agentId — when set, the loop drains SubagentBus.messageQueue each turn */ + agentId?: string; + /** Optional HookManager for lifecycle hook execution */ + hookManager?: HookManager; + /** Optional BudgetStore for disk offload of large tool results */ + budgetStore?: BudgetStore; + /** Optional callback to refresh system prompt each turn. When provided, the + * Agent Loop calls this at the start of every turn to get the latest system + * prompt (e.g. when MEMORY / Skills / Hooks context changes mid-conversation). + * When omitted, the static systemPrompt is used for all turns (backward + * compatible). */ + refreshSystemPrompt?: () => Promise | SystemPrompt; +} + +export interface CallModelParams { + system: string; + messages: Message[]; + tools: unknown[]; + signal: AbortSignal; +} + +export type QueryDeps = { + callModel: (params: CallModelParams) => AsyncGenerator; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createUserMessage(content: ContentBlock[]): UserMessage { + return { role: 'user', content }; +} + +function createToolErrorResult(toolUseId: string, error: string): ToolResultBlock { + return { + type: 'tool_result', + tool_use_id: toolUseId, + content: error, + is_error: true, + }; +} + +function estimateTokens(messages: Message[]): number { + let total = 0; + for (const msg of messages) { + if (typeof msg.content === 'string') { + total += Math.ceil(msg.content.length / 3.5); + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + const text = block.text ?? block.content ?? JSON.stringify(block.input ?? {}); + total += Math.ceil(String(text).length / 3.5); + } + } + } + return total; +} + +/** + * Extract plain text content from a list of assistant messages. + * Used by the PostMessage hook to provide a lightweight string + * representation of the LLM response. + */ +function extractAssistantText(assistantMessages: AssistantMessage[]): string { + const parts: string[] = []; + for (const msg of assistantMessages) { + if (typeof msg.content === 'string') { + parts.push(msg.content); + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + parts.push(block.text); + } else if (block.type === 'tool_use' && block.name) { + parts.push(`[tool_use: ${block.name}]`); + } else if (block.type === 'thinking') { + parts.push('[thinking]'); + } + } + } + } + return parts.join('\n'); +} + +// --------------------------------------------------------------------------- +// query() — Main Agent Loop +// --------------------------------------------------------------------------- + +export async function* query(config: QueryConfig): AsyncGenerator { + const { + sessionId, + cwd, + toolRegistry, + permissionEngine, + sessionManager, + checkpointManager, + abortController, + maxTurns, + maxBudgetUsd, + contextBudget, + compactThreshold, + callModel, + subagentBus, + hookManager, + budgetStore, + } = config; + + let messages = [...config.messages]; + let systemPrompt = config.systemPrompt; + let turnCount = 0; + let totalCost = 0; + + // ── Microcompact: track last user interaction for idle detection ──── + // When a session is resumed after a long idle period (>60min), + // microcompact clears stale tool results to save context budget. + let lastUserInteractionTime: number = Date.now(); + + // ── Lightweight Compactor instance for microcompact only ──────────── + // Full compaction uses inline logic (Snip strategy); the Compactor + // instance here provides only the zero-LLM microcompact capability. + const microcompactor = new Compactor({ + estimateTokens, + summarizeEnabled: false, + }); + + while (true) { + // === Exit conditions === + if (turnCount >= maxTurns) { + // ── Notification hook (non-blockable): MAX_TURNS ────────────── + if (hookManager) { + hookManager.onNotification( + sessionId, cwd, 'warn', + `Exceeded maximum of ${maxTurns} turns`, + { turnCount, maxTurns }, + ).catch(() => {}); + } + yield { + type: 'system', + subtype: 'error', + error: new AgentError(`Exceeded maximum of ${maxTurns} turns`, 'MAX_TURNS'), + }; + return; + } + + if (maxBudgetUsd && totalCost >= maxBudgetUsd) { + // ── Notification hook (non-blockable): BUDGET exceeded ──────── + if (hookManager) { + hookManager.onNotification( + sessionId, cwd, 'warn', + `Budget exceeded at $${totalCost.toFixed(2)}`, + { totalCost, maxBudgetUsd }, + ).catch(() => {}); + } + yield { + type: 'system', + subtype: 'error', + error: new AgentError(`Budget exceeded at $${totalCost.toFixed(2)}`, 'BUDGET'), + }; + return; + } + + if (abortController.signal.aborted) { + return; + } + + // === Dynamic system prompt refresh (per turn) === + // Moved AFTER exit condition checks (Sprint 7 fix): previously this ran + // before exit checks, wasting an assembler.assemble() call on the final + // turn. Now exit conditions short-circuit before the expensive refresh. + // When refreshSystemPrompt is provided, re-assemble the system prompt + // at the start of each turn. This allows MEMORY, Skills, and Hooks + // context to stay fresh as the conversation evolves. + if (config.refreshSystemPrompt) { + try { + systemPrompt = await config.refreshSystemPrompt(); + } catch { + // If refresh fails, keep the previous system prompt — a stale prompt + // is better than crashing the loop. + } + } + + // === Drain completed sub-agents (SubagentBus integration) === + // At the start of each turn, check for background workers that have + // finished. Inject XML into the message list so + // the LLM is aware of newly completed sub-agent work. + if (subagentBus && subagentBus.hasCompleted()) { + const completed = subagentBus.drainCompleted(); + for (const entry of completed) { + const notification = formatTaskNotification(entry); + const notificationMsg: UserMessage = { + role: 'user', + content: notification, + }; + messages.push(notificationMsg); + yield { type: 'user', message: notificationMsg }; + } + } + + // === Drain AgentMessage queue (Worker message injection) === + // At the start of each turn, check for parent-sent messages queued + // via AgentMessage tool. Inject them into the Worker's message list + // so the Worker processes follow-up instructions from Coordinator. + if (subagentBus && config.agentId) { + const queued = subagentBus.drainMessageQueue(config.agentId); + for (const msg of queued) { + messages.push(msg); + yield { type: 'user', message: msg }; + } + } + + // === Get tool definitions for LLM === + const toolDefinitions = toolRegistry.getDefinitions().map((def) => ({ + name: def.name, + description: def.description, + input_schema: def.inputSchema, + })); + + // === Stream call to LLM === + const assistantMessages: AssistantMessage[] = []; + const toolUseBlocks: ToolUseBlock[] = []; + let stopReason: StopReason = 'end_turn'; + let usage: CompletionUsage = { input_tokens: 0, output_tokens: 0 }; + + try { + let systemText = systemPrompt.prompt; + + // === PreMessage hook (blockable) === + if (hookManager) { + const messageSummaries = messages.slice(-10).map((m) => ({ + role: m.role, + summary: typeof m.content === 'string' + ? m.content.slice(0, 200) + : Array.isArray(m.content) + ? m.content + .map((b) => + b.type === 'text' + ? (b.text ?? '').slice(0, 100) + : `[${b.type}]`, + ) + .join('; ') + : '', + })); + const preMessageResult = await hookManager.onPreMessage( + sessionId, + cwd, + messageSummaries, + systemText, + 'unknown', + turnCount, + ); + if (preMessageResult.blocked) { + yield { + type: 'system', + subtype: 'error', + error: new AgentError( + preMessageResult.blockReason ?? 'API call blocked by PreMessage hook', + 'HOOK_BLOCKED', + ), + }; + return; + } + if (preMessageResult.modifiedSystemPrompt) { + systemText = preMessageResult.modifiedSystemPrompt; + } + if (preMessageResult.injectContext) { + systemText = `${preMessageResult.injectContext}\n\n${systemText}`; + } + } + + for await (const event of callModel({ + system: systemText, + messages, + tools: toolDefinitions, + signal: abortController.signal, + })) { + const isStreamEvent = 'type' in event; + if ( + isStreamEvent && + (event.type === 'content_block_start' || + event.type === 'content_block_delta' || + event.type === 'content_block_stop' || + event.type === 'message_start' || + event.type === 'message_delta' || + event.type === 'message_stop') + ) { + yield { type: 'stream_event', event: event as StreamEvent }; + + if (event.type === 'message_stop') { + const msg = (event as unknown as { type: 'message_stop'; message: AssistantMessage }).message; + if (msg) { + assistantMessages.push(msg); + stopReason = msg.stopReason; + usage = msg.usage; + + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'tool_use') { + toolUseBlocks.push(block as ToolUseBlock); + } + } + } + } + } + + if (event.type === 'message_delta') { + const delta = (event as { type: 'message_delta'; delta: { stop_reason: StopReason | null } }).delta; + if (delta.stop_reason) stopReason = delta.stop_reason; + } + } + + // Handle AssistantMessage yielded directly from the callModel generator + // (provider-adapter.build() returns AssistantMessage which has 'role' not 'type') + if (!isStreamEvent && 'role' in event && (event as AssistantMessage).role === 'assistant') { + const msg = event as AssistantMessage; + assistantMessages.push(msg); + stopReason = msg.stopReason ?? stopReason; + usage = msg.usage ?? usage; + + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'tool_use') { + toolUseBlocks.push(block as ToolUseBlock); + } + } + } + } + } + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + for (const block of toolUseBlocks) { + const errorMsg = createUserMessage([createToolErrorResult(block.id, errMsg)]); + messages.push(errorMsg); + yield { type: 'user', message: errorMsg }; + } + + // ── StopFailure hook (non-blockable) ────────────────────────── + if (hookManager) { + const apiError = { + message: errMsg, + code: (error as { code?: string })?.code, + status: (error as { status?: number })?.status, + }; + hookManager.onStopFailure(sessionId, cwd, apiError, turnCount).catch(() => {}); + } + + yield { + type: 'system', + subtype: 'error', + error: new AgentError(errMsg, 'API_ERROR', true), + }; + return; + } + + // Emit assistant messages + for (const msg of assistantMessages) { + yield { type: 'assistant', message: msg }; + } + + // === PostMessage hook (non-blockable, observability) === + if (hookManager) { + const assistantText = extractAssistantText(assistantMessages); + const messageSummaries = messages.slice(-10).map((m) => ({ + role: m.role, + summary: typeof m.content === 'string' + ? m.content.slice(0, 200) + : Array.isArray(m.content) + ? m.content + .map((b) => + b.type === 'text' + ? (b.text ?? '').slice(0, 100) + : `[${b.type}]`, + ) + .join('; ') + : '', + })); + hookManager.onPostMessage( + sessionId, + cwd, + assistantText, + 'unknown', + turnCount, + { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens }, + messageSummaries, + ).then((result) => { + // If hook wants to save to memory, queue it for the next turn + if (result.saveToMemory && config.refreshSystemPrompt && hookManager) { + // Memory saving is deferred to the session manager; + // injectContext is handled by injecting into the next system prompt + } + }).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + // Track cost + totalCost += usage.totalCost ?? 0; + const costEvent: StreamEvent = { type: 'cost_update', totalCost }; + yield { type: 'stream_event', event: costEvent }; + + sessionManager.updateUsage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + }); + if (usage.totalCost) sessionManager.addCost(usage.totalCost); + + // === Stop hook (end-of-turn) === + if (hookManager) { + const recentMessages = messages.slice(-5).map((m) => ({ + role: m.role, + summary: typeof m.content === 'string' + ? m.content.slice(0, 200) + : Array.isArray(m.content) + ? m.content + .map((b) => + b.type === 'text' + ? (b.text ?? '').slice(0, 100) + : `[${b.type}]`, + ) + .join('; ') + : '', + })); + const { shouldStop } = await hookManager.onStop( + sessionId, + cwd, + turnCount, + recentMessages, + ); + if (shouldStop) { + // ── Notification hook (non-blockable): HOOK_STOP ───────────── + if (hookManager) { + hookManager.onNotification( + sessionId, cwd, 'info', + 'Stop requested by hook', + { turnCount }, + ).catch(() => {}); + } + yield { + type: 'system', + subtype: 'error', + error: new AgentError( + 'Stop requested by hook', + 'HOOK_STOP', + ), + }; + return; + } + } + + // === stop_reason is not tool_use → done === + if (stopReason !== 'tool_use' || toolUseBlocks.length === 0) { + return; + } + + // === Execute tools === + const toolResults: ToolResultBlock[] = []; + + for (const toolBlock of toolUseBlocks) { + if (abortController.signal.aborted) { + toolResults.push(createToolErrorResult(toolBlock.id, 'Interrupted by user')); + continue; + } + + const progress: ToolProgress = { toolName: toolBlock.name, toolUseId: toolBlock.id, status: 'started' }; + yield { type: 'system', subtype: 'progress', data: progress }; + + // Permission check + const toolDef = toolRegistry.get(toolBlock.name)?.definition; + let permissionResult = await permissionEngine.check( + { + toolName: toolBlock.name, + input: toolBlock.input, + riskLevel: (toolDef?.riskLevel ?? RiskLevel.MUTATION) as RiskLevel, + }, + toolDef, + ); + + // === PermissionRequest hook (blockable — can override permission) === + if (hookManager && permissionResult.behavior !== 'approve') { + const riskLevelStr = toolDef?.riskLevel ?? RiskLevel.MUTATION; + const { permissionOverride } = await hookManager.onPermissionRequest( + sessionId, + cwd, + toolBlock.name, + toolBlock.input, + String(riskLevelStr), + permissionResult.behavior, + ); + if (permissionOverride === 'auto-approve') { + permissionResult.allowed = true; + permissionResult.behavior = 'approve'; + } else if (permissionOverride === 'auto-deny') { + permissionResult.allowed = false; + permissionResult.behavior = 'deny'; + permissionResult.reason = `Auto-denied by PermissionRequest hook`; + } + } + + // ── Branch: deny ────────────────────────────────────────── + if (!permissionResult.allowed && permissionResult.behavior === 'deny') { + toolResults.push(createToolErrorResult(toolBlock.id, permissionResult.reason ?? 'Denied')); + + // === PermissionDenied hook (non-blockable) === + if (hookManager) { + hookManager.onPermissionDenied( + sessionId, cwd, toolBlock.name, toolBlock.input, + permissionResult.reason ?? 'Permission denied', + ).catch(() => {}); + } + continue; + } + + // ── Branch: ask_user (Deferred permission) ──────────────── + if (permissionResult.behavior === 'ask_user') { + const toolInput = toolBlock.input as Record; + const command = [toolBlock.name, ...Object.entries(toolInput ?? {}).map(([k, v]) => `${k}=${String(v)}`)].join(' '); + const description = + permissionResult.prompt ?? + toolDef?.description ?? + `Execute ${toolBlock.name}`; + + let resolve!: (allowed: boolean) => void; + const promise = new Promise((res) => { resolve = res; }); + + const deferred: DeferredPermission = { + toolName: toolBlock.name, + command, + description, + toolUseId: toolBlock.id, + resolve, + promise, + }; + + yield { type: 'system', subtype: 'permission_required', deferred }; + + const allowed = await new Promise((resolve) => { + promise.then((v) => resolve(v)); + const onAbort = () => { resolve(false); }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + }); + if (!allowed) { + toolResults.push(createToolErrorResult(toolBlock.id, 'User denied permission')); + + // === PermissionDenied hook (non-blockable) === + if (hookManager) { + hookManager.onPermissionDenied( + sessionId, cwd, toolBlock.name, toolBlock.input, + 'User denied permission', + ).catch(() => {}); + } + + const progressDenied: ToolProgress = { toolName: toolBlock.name, toolUseId: toolBlock.id, status: 'completed' }; + yield { type: 'system', subtype: 'progress', data: progressDenied }; + continue; + } + } + + // === PreToolUse hook === + if (hookManager) { + const { blocked, reason } = await hookManager.onPreToolUse( + sessionId, + cwd, + toolBlock.name, + toolBlock.input, + ); + if (blocked) { + toolResults.push( + createToolErrorResult( + toolBlock.id, + reason ?? `Blocked by PreToolUse hook`, + ), + ); + const progressBlocked: ToolProgress = { + toolName: toolBlock.name, + toolUseId: toolBlock.id, + status: 'completed', + message: `Blocked: ${reason ?? 'PreToolUse hook'}`, + }; + yield { type: 'system', subtype: 'progress', data: progressBlocked }; + continue; + } + } + + // Git checkpoint before destructive operations + if (toolDef?.riskLevel === 'destructive') { + await checkpointManager.create({ sessionId, cwd, description: `Pre-${toolBlock.name}` }); + } + + const toolCtx: ToolContext = { sessionId, cwd, signal: abortController.signal }; + + const progressRunning: ToolProgress = { toolName: toolBlock.name, toolUseId: toolBlock.id, status: 'running' }; + yield { type: 'system', subtype: 'progress', data: progressRunning }; + + const toolStartTime = Date.now(); + + try { + const result = await toolRegistry.execute(toolBlock.name, toolBlock.input, toolCtx); + const resultBlock: ToolResultBlock = { + type: 'tool_result', + tool_use_id: toolBlock.id, + content: result.success ? (result.output ?? JSON.stringify(result.data) ?? 'Success') : (result.error ?? 'Error'), + is_error: !result.success, + }; + toolResults.push(resultBlock); + sessionManager.trackTool(toolBlock.name); + + // ── Tool Result Budget: offload large outputs to disk ──────── + if (budgetStore && !resultBlock.is_error && typeof resultBlock.content === 'string') { + const offloadResult = budgetStore.maybeOffload( + toolBlock.id, + toolBlock.name, + resultBlock.content as string, + sessionId, + ); + if (offloadResult.entry) { + // Replace in-memory content with truncated preview + resultBlock.content = offloadResult.content; + // Update the last toolResults entry (it's the same object reference) + toolResults[toolResults.length - 1] = resultBlock; + } + } + + if ((toolBlock.name === 'Write' || toolBlock.name === 'Edit') && toolBlock.input) { + const input = toolBlock.input as Record; + if (typeof input.file_path === 'string') { + sessionManager.trackModifiedFile(input.file_path); + + // ── Auto-checkpoint: fire-and-forget file snapshot ───────── + // Non-blocking — failures are silently logged, never thrown. + const cpFilePath = input.file_path as string; + checkpointManager.autoCreate({ + sessionId, + turnNumber: turnCount, + toolName: toolBlock.name, + filePath: cpFilePath, + cwd, + readAfter: true, + }).catch(() => { + // Auto-checkpoint failure is non-fatal + }); + } + } + + const progressDone: ToolProgress = { + toolName: toolBlock.name, + toolUseId: toolBlock.id, + status: 'completed', + is_error: resultBlock.is_error, + message: resultBlock.is_error + ? `Error: ${String(resultBlock.content)}` + : String(resultBlock.content).slice(0, 500), + }; + yield { type: 'system', subtype: 'progress', data: progressDone }; + + // ── Notification hook (non-blockable): tool completed ─────── + if (hookManager) { + hookManager.onNotification( + sessionId, + cwd, + resultBlock.is_error ? 'warn' : 'info', + `Tool ${toolBlock.name} ${resultBlock.is_error ? 'failed' : 'completed'}`, + { + toolName: toolBlock.name, + isError: resultBlock.is_error, + toolUseId: toolBlock.id, + }, + ).catch(() => {}); + } + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : String(error); + toolResults.push(createToolErrorResult(toolBlock.id, errMsg)); + + // ── PostToolUseFailure hook (non-blockable) ───────────────── + if (hookManager) { + const errObj = error instanceof Error ? error : new Error(errMsg); + hookManager.onPostToolUseFailure( + sessionId, + cwd, + toolBlock.name, + toolBlock.input, + errObj, + ).catch(() => {}); + } + + const progressDone: ToolProgress = { + toolName: toolBlock.name, + toolUseId: toolBlock.id, + status: 'completed', + is_error: true, + message: `Error: ${errMsg}`, + }; + yield { type: 'system', subtype: 'progress', data: progressDone }; + } + + // === PostToolUse hook === + if (hookManager) { + const durationMs = Date.now() - toolStartTime; + const lastResult = toolResults[toolResults.length - 1]; + const success = lastResult ? !lastResult.is_error : true; + const output = lastResult?.content ?? ''; + // Fire-and-forget — PostToolUse errors should not block the loop + hookManager + .onPostToolUse( + sessionId, + cwd, + toolBlock.name, + toolBlock.input, + { output, success }, + success, + durationMs, + ) + .catch(() => { + // Hook failures are non-fatal + }); + } + } + + // === PostToolBatch hook (non-blockable) === + if (hookManager && toolResults.length > 0) { + const batchResults = toolResults.map((tr, i) => { + const toolBlock = toolUseBlocks[i]; + return { + toolName: toolBlock?.name ?? 'unknown', + success: !tr.is_error, + durationMs: 0, // Duration is per-tool, not available in batch context + summary: typeof tr.content === 'string' ? tr.content.slice(0, 200) : JSON.stringify(tr.content).slice(0, 200), + }; + }); + hookManager.onPostToolBatch(sessionId, cwd, batchResults).catch(() => {}); + } + + // === Level 2: Aggregate offload check === + // If the combined tool output exceeds 200KB, batch-offload ALL results + // and replace them with an index summary. This prevents a single turn + // from filling the entire context window. + if (budgetStore && toolResults.length > 0) { + const contentList = toolResults + .filter((tr) => typeof tr.content === 'string') + .map((tr) => (tr.content as string)); + if (budgetStore.shouldAggregateOffload(contentList)) { + const aggregateInput = toolResults + .filter((tr) => !tr.is_error) + .map((tr, i) => ({ + toolUseId: tr.tool_use_id, + toolName: `Tool #${i + 1}`, // We don't track tool names through toolResults; use index + content: typeof tr.content === 'string' ? tr.content as string : JSON.stringify(tr.content), + })); + const { content: summaryContent } = budgetStore.batchOffload(aggregateInput, sessionId); + // Replace all non-error tool result contents with the summary. + // Error results are kept as-is so the model knows about failures. + for (const tr of toolResults) { + if (!tr.is_error && typeof tr.content === 'string') { + tr.content = `[Offloaded — see summary below]\n${summaryContent}`; + } + } + // De-duplicate: only the first offloaded result carries the summary + let summaryInjected = false; + for (const tr of toolResults) { + if (!tr.is_error && typeof tr.content === 'string' && tr.content.startsWith('[Offloaded')) { + if (!summaryInjected) { + summaryInjected = true; + } else { + tr.content = `[Offloaded — see first tool result above for index]`; + } + } + } + } + } + + // === Inject assistant + tool results in correct API order === + // Anthropic API requires alternating roles: user → assistant → user → ... + // The assistant message (with tool_use blocks) MUST come before the + // user message (with tool_results), otherwise the API rejects the call. + // DeepSeek's Anthropic-compatible endpoint silently hangs on this error. + for (const am of assistantMessages) { + messages.push(am); + } + + const userMsg = createUserMessage(toolResults); + messages.push(userMsg); + yield { type: 'user', message: userMsg }; + + turnCount++; + + // === Microcompact: zero-cost lightweight cleanup === + // Runs BEFORE full compaction — clears stale tool results when + // the session has been idle >60min, saving tokens without LLM cost. + // Only triggers when there are actual savings (savedTokens > 0). + { + const microResult = await microcompactor.microcompact( + messages, + lastUserInteractionTime, + ); + if (microResult.strategy !== 'none' && microResult.removedCount > 0) { + messages = microResult.messages; + // Yield a compact boundary so the UI can show a trim indicator + yield { + type: 'system', + subtype: 'compact_boundary', + compactMetadata: { + beforeTokens: microResult.savedTokens + estimateTokens(messages), + afterTokens: estimateTokens(messages), + strategy: microResult.strategy, + }, + }; + // Log the savings + if (hookManager) { + hookManager.onNotification( + sessionId, + cwd, + 'info', + `[Microcompact] ${microResult.strategy}: removed ${microResult.removedCount} messages, saved ~${microResult.savedTokens.toLocaleString()} tokens`, + { + strategy: microResult.strategy, + removedCount: microResult.removedCount, + savedTokens: microResult.savedTokens, + }, + ).catch(() => {}); + } + } + } + + // === Context compaction check === + const currentTokens = estimateTokens(messages); + if (currentTokens / contextBudget > compactThreshold) { + // === PreCompact hook === + let injectContext = ''; + if (hookManager) { + try { + const result = await hookManager.onPreCompact( + sessionId, + cwd, + messages.length, + currentTokens, + contextBudget, + 'snip', + ); + injectContext = result.injectContext; + } catch { + // Hook failures are non-fatal during compaction + } + } + + const compactMeta: CompactMetadata = { + beforeTokens: currentTokens, + afterTokens: Math.ceil(currentTokens * 0.5), + strategy: 'snip', + }; + yield { type: 'system', subtype: 'compact_boundary', compactMetadata: compactMeta }; + + // ── Notification hook (non-blockable): compaction completed ── + if (hookManager) { + hookManager.onNotification( + sessionId, + cwd, + 'info', + `Context compacted: ${currentTokens} → ${compactMeta.afterTokens} tokens (snip)`, + { + beforeTokens: currentTokens, + afterTokens: compactMeta.afterTokens, + strategy: 'snip', + }, + ).catch(() => {}); + + // ── PostCompact hook (non-blockable) ───────────────────────── + const messagesRemoved = currentTokens - compactMeta.afterTokens; + hookManager.onPostCompact( + sessionId, + cwd, + 'snip', + currentTokens, + compactMeta.afterTokens, + messagesRemoved, + ).catch(() => {}); + } + + // Inject hook context as a system message before snipping + if (injectContext) { + const compactCtxMsg: Message = { + role: 'system', + content: `[PreCompact hook context]\n${injectContext}`, + }; + messages.push(compactCtxMsg); + } + + if (messages.length > 30) { + messages = messages.slice(-30); + } + } + } +} diff --git a/packages/core/src/rules-manager.ts b/packages/core/src/rules-manager.ts new file mode 100644 index 0000000..a0a9f7e --- /dev/null +++ b/packages/core/src/rules-manager.ts @@ -0,0 +1,426 @@ +/** + * rules-manager.ts — Path-scoped rules system (Phase 5 Sprint 7) + * + * Scans .coder/rules/*.md files, extracts YAML frontmatter with optional + * pathPattern glob, and returns only rules whose glob matches the currently + * active file path. Matches .coder/rules/*.md pattern for auto-loading rules by file path. + * + * Rule file format: + * --- + * pathPattern: "src/frontend/**\/*.tsx" + * description: "React frontend conventions" + * --- + * # Frontend Rules + * - Use React 18+ hooks + * - TypeScript strict mode + * + * Architecture reference: CLAUDE_CODE_COMPARISON.md §二 (System Prompt 差距) + */ + +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A single parsed rule file. + */ +export interface RuleFile { + /** Absolute path to the rule file */ + path: string; + /** Relative path from project root (for display) */ + relativePath: string; + /** Glob pattern from frontmatter (e.g. "src/frontend/**\/*.tsx") */ + pathPattern?: string; + /** Human-readable description of the rule */ + description?: string; + /** The Markdown body (frontmatter stripped) */ + content: string; + /** If true, the rule has no pathPattern and always loads */ + alwaysLoad: boolean; +} + +/** + * Context passed to getMatchingRules to determine which rules apply. + */ +export interface ActiveRulesContext { + /** Project working directory */ + cwd: string; + /** Currently active tool being invoked (e.g. 'Read', 'Write', 'Edit') */ + currentToolName?: string; + /** Absolute path of the file currently being operated on */ + currentFilePath?: string; + /** + * Additional file paths being operated on (e.g. Edit tool's + * old_path + new_path). All paths are checked against each rule's + * pathPattern. + */ + additionalPaths?: string[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Directory name containing rule files */ +const RULES_DIR = '.coder/rules'; + +/** Maximum individual rule file size (50 KB) */ +const MAX_RULE_SIZE_BYTES = 50 * 1024; + +// --------------------------------------------------------------------------- +// RuleManager +// --------------------------------------------------------------------------- + +export class RuleManager { + /** Loaded and parsed rules, keyed by project root path */ + private rulesByProject = new Map(); + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Scan the .coder/rules/ directory under cwd and parse all .md files. + * + * Results are cached per project root. Call this once at session start + * or on tool invocation. Subsequent calls return the cached rules. + * + * @param cwd — Project working directory (used to locate .coder/rules/) + * @returns Array of parsed RuleFile objects (may be empty if no rules dir) + */ + loadRules(cwd: string): RuleFile[] { + const rulesDir = resolve(cwd, RULES_DIR); + + // Return cached if already loaded for this project + const cached = this.rulesByProject.get(rulesDir); + if (cached) return cached; + + if (!existsSync(rulesDir)) { + // Cache empty result so we don't re-check the filesystem + this.rulesByProject.set(rulesDir, []); + return []; + } + + const rules: RuleFile[] = []; + + try { + const entries = readdirSync(rulesDir); + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + + const fullPath = join(rulesDir, entry); + try { + const st = statSync(fullPath); + if (!st.isFile()) continue; + if (st.size > MAX_RULE_SIZE_BYTES) continue; + } catch { + continue; // stat failed (permissions, deleted) — skip + } + + try { + const rawContent = readFileSync(fullPath, 'utf-8'); + const { frontmatter, body } = this.parseFrontmatter(rawContent); + + const pathPattern = frontmatter.pathPattern || frontmatter.pathpattern || frontmatter.glob; + const description = frontmatter.description; + + rules.push({ + path: fullPath, + relativePath: relative(cwd, fullPath), + pathPattern, + description, + content: body.trim(), + alwaysLoad: !pathPattern, + }); + } catch { + // Corrupt or unreadable rule file — skip + } + } + } catch { + // readdir failed (permissions) — return empty + } + + // Sort: always-load rules first, then by relative path for determinism + rules.sort((a, b) => { + if (a.alwaysLoad !== b.alwaysLoad) return a.alwaysLoad ? -1 : 1; + return a.relativePath.localeCompare(b.relativePath); + }); + + this.rulesByProject.set(rulesDir, rules); + return rules; + } + + /** + * Get rules matching the current context. + * + * A rule matches if: + * 1. It has alwaysLoad=true (no pathPattern), OR + * 2. Its pathPattern glob matches any of the active file paths + * (currentFilePath + additionalPaths) + * + * @param ctx — Active context with current tool and file paths + * @returns Array of matching RuleFile objects + */ + getMatchingRules(ctx: ActiveRulesContext): RuleFile[] { + const allRules = this.loadRules(ctx.cwd); + if (allRules.length === 0) return []; + + // Collect all file paths to check + const pathsToCheck: string[] = []; + if (ctx.currentFilePath) { + pathsToCheck.push(ctx.currentFilePath); + } + if (ctx.additionalPaths && ctx.additionalPaths.length > 0) { + pathsToCheck.push(...ctx.additionalPaths); + } + + // If no file paths at all, only return always-load rules + if (pathsToCheck.length === 0) { + return allRules.filter((r) => r.alwaysLoad); + } + + // Compute the relative version of each path for glob matching + const cwd = ctx.cwd; + const relativePaths = pathsToCheck.map((p) => { + try { + return relative(cwd, p); + } catch { + return p; // fallback to absolute + } + }); + + return allRules.filter((rule) => { + // Always-load rules always match + if (rule.alwaysLoad) return true; + + // Path-scoped rules: check if any active path matches the glob + if (rule.pathPattern) { + for (const rp of relativePaths) { + if (this.matchGlob(rule.pathPattern, rp)) return true; + } + } + + return false; + }); + } + + /** + * Format matching rules for system prompt injection. + * + * @param rules — Matching rules from getMatchingRules() + * @returns Formatted string ready for system prompt assembly + */ + formatRulesForPrompt(rules: RuleFile[]): string { + if (rules.length === 0) return ''; + + const header = '## Project Rules'; + const sections = rules.map((rule) => { + const desc = rule.description ? ` (${rule.description})` : ''; + const scope = rule.alwaysLoad ? '[always]' : `[scope: \`${rule.pathPattern}\`]`; + return `### ${rule.relativePath} ${scope}${desc}\n${rule.content}`; + }); + + return [header, ...sections].join('\n\n'); + } + + /** + * Clear the cache for a specific project (or all if cwd omitted). + * Useful for testing and for reloading rules after file changes. + */ + clearCache(cwd?: string): void { + if (cwd) { + const rulesDir = resolve(cwd, RULES_DIR); + this.rulesByProject.delete(rulesDir); + } else { + this.rulesByProject.clear(); + } + } + + // --------------------------------------------------------------------------- + // Private: Frontmatter Parsing + // --------------------------------------------------------------------------- + + /** + * Parse YAML-like frontmatter from a Markdown file. + * + * Frontmatter is delimited by --- at the start and end of the block. + * Only key: "value" pairs are supported (no nested YAML structures). + * + * @param content — Raw file content + * @returns Parsed frontmatter key-value pairs and remaining body text + */ + private parseFrontmatter(content: string): { + frontmatter: Record; + body: string; + } { + const frontmatter: Record = {}; + + // Match frontmatter block: starts with --- at line start, ends with --- + const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); + if (!match) { + return { frontmatter, body: content }; + } + + const fmBlock = match[1]!; + const body = content.slice(match[0].length); + + // Parse simple key: "value" lines + for (const line of fmBlock.split('\n')) { + const kvMatch = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*["']?(.+?)["']?\s*$/); + if (kvMatch) { + const key = kvMatch[1]!; + let value = kvMatch[2]!; + // Strip trailing quotes if they exist + value = value.replace(/^["']|["']$/g, ''); + frontmatter[key] = value; + } + } + + return { frontmatter, body }; + } + + // --------------------------------------------------------------------------- + // Private: Glob Matching + // --------------------------------------------------------------------------- + + /** + * Check whether a relative file path matches a glob pattern. + * + * Supported glob features: + * ** — matches zero or more directory segments + * * — matches zero or more characters within a segment (except /) + * ? — matches exactly one character within a segment (except /) + * {a,b} — alternation (matches a or b) + * [...] — character class + * + * This is a lightweight implementation that avoids the minimatch dependency. + * + * @param pattern — Glob pattern (e.g. "src/frontend/**\/*.tsx") + * @param filePath — Relative file path to check + * @returns true if the file path matches the pattern + */ + matchGlob(pattern: string, filePath: string): boolean { + // Normalize to forward slashes for cross-platform matching + const normPattern = pattern.replace(/\\/g, '/'); + const normPath = filePath.replace(/\\/g, '/'); + + try { + const regex = this.globToRegex(normPattern); + return regex.test(normPath); + } catch { + // If regex construction fails, fall back to simple string match + return normPath.includes(normPattern.replace(/\*\*/g, '')); + } + } + + /** + * Convert a glob pattern to a regular expression. + * + * Conversion rules: + * **[/] → (.*[/])? (match zero+ directory segments) + * * → [^/]* (match within a single segment) + * ? → [^/] (single character) + * {a,b} → (a|b) + * [...] — passed through as-is (character class) + * . + ^ $ ( ) | — escaped + * + * @param pattern — Glob pattern string + * @returns Compiled RegExp + */ + private globToRegex(pattern: string): RegExp { + let regexStr = ''; + let i = 0; + + while (i < pattern.length) { + const ch = pattern[i]; + + switch (ch) { + case '\\': { + // Escaped character — pass through literally + i++; + if (i < pattern.length) { + regexStr += escapeRegExp(pattern[i]!); + } + i++; + break; + } + case '*': { + // Check for ** (double star = cross-directory) + if (pattern[i + 1] === '*' && (pattern[i + 2] === '/' || pattern[i + 2] === undefined)) { + // ** or **/ → match zero or more directory segments + regexStr += '(?:.*/)?'; + i += 2; + if (pattern[i] === '/') i++; // skip trailing / + } else { + // Single * → match within segment + regexStr += '[^/]*'; + i++; + } + break; + } + case '?': { + regexStr += '[^/]'; + i++; + break; + } + case '{': { + // Alternation: {a,b,c} + const closing = pattern.indexOf('}', i); + if (closing === -1) { + regexStr += '\\{'; + i++; + } else { + const inner = pattern.slice(i + 1, closing); + const options = inner.split(',').map((o) => o.trim()); + regexStr += `(${options.map(escapeRegExp).join('|')})`; + i = closing + 1; + } + break; + } + case '[': { + // Character class: pass through + const closing = pattern.indexOf(']', i); + if (closing === -1) { + regexStr += '\\['; + i++; + } else { + regexStr += pattern.slice(i, closing + 1); + i = closing + 1; + } + break; + } + case '.': + case '+': + case '^': + case '$': + case '(': + case ')': + case '|': + case '/': { + // Escape regex special chars (but `/` is literal in paths) + regexStr += ch === '/' ? '\\/' : `\\${ch}`; + i++; + break; + } + default: { + regexStr += ch; + i++; + } + } + } + + // Anchor: must match the entire path + return new RegExp(`^${regexStr}$`); + } +} + +// --------------------------------------------------------------------------- +// Helper: escape string for RegExp +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/core/src/scratchpad.ts b/packages/core/src/scratchpad.ts new file mode 100644 index 0000000..059b9b5 --- /dev/null +++ b/packages/core/src/scratchpad.ts @@ -0,0 +1,449 @@ +/** + * Scratchpad — Shared filesystem for cross-Worker communication + * + * Provides a persistent, shared directory (~/.coder/scratchpad/) where + * Worker agents can read and write files without permission approval. + * + * Features: + * - Per-agent directories for private scratch files + * - Shared directory for cross-Worker data exchange + * - Version control via .v suffix + * - File-level locking to prevent concurrent write corruption + * - Atomic writes (tmp → rename, mirroring MemoryStore pattern) + * - Auto-cleanup of files older than 24h + * + * Architecture reference: ARCHITECTURE.md §4.8e + */ + +import { + writeFileSync, + readFileSync, + existsSync, + mkdirSync, + readdirSync, + renameSync, + unlinkSync, + statSync, + rmSync, + copyFileSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_BASE_DIR = join(homedir(), '.coder', 'scratchpad'); +const SHARED_DIR_NAME = 'shared'; +const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours +const LOCK_POLL_INTERVAL_MS = 100; +const LOCK_MAX_WAIT_MS = 10_000; // 10 seconds max lock wait + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface VersionEntry { + version: number; + timestamp: string; +} + +// --------------------------------------------------------------------------- +// Scratchpad +// --------------------------------------------------------------------------- + +export class Scratchpad { + private baseDir: string; + private locks: Map> = new Map(); + + constructor(baseDir?: string) { + this.baseDir = baseDir ?? DEFAULT_BASE_DIR; + this.ensureDir(this.baseDir); + this.ensureDir(join(this.baseDir, SHARED_DIR_NAME)); + } + + // ── File Operations (per-agent) ────────────────────────────────── + + /** + * Write content to a file in the agent's private scratch directory. + * Uses atomic write: writes to .tmp then renames to prevent corruption. + * + * @param agentId - Owning agent identifier + * @param filename - Filename (not path, validated to prevent traversal) + * @param content - Content to write + * @returns Absolute path to the written file + */ + async writeFile(agentId: string, filename: string, content: string): Promise { + this.validateFilename(filename); + + const agentDir = join(this.baseDir, agentId); + this.ensureDir(agentDir); + + const filePath = join(agentDir, filename); + const lockPath = filePath + '.lock'; + + await this.acquireLock(lockPath); + try { + // Version control: save current version before overwriting + if (existsSync(filePath)) { + this.archiveVersion(filePath); + } + + // Atomic write: write to .tmp then rename + const tmpPath = `${filePath}.coder-tmp-${randomUUID()}`; + try { + writeFileSync(tmpPath, content, 'utf-8'); + renameSync(tmpPath, filePath); + } catch (err) { + // Clean up temp file on failure + try { unlinkSync(tmpPath); } catch { /* ignore */ } + throw err; + } + + return filePath; + } finally { + this.releaseLock(lockPath); + } + } + + /** + * Read content from a file in the agent's private scratch directory. + * + * @returns File content as string, or null if the file does not exist. + */ + async readFile(agentId: string, filename: string): Promise { + this.validateFilename(filename); + + const filePath = join(this.baseDir, agentId, filename); + if (!existsSync(filePath)) return null; + + return readFileSync(filePath, 'utf-8'); + } + + /** + * List all files in the agent's private scratch directory. + */ + async listFiles(agentId: string): Promise { + const agentDir = join(this.baseDir, agentId); + if (!existsSync(agentDir)) return []; + + try { + return readdirSync(agentDir, { withFileTypes: true }) + .filter((e) => e.isFile() && !e.name.endsWith('.lock') && !e.name.endsWith('.tmp')) + .map((e) => e.name); + } catch { + return []; + } + } + + // ── Version Control ────────────────────────────────────────────── + + /** + * Get version history for a file. + * Versions are stored as `.v` in the same directory. + */ + async getHistory(agentId: string, filename: string): Promise { + const agentDir = join(this.baseDir, agentId); + if (!existsSync(agentDir)) return []; + + const prefix = filename + '.v'; + const versions: VersionEntry[] = []; + + try { + for (const entry of readdirSync(agentDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.startsWith(prefix)) { + const versionStr = entry.name.slice(prefix.length); + const version = parseInt(versionStr, 10); + if (!isNaN(version)) { + const stat = statSync(join(agentDir, entry.name)); + versions.push({ + version, + timestamp: stat.mtime.toISOString(), + }); + } + } + } + } catch { + // Directory might have been removed + } + + return versions.sort((a, b) => b.version - a.version); + } + + /** + * Read a specific version of a file. + * + * @returns File content, or null if the version does not exist. + */ + async getVersion(agentId: string, filename: string, version: number): Promise { + const versionPath = join(this.baseDir, agentId, `${filename}.v${version}`); + if (!existsSync(versionPath)) return null; + return readFileSync(versionPath, 'utf-8'); + } + + /** + * Archive the current version before overwriting. + * Finds the highest existing version number and saves as v. + */ + private archiveVersion(filePath: string): void { + const dir = dirname(filePath); + const baseName = filePath.split('/').pop()!; + const history = this.syncGetHistory(dir, baseName); + + const nextVersion = history.length > 0 ? history[0]!.version + 1 : 1; + + // Keep at most 10 versions + const versionPath = join(dir, `${baseName}.v${nextVersion}`); + try { + copyFileSync(filePath, versionPath); + } catch { + // If copy fails, skip versioning for this write + } + + // Prune old versions (keep last 10) + if (history.length >= 10) { + const toRemove = history.slice(9); + for (const entry of toRemove) { + try { unlinkSync(join(dir, `${baseName}.v${entry.version}`)); } catch { /* ignore */ } + } + } + } + + /** + * Synchronous variant of getHistory for internal use. + */ + private syncGetHistory(dir: string, filename: string): VersionEntry[] { + const prefix = filename + '.v'; + const versions: VersionEntry[] = []; + + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.startsWith(prefix)) { + const versionStr = entry.name.slice(prefix.length); + const version = parseInt(versionStr, 10); + if (!isNaN(version)) { + const stat = statSync(join(dir, entry.name)); + versions.push({ version, timestamp: stat.mtime.toISOString() }); + } + } + } + } catch { /* ignore */ } + + return versions.sort((a, b) => b.version - a.version); + } + + // ── Lock Mechanism ─────────────────────────────────────────────── + + /** + * Acquire a file-level lock using a .lock marker file. + * Polls every 100ms until the lock is released or timeout reached. + */ + private async acquireLock(lockPath: string): Promise { + const existing = this.locks.get(lockPath); + if (existing) { + await existing; + return this.acquireLock(lockPath); + } + + const lockPromise = new Promise((resolve, reject) => { + const start = Date.now(); + + const tryAcquire = (): void => { + if (Date.now() - start > LOCK_MAX_WAIT_MS) { + reject(new Error(`Lock timeout after ${LOCK_MAX_WAIT_MS}ms: ${lockPath}`)); + return; + } + + try { + // Try to create the lock file exclusively + writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); + resolve(); + } catch { + // Lock exists — poll + setTimeout(tryAcquire, LOCK_POLL_INTERVAL_MS); + } + }; + + tryAcquire(); + }); + + this.locks.set(lockPath, lockPromise); + + try { + await lockPromise; + } finally { + this.locks.delete(lockPath); + } + } + + /** + * Release a file-level lock by removing the .lock marker file. + */ + private releaseLock(lockPath: string): void { + try { + if (existsSync(lockPath)) { + unlinkSync(lockPath); + } + } catch { + // Best-effort cleanup + } + } + + // ── Shared Directory (cross-Worker) ────────────────────────────── + + /** + * Write to the shared directory (accessible by all Workers). + * Uses atomic write for safety. + */ + async writeShared(filename: string, content: string): Promise { + this.validateFilename(filename); + + const sharedDir = join(this.baseDir, SHARED_DIR_NAME); + this.ensureDir(sharedDir); + + const filePath = join(sharedDir, filename); + const tmpPath = `${filePath}.coder-tmp-${randomUUID()}`; + + try { + writeFileSync(tmpPath, content, 'utf-8'); + renameSync(tmpPath, filePath); + } catch (err) { + try { unlinkSync(tmpPath); } catch { /* ignore */ } + throw err; + } + + return filePath; + } + + /** + * Read from the shared directory. + * + * @returns File content, or null if the file does not exist. + */ + async readShared(filename: string): Promise { + this.validateFilename(filename); + + const filePath = join(this.baseDir, SHARED_DIR_NAME, filename); + if (!existsSync(filePath)) return null; + + return readFileSync(filePath, 'utf-8'); + } + + // ── Cleanup ────────────────────────────────────────────────────── + + /** + * Remove files older than maxAgeMs (default: 24 hours). + * Also cleans up stale .lock files. + * + * @returns Number of files removed. + */ + async cleanup(maxAgeMs: number = DEFAULT_MAX_AGE_MS): Promise { + let removed = 0; + const now = Date.now(); + + const cleanDir = (dir: string): void => { + if (!existsSync(dir)) return; + + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + // Recurse into agent directories + cleanDir(fullPath); + // Remove empty directories + try { + const remaining = readdirSync(fullPath); + if (remaining.length === 0) { + rmSync(fullPath, { recursive: true }); + } + } catch { /* ignore */ } + continue; + } + + if (!entry.isFile()) continue; + + try { + const stat = statSync(fullPath); + + // Remove stale lock files (older than 5 minutes) + if (entry.name.endsWith('.lock') && (now - stat.mtimeMs > 5 * 60 * 1000)) { + unlinkSync(fullPath); + removed++; + continue; + } + + // Remove temporary files + if (entry.name.includes('.coder-tmp-') && (now - stat.mtimeMs > 60 * 1000)) { + unlinkSync(fullPath); + removed++; + continue; + } + + // Remove files older than maxAge + if (now - stat.mtimeMs > maxAgeMs) { + unlinkSync(fullPath); + removed++; + } + } catch { + // File might have been removed concurrently + } + } + } catch { + // Directory might have been removed + } + }; + + cleanDir(this.baseDir); + return removed; + } + + // ── Helpers ────────────────────────────────────────────────────── + + private ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + + /** + * Validate filename to prevent path traversal attacks. + */ + private validateFilename(filename: string): void { + if (!filename || typeof filename !== 'string') { + throw new Error('Filename must be a non-empty string'); + } + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + throw new Error(`Invalid filename: "${filename}". Path traversal not allowed.`); + } + if (filename.startsWith('.lock') || filename.endsWith('.lock')) { + throw new Error(`Reserved filename pattern: "${filename}"`); + } + } +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +let _instance: Scratchpad | null = null; + +/** + * Get the process-wide Scratchpad singleton. + * Creates it with the default ~/.coder/scratchpad/ path on first call. + */ +export function getScratchpad(): Scratchpad { + if (!_instance) { + _instance = new Scratchpad(); + } + return _instance; +} + +/** + * Replace the Scratchpad singleton (for testing). + */ +export function setScratchpad(sp: Scratchpad): void { + _instance = sp; +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts new file mode 100644 index 0000000..2bc930e --- /dev/null +++ b/packages/core/src/session.ts @@ -0,0 +1,410 @@ +/** + * SessionManager — Session lifecycle management + * + * Manages session creation, resume, fork, rewind, and persistence. + * Sessions are stored as JSON files in ~/.coder/sessions/. + * + * Session management for persisting agent conversation state. + */ + +import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import type { + Session, + SessionFilter, + SessionSummary, + TokenUsageSummary, +} from '@coder/shared'; +import type { Message } from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SESSIONS_DIR = join(homedir(), '.coder', 'sessions'); + +// --------------------------------------------------------------------------- +// SessionManager +// --------------------------------------------------------------------------- + +export class SessionManager { + private activeSession: Session | null = null; + private sessions: Map = new Map(); + + constructor() { + if (!existsSync(SESSIONS_DIR)) { + mkdirSync(SESSIONS_DIR, { recursive: true }); + } + } + + /** + * Create a new session. + */ + create(options: { + title?: string; + cwd?: string; + model?: string; + provider?: string; + parentSessionId?: string; + baseCommit?: string; + }): Session { + const id = randomUUID(); + const now = new Date(); + const session: Session = { + id, + title: options.title ?? `Session ${id.slice(0, 8)}`, + status: 'active', + messages: [], + turnCount: 0, + totalCost: 0, + createdAt: now, + updatedAt: now, + cwd: options.cwd ?? process.cwd(), + model: options.model ?? 'deepseek-v4-pro', + provider: options.provider ?? 'anthropic', + parentSessionId: options.parentSessionId, + baseCommit: options.baseCommit, + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + totalTokens: 0, + }, + metadata: { + filesModified: [], + toolsUsed: [], + tags: [], + }, + }; + + this.sessions.set(id, session); + this.activeSession = session; + this.saveSession(session); + + return session; + } + + /** + * Get the active session or throw. + */ + getActive(): Session { + if (!this.activeSession) { + throw new Error('No active session. Call create() or resume() first.'); + } + return this.activeSession; + } + + /** + * Get a session by ID. + */ + get(id: string): Session | undefined { + // Check cache first + const cached = this.sessions.get(id); + if (cached) return cached; + + // Try loading from disk + return this.loadSession(id); + } + + /** + * Resume a session from disk. + */ + resume(sessionId: string): Session { + const session = this.loadSession(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.status = 'active'; + session.updatedAt = new Date(); + + this.sessions.set(sessionId, session); + this.activeSession = session; + this.saveSession(session); + + return session; + } + + /** + * Fork a session — create a new session from the parent's messages up to a turn. + */ + fork(options: { sessionId: string; fromTurn?: number; cwd?: string }): Session { + const parent = this.get(options.sessionId); + if (!parent) { + throw new Error(`Parent session not found: ${options.sessionId}`); + } + + const messages = options.fromTurn + ? parent.messages.slice(0, options.fromTurn) + : [...parent.messages]; + + return this.create({ + title: `${parent.title} (fork)`, + cwd: options.cwd ?? parent.cwd, + model: parent.model, + provider: parent.provider, + parentSessionId: parent.id, + baseCommit: parent.baseCommit, + }); + } + + /** + * Rewind a session to a specific turn. + */ + rewind(sessionId: string, toTurn: number): Session { + const session = this.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Calculate which messages to keep + let turnCount = 0; + const keptMessages: Message[] = []; + + for (const msg of session.messages) { + keptMessages.push(msg); + if (msg.role === 'assistant') { + turnCount++; + } + if (turnCount >= toTurn) break; + } + + session.messages = keptMessages; + session.turnCount = toTurn; + session.updatedAt = new Date(); + + this.saveSession(session); + return session; + } + + /** + * Add a message to the active session. + */ + addMessage(message: Message): void { + const session = this.getActive(); + session.messages.push(message); + session.updatedAt = new Date(); + + if (message.role === 'assistant') { + session.turnCount++; + } + + // Periodically save (every 5 messages) + if (session.messages.length % 5 === 0) { + this.saveSession(session); + } + } + + /** + * Update token usage for the active session. + */ + updateUsage(usage: Partial): void { + const session = this.getActive(); + if (usage.inputTokens) session.tokenUsage.inputTokens += usage.inputTokens; + if (usage.outputTokens) session.tokenUsage.outputTokens += usage.outputTokens; + if (usage.cacheCreationInputTokens) session.tokenUsage.cacheCreationInputTokens += usage.cacheCreationInputTokens; + if (usage.cacheReadInputTokens) session.tokenUsage.cacheReadInputTokens += usage.cacheReadInputTokens; + session.tokenUsage.totalTokens = + session.tokenUsage.inputTokens + + session.tokenUsage.outputTokens; + } + + /** + * Add cost to the active session. + */ + addCost(cost: number): void { + const session = this.getActive(); + session.totalCost += cost; + } + + /** + * Add a file to the modified-files list. + */ + trackModifiedFile(filePath: string): void { + const session = this.getActive(); + if (!session.metadata.filesModified.includes(filePath)) { + session.metadata.filesModified.push(filePath); + } + } + + /** + * Track a tool that was used. + */ + trackTool(toolName: string): void { + const session = this.getActive(); + if (!session.metadata.toolsUsed.includes(toolName)) { + session.metadata.toolsUsed.push(toolName); + } + } + + /** + * Complete the active session. + */ + complete(): void { + const session = this.getActive(); + session.status = 'completed'; + session.completedAt = new Date(); + session.updatedAt = new Date(); + this.saveSession(session); + this.activeSession = null; + } + + /** + * Pause the active session. + */ + pause(): void { + const session = this.getActive(); + session.status = 'paused'; + session.updatedAt = new Date(); + this.saveSession(session); + this.activeSession = null; + } + + /** + * Mark the active session as error. + */ + error(): void { + const session = this.getActive(); + session.status = 'error'; + session.updatedAt = new Date(); + this.saveSession(session); + } + + /** + * Save the session to disk. + */ + saveSession(session: Session): void { + const dir = join(SESSIONS_DIR, session.id); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const path = join(dir, 'session.json'); + writeFileSync(path, JSON.stringify(session, null, 2), 'utf-8'); + } + + /** + * List sessions matching a filter. + */ + list(filter?: SessionFilter): SessionSummary[] { + const summaries: SessionSummary[] = []; + + let entries: string[]; + try { + entries = readdirSync(SESSIONS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return []; + } + + for (const id of entries) { + const session = this.loadSession(id); + if (!session) continue; + + // Apply filters + if (filter?.status && session.status !== filter.status) continue; + if (filter?.model && session.model !== filter.model) continue; + if (filter?.provider && session.provider !== filter.provider) continue; + if (filter?.since && new Date(session.createdAt) < filter.since) continue; + + summaries.push({ + id: session.id, + title: session.title, + status: session.status, + turnCount: session.turnCount, + totalCost: session.totalCost, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + model: session.model, + }); + } + + // Sort by most recently updated + summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + if (filter?.limit) { + return summaries.slice(filter.offset ?? 0, (filter.offset ?? 0) + filter.limit); + } + return summaries; + } + + /** + * Continue the most recently updated session. + * + * Finds the latest session by updatedAt and resumes it. + * Throws if no sessions exist on disk. + */ + continueLatest(): Session { + const sessions = this.list({ limit: 1 }); + if (sessions.length === 0) { + throw new Error('No sessions found. Call create() first.'); + } + return this.resume(sessions[0]!.id); + } + + /** + * List all sessions (convenience wrapper around list()). + * + * @param limit — Maximum number of sessions to return (default: 50) + * @returns SessionSummary[] sorted by most recently updated + */ + listSessions(limit?: number): SessionSummary[] { + return this.list({ limit: limit ?? 50 }); + } + + /** + * Delete a session. + */ + delete(sessionId: string): boolean { + const dir = join(SESSIONS_DIR, sessionId); + const sessionPath = join(dir, 'session.json'); + + if (!existsSync(sessionPath)) return false; + + try { + // Delete session files recursively + const files = readdirSync(dir); + for (const file of files) { + unlinkSync(join(dir, file)); + } + // Remove the directory (may have subdirs like tasks/) + try { unlinkSync(dir); } catch { /* not empty, leave subdirs */ } + + this.sessions.delete(sessionId); + if (this.activeSession?.id === sessionId) { + this.activeSession = null; + } + return true; + } catch { + return false; + } + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private loadSession(id: string): Session | undefined { + const path = join(SESSIONS_DIR, id, 'session.json'); + if (!existsSync(path)) return undefined; + + try { + const raw = readFileSync(path, 'utf-8'); + const data = JSON.parse(raw); + + // Convert date strings back to Date objects + return { + ...data, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + completedAt: data.completedAt ? new Date(data.completedAt) : undefined, + }; + } catch { + return undefined; + } + } +} diff --git a/packages/core/src/subagent-bus.ts b/packages/core/src/subagent-bus.ts new file mode 100644 index 0000000..6398458 --- /dev/null +++ b/packages/core/src/subagent-bus.ts @@ -0,0 +1,565 @@ +/** + * subagent-bus.ts — Core-side SubagentBus engine-runner integration + * + * Provides createRunAgentCallback() — a factory that returns a + * RunAgentCallback wired to the Coder Agent runtime (QueryEngine, + * SessionManager, ToolRegistry). + * + * The shared @coder/shared SubagentBus is a pure coordinator. Engine + * creation happens here in @coder/core where QueryEngine lives. + * + * Usage (in CLI layer): + * import { getSubagentBus } from '@coder/shared'; + * import { createRunAgentCallback } from '@coder/core'; + * + * const bus = getSubagentBus(); + * bus.initialize({ + * runAgent: createRunAgentCallback({ cwd, toolRegistry, callModel, model }), + * }); + * + * Architecture reference: ARCHITECTURE.md §4.3 (Sub-Agent System) + */ + +import type { Message, StreamEvent, AssistantMessage } from '@coder/shared'; +import type { + SubagentEntry, + SubagentSpawnConfig, + RunAgentCallback, + ForkSessionConfig, + RunForkAgentCallback, + WorkerConfig, +} from '@coder/shared'; +import { QueryEngine } from './query-engine.js'; +import { ToolRegistry } from './tool-registry.js'; +import { SessionManager } from './session.js'; +import type { HookManager } from './hooks/manager.js'; +import type { CallModelParams } from './query.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CreateRunAgentOptions { + cwd: string; + /** The FULL parent tool registry — callback creates a restricted copy */ + toolRegistry: ToolRegistry; + /** callModel function shared with the parent engine */ + callModel: (params: CallModelParams) => AsyncGenerator; + model?: string; + systemPrompt?: string; + defaultMaxTurns?: number; + /** + * Optional HookManager for lifecycle hook execution. + * When provided, SubagentStart and SubagentStop hooks are fired + * around sub-agent execution. + */ + hookManager?: HookManager; + /** + * Optional WorkerConfig for role-based tool restrictions. + * When provided, allowedTools from the config override the default + * RESTRICTED_TOOL_NAMES exclusion list. + * + * - If `allowedTools` contains `"*"` (Coordinator wildcard), all tools + * except the agent-recursion tools (Agent/AgentMessage/AgentStop/AgentRead) + * are allowed. + * - If `allowedTools` is a specific list, only those tools are registered. + * - If `workerConfig` is not provided, the original hardcoded restriction + * (exclude Agent/AgentMessage/AgentStop/AgentRead) is used as default. + */ + workerConfig?: WorkerConfig; +} + +export interface CreateForkAgentOptions { + cwd: string; + /** The FULL parent tool registry — forked agent inherits unrestricted access */ + toolRegistry: ToolRegistry; + /** callModel function shared with the parent engine */ + callModel: (params: CallModelParams) => AsyncGenerator; + /** Parent session manager — forked agents inherit its messages */ + sessionManager: SessionManager; + model?: string; + systemPrompt?: string; + defaultMaxTurns?: number; + /** Token budget for the forked session (default: 200000) */ + contextBudget?: number; + /** + * Optional HookManager for lifecycle hook execution. + * When provided, SubagentStart and SubagentStop hooks are fired + * around forked agent execution. + */ + hookManager?: HookManager; +} + +// --------------------------------------------------------------------------- +// Tool restrictions +// --------------------------------------------------------------------------- + +/** + * Tool names forbidden in sub-agent contexts. + * Prevents infinite agent-spawning recursion and cross-agent confusion. + */ +const RESTRICTED_TOOL_NAMES = new Set(['Agent', 'AgentMessage', 'AgentStop', 'AgentRead']); + +// --------------------------------------------------------------------------- +// createRunAgentCallback +// --------------------------------------------------------------------------- + +/** + * Create a RunAgentCallback that spawns restricted QueryEngines. + * + * Each sub-agent gets: + * - An isolated SessionManager (forked from parent session lineage) + * - A restricted ToolRegistry (no Agent tools → no infinite recursion) + * - Its own QueryEngine with a configurable maxTurns limit + * + * The callback is passed to SubagentBus.initialize() and invoked + * by SubagentBus.spawn() for each new sub-agent. + */ +export function createRunAgentCallback(options: CreateRunAgentOptions): RunAgentCallback { + const { cwd, toolRegistry, callModel, model, systemPrompt, defaultMaxTurns = 50, workerConfig, hookManager } = options; + + return async function runAgent( + agentId: string, + entry: SubagentEntry, + parentSessionId: string, + spawnConfig: SubagentSpawnConfig, + ): Promise { + entry.status = 'running'; + const startTime = Date.now(); + + // ── SubagentStart hook ──────────────────────────────────────── + if (hookManager) { + const allowedToolNames = workerConfig?.allowedTools ?? ['*']; + hookManager.onSubagentStart( + parentSessionId, + cwd, + spawnConfig.description.slice(0, 80), + spawnConfig.prompt.slice(0, 500), + allowedToolNames, + ).catch(() => { + // Hook errors are non-fatal — never crash the agent + }); + + // ── TaskCreated hook (non-blockable) ───────────────────────── + hookManager.onTaskCreated( + parentSessionId, + cwd, + agentId, + 'subagent', + spawnConfig.prompt.slice(0, 500), + allowedToolNames[0] === '*' ? undefined : allowedToolNames, + ).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + // ── Build restricted tool registry ──────────────────────────── + // + // Priority (per-spawn overrides global): + // 1. spawnConfig.workerConfig.allowedTools with "*" → all tools except agent-recursion tools + // 2. spawnConfig.workerConfig.allowedTools (explicit list) → only those tools + // 3. options.workerConfig.allowedTools with "*" → all tools except agent-recursion tools + // 4. options.workerConfig.allowedTools (explicit list) → only those tools + // 5. No workerConfig → default: exclude Agent/AgentMessage/AgentStop/AgentRead + const restrictedRegistry = new ToolRegistry(); + const effectiveWorkerConfig = spawnConfig.workerConfig ?? workerConfig; + const allowedTools = effectiveWorkerConfig?.allowedTools; + const hasWildcard = allowedTools && allowedTools.length === 1 && allowedTools[0] === '*'; + + for (const toolEntry of toolRegistry.getAll()) { + const toolName = toolEntry.definition.name; + + if (allowedTools && allowedTools.length > 0) { + // WorkerConfig provided: use allow-list approach + if (hasWildcard) { + // Coordinator wildcard: allow everything except agent recursion tools + if (!RESTRICTED_TOOL_NAMES.has(toolName)) { + restrictedRegistry.register(toolEntry.instance); + } + } else { + // Explicit allow-list: only register tools in the list + if (allowedTools.includes(toolName)) { + restrictedRegistry.register(toolEntry.instance); + } + } + } else { + // Default (no workerConfig): exclude agent recursion tools + if (!RESTRICTED_TOOL_NAMES.has(toolName)) { + restrictedRegistry.register(toolEntry.instance); + } + } + } + + // Isolated SessionManager for this sub-agent + const sessionManager = new SessionManager(); + sessionManager.create({ + cwd, + model, + parentSessionId, + title: `subagent: ${spawnConfig.description.slice(0, 50)}`, + }); + + const engine = new QueryEngine({ + cwd, + toolRegistry: restrictedRegistry, + sessionManager, + maxTurns: spawnConfig.maxTurns ?? defaultMaxTurns, + callModel, + model, + customSystemPrompt: systemPrompt, + agentId, // Worker identity for message queue draining + mode: 'worker', + }); + + await engine.init(); + + try { + for await (const event of engine.submitMessage(spawnConfig.prompt)) { + // Collect structured messages + if (event.type === 'message' && event.data) { + const data = event.data as { type?: string; message?: Message }; + + if (data.type === 'assistant' && data.message) { + entry.messages.push(data.message); + entry.turnCount++; + // Also build a human-readable transcript line + entry.transcript.push(messageToLine(data.message)); + } else if (data.type === 'user' && data.message) { + entry.messages.push(data.message); + } + } + + if (event.type === 'error') { + const errData = event.data as { message?: string } | undefined; + entry.error = errData?.message ?? 'Unknown error'; + engine.shutdown(); + // resolveDone is called via settle() — we reach it through the spawn() catch handler + throw new Error(entry.error); + } + + if (event.type === 'done') { + entry.result = extractResultText(entry.messages as Message[]); + engine.shutdown(); + + // ── SubagentStop hook (success) ────────────────────────── + if (hookManager) { + const durationMs = Date.now() - startTime; + const tokenUsage = entry.messages.length; // approximate + hookManager.onSubagentStop( + parentSessionId, + cwd, + spawnConfig.description.slice(0, 80), + true, + entry.result ?? 'Task completed', + tokenUsage, + durationMs, + ).catch(() => { /* non-fatal */ }); + + // ── TaskCompleted hook (non-blockable) ───────────────── + hookManager.onTaskCompleted( + parentSessionId, + cwd, + agentId, + 'completed', + entry.result ?? 'Task completed', + { + tokens: tokenUsage, + toolCalls: entry.turnCount, + durationMs, + }, + ).catch(() => { /* non-fatal */ }); + } + + return; // donePromise resolves → spawn() settles as 'completed' + } + } + } catch (error: unknown) { + entry.error = error instanceof Error ? error.message : String(error); + + // ── SubagentStop hook (failure) ───────────────────────────── + if (hookManager) { + const durationMs = Date.now() - startTime; + hookManager.onSubagentStop( + parentSessionId, + cwd, + spawnConfig.description.slice(0, 80), + false, + entry.error ?? 'Unknown error', + 0, + durationMs, + ).catch(() => { /* non-fatal */ }); + + // ── TaskCompleted hook (non-blockable, failed) ──────────── + hookManager.onTaskCompleted( + parentSessionId, + cwd, + agentId, + 'failed', + entry.error ?? 'Unknown error', + { + tokens: 0, + toolCalls: entry.turnCount, + durationMs, + }, + ).catch(() => { /* non-fatal */ }); + } + + throw error; // re-throw to let spawn()'s .catch() handle settlement + } + }; +} + +// --------------------------------------------------------------------------- +// createForkAgentCallback +// --------------------------------------------------------------------------- + +/** + * Create a RunForkAgentCallback that spawns QueryEngines inheriting + * the parent's full message context. + * + * Each forked agent gets: + * - The FULL parent message history (deep-copied at fork time) + * - The parent's unrestricted ToolRegistry (no tool exclusions) + * - An isolated SessionManager seeded with parent messages + * - Its own QueryEngine with a configurable maxTurns limit + * - A larger context window (200K token budget by default) + * + * The callback is passed to SubagentBus.initialize() and invoked + * by SubagentBus.forkSession() for each new forked agent. + */ +export function createForkAgentCallback(options: CreateForkAgentOptions): RunForkAgentCallback { + const { + cwd, + toolRegistry, + callModel, + sessionManager, + model, + systemPrompt, + defaultMaxTurns = 50, + contextBudget = 200000, + hookManager, + } = options; + + return async function runForkAgent( + agentId: string, + entry: SubagentEntry, + parentSessionId: string, + config: ForkSessionConfig, + ): Promise { + entry.status = 'running'; + const startTime = Date.now(); + + // ── SubagentStart hook ──────────────────────────────────────── + if (hookManager) { + hookManager.onSubagentStart( + parentSessionId, + cwd, + config.description.slice(0, 80), + config.prompt.slice(0, 500), + ['*'], // Fork inherits full tool access — no restrictions + ).catch(() => { + // Hook errors are non-fatal — never crash the agent + }); + + // ── TaskCreated hook (non-blockable) ───────────────────────── + hookManager.onTaskCreated( + parentSessionId, + cwd, + agentId, + 'subagent', + config.prompt.slice(0, 500), + undefined, // Unrestricted tool set for fork + ).catch(() => { + // Non-blockable event: hook failures are silently ignored + }); + } + + // ── Snapshot parent messages at fork time ────────────────────── + // + // We deep-copy the active session's messages at invocation time. + // At this point the parent's tool execution is still in progress + // (the Agent tool hasn't returned yet), so messages[] contains + // the full context up to and including the assistant message + // that called the Agent tool. + let parentMessages: Message[] = []; + try { + const activeSession = sessionManager.getActive(); + parentMessages = structuredClone(activeSession.messages); + } catch { + // If sessionManager is unavailable, fall back to empty context + // The fork will run with only the prompt as context + } + + // ── Create isolated forked session ───────────────────────────── + const forkedSessionManager = new SessionManager(); + const forkedSession = forkedSessionManager.create({ + cwd, + model, + parentSessionId, + title: `fork: ${config.description.slice(0, 50)}`, + }); + + // Seed the forked session with parent message history + for (const msg of parentMessages) { + forkedSessionManager.addMessage(msg); + } + + // ── Create unrestricted engine ───────────────────────────────── + // + // Fork inherits the FULL parent tool registry with NO restrictions. + // Unlike spawn(), we do NOT filter out Agent tools — the forked + // agent operates with the same capabilities as the parent. + const engine = new QueryEngine({ + cwd, + toolRegistry, // Full parent registry — no tool restrictions + sessionManager: forkedSessionManager, + maxTurns: config.maxTurns ?? defaultMaxTurns, + callModel, + model, + customSystemPrompt: systemPrompt, + agentId, // Worker identity for message queue draining + mode: 'worker', + }); + + await engine.init(); + + try { + for await (const event of engine.submitMessage(config.prompt)) { + // Collect structured messages + if (event.type === 'message' && event.data) { + const data = event.data as { type?: string; message?: Message }; + + if (data.type === 'assistant' && data.message) { + entry.messages.push(data.message); + entry.turnCount++; + // Also build a human-readable transcript line + entry.transcript.push(messageToLine(data.message)); + } else if (data.type === 'user' && data.message) { + entry.messages.push(data.message); + } + } + + if (event.type === 'error') { + const errData = event.data as { message?: string } | undefined; + entry.error = errData?.message ?? 'Unknown error'; + engine.shutdown(); + // resolveDone is called via settle() — we reach it through the forkSession() catch handler + throw new Error(entry.error); + } + + if (event.type === 'done') { + entry.result = extractResultText(entry.messages as Message[]); + engine.shutdown(); + + // ── SubagentStop hook (success) ────────────────────────── + if (hookManager) { + const durationMs = Date.now() - startTime; + const tokenUsage = entry.messages.length; // approximate + hookManager.onSubagentStop( + parentSessionId, + cwd, + config.description.slice(0, 80), + true, + entry.result ?? 'Fork task completed', + tokenUsage, + durationMs, + ).catch(() => { /* non-fatal */ }); + + // ── TaskCompleted hook (non-blockable) ───────────────── + hookManager.onTaskCompleted( + parentSessionId, + cwd, + agentId, + 'completed', + entry.result ?? 'Fork task completed', + { + tokens: tokenUsage, + toolCalls: entry.turnCount, + durationMs, + }, + ).catch(() => { /* non-fatal */ }); + } + + return; // donePromise resolves → forkSession() settles as 'completed' + } + } + } catch (error: unknown) { + entry.error = error instanceof Error ? error.message : String(error); + + // ── SubagentStop hook (failure) ───────────────────────────── + if (hookManager) { + const durationMs = Date.now() - startTime; + hookManager.onSubagentStop( + parentSessionId, + cwd, + config.description.slice(0, 80), + false, + entry.error ?? 'Unknown error', + 0, + durationMs, + ).catch(() => { /* non-fatal */ }); + + // ── TaskCompleted hook (non-blockable, failed) ──────────── + hookManager.onTaskCompleted( + parentSessionId, + cwd, + agentId, + 'failed', + entry.error ?? 'Unknown error', + { + tokens: 0, + toolCalls: entry.turnCount, + durationMs, + }, + ).catch(() => { /* non-fatal */ }); + } + + throw error; // re-throw to let forkSession()'s .catch() handle settlement + } + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractResultText(messages: Message[]): string { + for (const msg of [...messages].reverse()) { + if (msg.role !== 'assistant') continue; + + if (typeof msg.content === 'string') { + return msg.content; + } + + if (Array.isArray(msg.content)) { + const textBlocks = msg.content.filter((b) => b.type === 'text'); + if (textBlocks.length > 0) { + return textBlocks.map((b) => b.text ?? '').join('\n'); + } + } + } + return 'Task completed (no text output)'; +} + +function messageToLine(msg: Message): string { + if (typeof msg.content === 'string') { + return `[assistant] ${msg.content.slice(0, 500)}`; + } + if (Array.isArray(msg.content)) { + const parts: string[] = []; + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + parts.push(block.text.slice(0, 300)); + } else if (block.type === 'tool_use') { + parts.push(`🔧 ${block.name}`); + } else if (block.type === 'tool_result') { + const preview = typeof block.content === 'string' + ? block.content.slice(0, 100) + : '[result]'; + parts.push(` ↳ ${preview}`); + } + } + return parts.join('\n'); + } + return '[assistant message]'; +} diff --git a/packages/core/src/system-prompt/assembler.ts b/packages/core/src/system-prompt/assembler.ts new file mode 100644 index 0000000..28c013e --- /dev/null +++ b/packages/core/src/system-prompt/assembler.ts @@ -0,0 +1,286 @@ +/** + * SystemPromptAssembler — Dynamic system prompt assembly + * + * Dynamically assembles the system prompt from multiple sources: + * 1. Base harness instructions (tool usage, message format, permissions) + * 2. CODER.md / CLAUDE.md / .coder/ project context (discovered from filesystem) + * 3. Rules directory (path-scoped .coder/rules/*.md — Phase 5) + * 4. MEMORY.md memories (from FTS5 store) + * 5. Active skills summary (progressive disclosure) + * 6. MCP context (server-provided resources and prompts) + * 7. User custom prompt + * + * Dynamic system prompt assembly with multi-source context injection. + * Reference: ARCHITECTURE.md §4.2 + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import type { IMemoryStore } from '../memory/store.js'; +import { getCoordinatorPrompt } from './coordinator.js'; +import type { RuleManager } from '../rules-manager.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PromptPart { + source: string; + content: string; +} + +export interface AssemblyContext { + /** Working directory — used to discover CODER.md / CLAUDE.md */ + cwd: string; + /** User's query for memory selection */ + query?: string; + /** Custom system prompt from CLI flags */ + customPrompt?: string; + /** Append prompt (added after main prompt) */ + appendPrompt?: string; + /** Selected memories */ + memories?: string[]; + /** Active skill summaries */ + skillSummaries?: string[]; + /** MCP context strings */ + mcpContext?: string; + /** Permission mode for instruction selection */ + permissionMode?: 'plan' | 'ask' | 'auto'; + /** Agent role for mode-specific prompt injection */ + agentRole?: 'coordinator' | 'worker' | 'default'; + /** + * Optional RuleManager for loading path-scoped rules from .coder/rules/*.md. + * When provided and activeFilePath is set, matching rules are injected + * into the system prompt. (Phase 5 — matches Claude Code's rules directory) + */ + ruleManager?: RuleManager; + /** + * Currently active file path (e.g. file being read/written/edited). + * Used by RuleManager.getMatchingRules() to select path-scoped rules. + */ + activeFilePath?: string; + /** + * Currently active tool name (e.g. 'Read', 'Write', 'Edit'). + * Passed to RuleManager for context-aware rule matching. + */ + currentToolName?: string; + /** + * Optional MemoryStore for auto-selecting relevant memories. + * When provided and `memories` is not explicitly set, the assembler + * calls `memoryStore.selectRelevant(messages)` to populate memories. + */ + memoryStore?: IMemoryStore; + /** + * Recent conversation messages for memory relevance matching. + * Required when `memoryStore` is provided without explicit `memories`. + */ + messages?: Array<{ role: string; content: string | unknown }>; +} + +export interface SystemPrompt { + prompt: string; + parts: PromptPart[]; + estimatedTokens: number; +} + +// --------------------------------------------------------------------------- +// Base Instructions +// --------------------------------------------------------------------------- + +const BASE_INSTRUCTIONS = `You are Coder Agent, an Open-Source Coding Agent built by the open-source community. + +## Your Capabilities +- Read, write, and edit files +- Execute bash commands +- Search code with glob patterns and regex +- Manage git repositories +- Create and track tasks +- Delegate work to sub-agents + +## Tool Usage +- Always use absolute file paths +- Read files before editing them +- Prefer editing existing files over creating new ones +- Each tool call must include a clear description + +## Response Style +- Be concise and direct +- Explain your reasoning before making changes +- Ask clarifying questions when needed +- Report errors honestly + +## Safety +- Never execute dangerous commands without confirmation +- Never skip git hooks +- Never force push to main/master`; + +const PLAN_MODE_INSTRUCTIONS = ` +## Plan Mode +You are in PLAN MODE. You can ONLY: +- Read files (Read tool) +- Search code (Glob, Grep tools) +- View git status (Git tool) +- Ask the user questions + +You CANNOT: +- Write or edit files +- Execute bash commands +- Make git commits + +Focus on understanding the codebase and designing a plan.`; + + +const WORKER_INSTRUCTIONS = ` +## Worker Mode +You are operating as a **Worker Agent**. Your responsibilities: +1. Execute sub-tasks assigned by the Coordinator +2. Write, edit, and test code +3. Report results back to the Coordinator + +**Focus**: Complete your assigned sub-task efficiently. Do not attempt to decompose further or delegate.`; + +// --------------------------------------------------------------------------- +// SystemPromptAssembler +// --------------------------------------------------------------------------- + +export class SystemPromptAssembler { + /** + * Assemble the complete system prompt from all sources. + */ + async assemble(ctx: AssemblyContext): Promise { + const parts: PromptPart[] = []; + + // 1. Base harness instructions + let baseInstructions = BASE_INSTRUCTIONS; + if (ctx.permissionMode === 'plan') { + baseInstructions += PLAN_MODE_INSTRUCTIONS; + } + parts.push({ source: 'base', content: baseInstructions }); + + // 2. Discover and load CODER.md / CLAUDE.md files + const contextFiles = this.discoverContextFiles(ctx.cwd); + for (const file of contextFiles) { + parts.push({ source: file.source, content: file.content }); + } + + // 2.5. Path-scoped rules from .coder/rules/*.md (Phase 5) + // Injected between project context and memory so rules are highly visible. + if (ctx.ruleManager) { + const matchingRules = ctx.ruleManager.getMatchingRules({ + cwd: ctx.cwd, + currentToolName: ctx.currentToolName, + currentFilePath: ctx.activeFilePath, + }); + if (matchingRules.length > 0) { + const rulesBlock = ctx.ruleManager.formatRulesForPrompt(matchingRules); + parts.push({ source: 'rules', content: rulesBlock }); + } + } + + // 3. Memory injection + // Auto-select relevant memories from MemoryStore if provided and + // explicit memories are not already set by the caller. + let memories = ctx.memories; + if (!memories && ctx.memoryStore && ctx.messages && ctx.messages.length > 0) { + const results = ctx.memoryStore.selectRelevant(ctx.messages, 5); + memories = results.map((r) => r.memory.content); + } + if (memories && memories.length > 0) { + const memoryBlock = this.formatMemories(memories); + parts.push({ source: 'memory', content: memoryBlock }); + } + + // 4. Active skills summary (progressive disclosure) + if (ctx.skillSummaries && ctx.skillSummaries.length > 0) { + const skillBlock = this.formatSkillSummaries(ctx.skillSummaries); + parts.push({ source: 'skills', content: skillBlock }); + } + + // 4.5. Role-specific instructions (Coordinator / Worker mode) + if (ctx.agentRole === 'coordinator') { + parts.push({ source: 'role', content: getCoordinatorPrompt() }); + } else if (ctx.agentRole === 'worker') { + parts.push({ source: 'role', content: WORKER_INSTRUCTIONS }); + } + + // 5. MCP context + if (ctx.mcpContext) { + parts.push({ source: 'mcp', content: ctx.mcpContext }); + } + + // 6. User custom prompt (the append prompt) + if (ctx.appendPrompt) { + parts.push({ source: 'append', content: ctx.appendPrompt }); + } + + // 7. User custom prompt (replaces base if set) + if (ctx.customPrompt) { + // Custom prompt replaces all parts + parts.length = 0; + parts.push({ source: 'user', content: ctx.customPrompt }); + } + + const prompt = parts.map((p) => p.content).join('\n\n'); + const estimatedTokens = Math.ceil(prompt.length / 3.5); // rough estimate: ~3.5 chars per token + + return { prompt, parts, estimatedTokens }; + } + + /** + * Discover CODER.md / CLAUDE.md / CODEBUDDY.md files from cwd up to root. + */ + discoverContextFiles(cwd: string): PromptPart[] { + const files: PromptPart[] = []; + const searchNames = ['CODER.md', 'CLAUDE.md', 'CODEBUDDY.md', '.coderrules', '.cursorrules']; + + let dir = cwd; + while (dir !== dirname(dir)) { + for (const name of searchNames) { + const filePath = join(dir, name); + if (existsSync(filePath)) { + try { + const content = readFileSync(filePath, 'utf-8'); + files.unshift({ source: filePath, content }); + } catch { + // Permission error — skip + } + } + } + dir = dirname(dir); + } + + // Also check home directory + for (const name of searchNames) { + const homePath = join(homedir(), name); + if (existsSync(homePath)) { + try { + const content = readFileSync(homePath, 'utf-8'); + files.push({ source: homePath, content }); + } catch { + // skip + } + } + } + + return files; + } + + /** + * Format memories for system prompt injection. + */ + private formatMemories(memories: string[]): string { + const header = '## Relevant Memories'; + const items = memories.map((m) => `- ${m}`).join('\n'); + return `${header}\n${items}`; + } + + /** + * Format skill summaries for progressive disclosure. + */ + private formatSkillSummaries(skills: string[]): string { + const header = '## Available Skills'; + const items = skills.map((s) => `- ${s}`).join('\n'); + return `${header}\n${items}`; + } +} diff --git a/packages/core/src/system-prompt/coordinator.ts b/packages/core/src/system-prompt/coordinator.ts new file mode 100644 index 0000000..370d7c4 --- /dev/null +++ b/packages/core/src/system-prompt/coordinator.ts @@ -0,0 +1,104 @@ +/** + * coordinator.ts — Coordinator system prompt template + * + * Injects Coordinator-specific instructions into the system prompt when + * agentRole === "coordinator". Describes the task splitting protocol, + * Worker communication format, and result synthesis strategy. + * + * Architecture reference: ARCHITECTURE.md §4.3 (Sub-Agent System) + */ + +/** + * Get the Coordinator-specific system prompt section. + * + * This is injected by SystemPromptAssembler when `agentRole === "coordinator"`. + * The prompt tells the LLM: + * 1. Its role as Coordinator (not executor) + * 2. The 4-phase task splitting protocol + * 3. How to use AgentSpawn/AgentMessage/AgentStop for Worker management + * 4. How to synthesize Worker results into a coherent final answer + */ +export function getCoordinatorPrompt(): string { + return `## Coordinator Mode + +You are in COORDINATOR MODE. You are the orchestrator of an Agent Team — your job is to +**split tasks, delegate to Workers, and synthesize results**. You are NOT the executor. + +### Worker Roles at Your Disposal + +| Role | Tools | Purpose | +|------|-------|---------| +| **explore** | Read, Glob, Grep, WebFetch, WebSearch | Code discovery and research | +| **builder** | Read, Glob, Grep, Write, Edit, Bash | Code authoring and modification | +| **reviewer** | Read, Glob, Grep, Bash | Code review, linting, testing | + +### Task Splitting Protocol + +Follow this 4-phase workflow for every user request: + +1. **Research (→ explore Workers)** + - Identify what you need to understand before acting + - Spawn 1-3 explore Workers in parallel to investigate different aspects + - Each Worker gets a self-contained prompt with clear discovery goals + +2. **Synthesis (You)** + - Read Worker transcripts via AgentRead + - Understand findings, identify gaps, resolve conflicts + - Design the implementation approach based on what was discovered + +3. **Implement (→ builder Workers)** + - Split the implementation into independent work units + - Spawn builder Workers for each unit (max 3 concurrently) + - Provide each Worker with precise file paths and change specifications + +4. **Verify (→ reviewer Workers)** + - Spawn a reviewer Worker to check the changes + - Read the reviewer's findings via AgentRead + - If issues found, spawn follow-up builder Workers to fix them + +### Worker Communication + +**Spawning a Worker:** +Use the Agent tool with these parameters: +- \`description\`: Short (3-5 words) task label +- \`prompt\`: Self-contained task description with all context the Worker needs +- \`subagent_type\`: Use "general-purpose" for all Workers +- \`worker_role\`: One of "explore", "builder", or "reviewer" +- \`max_turns\`: Limit turns to prevent runaway Workers (default: 50) + +**Reading Worker Output:** +Use AgentRead with the Worker's agentId to read its transcript. +- Start with \`limit: 50\` and paginate as needed +- Focus on the final assistant messages for conclusions +- Look for error messages if the Worker didn't complete successfully + +**Messaging a Running Worker:** +Use AgentMessage to send follow-up instructions without restarting the Worker. +- Only message Workers that are still running (check AgentRead status) +- Keep messages focused and actionable +- Don't spam — one clear instruction is better than many small ones + +**Stopping a Worker:** +Use AgentStop to abort a Worker that is: +- Running too long (exceeded expected turn count) +- Producing irrelevant results +- No longer needed + +### Result Synthesis Strategy + +When all Workers complete (or sufficient results are available): + +1. Read each Worker's full transcript via AgentRead +2. Extract key findings — what did each Worker discover/produce? +3. Identify conflicts or gaps between Worker outputs +4. Produce a unified response that synthesizes everything +5. If critical information is missing, spawn a targeted follow-up Worker + +### Key Rules + +- **Never do the work yourself** — always delegate execution to Workers +- **Workers run in parallel** — spawn independent Workers simultaneously +- **Each Worker is isolated** — they cannot see each other's output; you must bridge +- **Respect turn limits** — Workers have finite turns; give them focused tasks +- **Drain notifications arrive automatically** — you'll see \`\` XML when Workers complete`; +} diff --git a/packages/core/src/tool-registry.ts b/packages/core/src/tool-registry.ts new file mode 100644 index 0000000..efd6981 --- /dev/null +++ b/packages/core/src/tool-registry.ts @@ -0,0 +1,139 @@ +/** + * tool-registry.ts — ToolRegistry: register, discover, getDefinitions + * + * Manages tool registration and discovery. Tools are registered by their + * class instance and can be queried by name, category, or risk level. + * + * Tool registry for managing agent tools. + * Architecture reference: ARCHITECTURE.md §4.6 + */ + +import { + BaseTool, + RiskLevel, + type ToolDefinition, + type ToolContext, + type ToolExecutionResult, +} from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Tool Registry +// --------------------------------------------------------------------------- + +export interface ToolEntry { + definition: ToolDefinition; + instance: BaseTool; +} + +export type ToolCategory = + | 'file_system' + | 'search' + | 'shell' + | 'version_control' + | 'task_management' + | 'browser' + | 'agent' + | 'system'; + +export const CATEGORY_MAP: Record = { + Read: 'file_system', + Write: 'file_system', + Edit: 'file_system', + Glob: 'file_system', + Grep: 'search', + Bash: 'shell', + Git: 'version_control', + TodoWrite: 'task_management', + TaskCreate: 'task_management', + TaskUpdate: 'task_management', + TaskDescribe: 'task_management', + WebFetch: 'browser', + WebSearch: 'browser', + AgentSpawn: 'agent', + AgentMessage: 'agent', + AgentStop: 'agent', + Skill: 'system', + MCPClient: 'system', + TeamCreate: 'task_management', + TeamDelete: 'task_management', + NotebookEdit: 'file_system', + LSP: 'system', + ExitPlanMode: 'system', +}; + +export class ToolRegistry { + private tools: Map = new Map(); + + register(tool: BaseTool): void { + const def = tool.definition; + if (this.tools.has(def.name)) { + throw new Error(`Tool already registered: ${def.name}`); + } + this.tools.set(def.name, { definition: def, instance: tool }); + } + + registerAll(tools: BaseTool[]): void { + for (const tool of tools) { + this.register(tool); + } + } + + unregister(name: string): boolean { + return this.tools.delete(name); + } + + get(name: string): ToolEntry | undefined { + return this.tools.get(name); + } + + getAll(): ToolEntry[] { + return Array.from(this.tools.values()); + } + + getDefinitions(): ToolDefinition[] { + return this.getAll().map((entry) => entry.definition); + } + + getDefinitionsForMode(mode: 'plan' | 'ask' | 'auto'): ToolDefinition[] { + return this.getDefinitions().filter((def) => { + if (mode === 'plan') { + return def.riskLevel === RiskLevel.SAFE; + } + return true; + }); + } + + getByCategory(category: ToolCategory): ToolEntry[] { + return this.getAll().filter( + (entry) => CATEGORY_MAP[entry.definition.name] === category, + ); + } + + getByRiskLevel(level: RiskLevel): ToolEntry[] { + return this.getAll().filter((entry) => entry.definition.riskLevel === level); + } + + async execute( + name: string, + input: unknown, + ctx: ToolContext, + ): Promise { + const entry = this.tools.get(name); + if (!entry) { + return { + success: false, + error: `Unknown tool: ${name}`, + durationMs: 0, + }; + } + return entry.instance.run(input, ctx); + } + + get size(): number { + return this.tools.size; + } + + [Symbol.iterator](): IterableIterator { + return this.tools.values(); + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..9d4c190 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..8212091 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "@coder/mcp", + "version": "0.1.0", + "description": "Coder Agent MCP Client & Server — JSON-RPC 2.0 over stdio", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist" + }, + "dependencies": { + "@coder/shared": "workspace:*" + } +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts new file mode 100644 index 0000000..05aa4d7 --- /dev/null +++ b/packages/mcp/src/client.ts @@ -0,0 +1,794 @@ +/** + * client.ts — MCPClient: JSON-RPC 2.0 over stdio for MCP Server communication + * + * Spawns an MCP-compatible server process (e.g., a language server, database + * tool, or external agent) and communicates via line-delimited JSON on + * stdin/stdout following the Model Context Protocol. + * + * Features: + * - hand-written JSON-RPC 2.0 (no @modelcontextprotocol/sdk dependency) + * - 30s per-request timeout with automatic retry (1 attempt) + * - MCP tool → BaseTool wrapping for ToolRegistry integration + * - Graceful disconnect with child process cleanup + * + * Architecture reference: ARCHITECTURE.md §4.12 (MCP Integration) + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { + BaseTool, + RiskLevel, + type ToolContext, + type ToolDefinition, + type ValidationResult, +} from '@coder/shared'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_TIMEOUT_MS = 30_000; // 30s per request +const MAX_RETRIES = 1; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** MCP Tool descriptor returned by tools/list */ +export interface MCPTool { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +/** MCP Tool call result */ +export interface MCPToolResult { + content: Array<{ + type: 'text' | 'image' | 'resource'; + text?: string; + data?: string; + mimeType?: string; + }>; + isError?: boolean; +} + +/** JSON-RPC 2.0 request envelope */ +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number | string; + method: string; + params?: unknown; +} + +/** JSON-RPC 2.0 success response */ +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | string; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +/** MCP Initialize result */ +interface MCPInitializeResult { + protocolVersion: string; + capabilities: { + tools?: Record; + resources?: Record; + prompts?: Record; + }; + serverInfo?: { + name: string; + version: string; + }; +} + +/** MCP Client configuration */ +export interface MCPClientConfig { + /** Shell command to start the MCP server */ + serverCommand: string; + /** Arguments passed to the server command */ + serverArgs?: string[]; + /** Environment variables for the server process */ + env?: Record; + /** Per-request timeout in ms (default: 30_000) */ + timeoutMs?: number; + /** Label for this MCP server (used in tool namespacing) */ + label?: string; + /** OAuth 2.0 configuration for remote MCP servers */ + oauth?: MCPOAuthConfig; +} + +/** OAuth 2.0 configuration for MCP client authentication */ +export interface MCPOAuthConfig { + /** OAuth authorization endpoint URL */ + authorizationUrl: string; + /** OAuth token endpoint URL */ + tokenUrl: string; + /** OAuth client ID */ + clientId: string; + /** OAuth client secret */ + clientSecret: string; + /** OAuth scopes (space-separated) */ + scope?: string; + /** Redirect URI (default: http://localhost:0/callback) */ + redirectUri?: string; +} + +/** OAuth 2.0 token response */ +export interface OAuthToken { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + tokenType: string; + scope?: string; +} + +// --------------------------------------------------------------------------- +// MCPClient +// --------------------------------------------------------------------------- + +export class MCPClient { + private config: Required> & { + env?: Record; + oauth?: MCPOAuthConfig; + }; + private process: ChildProcess | null = null; + private requestId = 0; + private pending = new Map< + number | string, + { resolve: (value: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout } + >(); + private buffer = ''; + private initialized = false; + private serverCapabilities: MCPInitializeResult | null = null; + private cachedTools: MCPTool[] | null = null; + private _oauthToken: OAuthToken | null = null; + + constructor(config: MCPClientConfig) { + this.config = { + serverCommand: config.serverCommand, + serverArgs: config.serverArgs ?? [], + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + label: config.label ?? config.serverCommand, + env: config.env, + oauth: config.oauth, + }; + } + + // ------------------------------------------------------------------- + // Public: Connection lifecycle + // ------------------------------------------------------------------- + + /** + * Start the MCP server and perform the initialize handshake. + * + * Spawns the server process, sets up stdin/stdout JSON-RPC transport, + * and sends the MCP `initialize` request. + */ + async connect(): Promise { + if (this.process) { + throw new Error('MCPClient is already connected'); + } + + const env = { + ...process.env, + ...this.config.env, + }; + + this.process = spawn(this.config.serverCommand, this.config.serverArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env, + // Prevent the child from attaching to the parent TTY + detached: false, + }); + + // Handle unexpected process exit + this.process.on('exit', (code, signal) => { + this.rejectAllPending( + new Error(`MCP server process exited (code=${code}, signal=${signal})`), + ); + this.initialized = false; + }); + + this.process.on('error', (err) => { + this.rejectAllPending( + new Error(`MCP server process error: ${err.message}`), + ); + this.initialized = false; + }); + + // Read JSON-RPC responses from stdout + if (this.process.stdout) { + this.process.stdout.on('data', (chunk: Buffer) => { + this.handleData(chunk.toString('utf-8')); + }); + } + + // Log stderr for debugging + if (this.process.stderr) { + this.process.stderr.on('data', (chunk: Buffer) => { + // Stderr is informational — not used for protocol + // Could be logged to a debug channel + }); + } + + // Send initialize request + try { + const result = await this.sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: { + tools: {}, + }, + clientInfo: { + name: 'coder-agent', + version: '0.1.0', + }, + }) as MCPInitializeResult; + + this.serverCapabilities = result; + this.initialized = true; + + // Send initialized notification (no response expected) + this.sendNotification('notifications/initialized', {}); + } catch (err) { + this.disconnect(); + throw new Error( + `MCP initialize failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** + * Disconnect from the MCP server and clean up the child process. + */ + disconnect(): void { + this.initialized = false; + this.serverCapabilities = null; + this.cachedTools = null; + this._oauthToken = null; + this.rejectAllPending(new Error('MCPClient disconnected')); + + if (this.process) { + try { + this.process.kill('SIGTERM'); + } catch { + // Process may already be dead + } + + // Force kill after 3s if still alive + setTimeout(() => { + if (this.process && this.process.exitCode === null) { + try { + this.process.kill('SIGKILL'); + } catch { + // Already dead + } + } + }, 3000).unref(); + + this.process = null; + } + } + + /** + * Check if the client is connected and initialized. + */ + get connected(): boolean { + return this.initialized && this.process !== null && this.process.exitCode === null; + } + + /** + * Get server capabilities from the initialize handshake. + */ + get capabilities(): MCPInitializeResult | null { + return this.serverCapabilities; + } + + // ------------------------------------------------------------------- + // Public: OAuth 2.0 authentication + // ------------------------------------------------------------------- + + /** + * Whether the client has a valid OAuth token. + */ + get isAuthenticated(): boolean { + return this._oauthToken !== null && !this._isTokenExpired(); + } + + /** + * Get the current OAuth access token, refreshing if needed. + * + * Returns null if OAuth is not configured or the token cannot be refreshed. + */ + async getAccessToken(): Promise { + if (!this._oauthToken) return null; + + if (this._isTokenExpired()) { + try { + await this._refreshAccessToken(); + } catch { + this._oauthToken = null; + return null; + } + } + + return this._oauthToken.accessToken; + } + + /** + * Exchange an authorization code for an OAuth 2.0 access token. + * + * This completes the second step of the OAuth 2.0 authorization code flow. + * The user must first visit the authorization URL (config.oauth.authorizationUrl) + * and grant consent, after which the authorization server redirects to the + * redirect URI with a `code` parameter. Pass that code here. + * + * @param authorizationCode — The `code` query parameter from the OAuth callback + */ + async authenticate(authorizationCode: string): Promise { + const oauth = this.config.oauth; + if (!oauth) { + throw new Error('OAuth is not configured for this MCP client'); + } + + const params = new URLSearchParams(); + params.set('grant_type', 'authorization_code'); + params.set('code', authorizationCode); + params.set('client_id', oauth.clientId); + params.set('client_secret', oauth.clientSecret); + params.set('redirect_uri', oauth.redirectUri ?? 'http://localhost:0/callback'); + + let response: globalThis.Response; + try { + response = await fetch(oauth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: params.toString(), + }); + } catch (err) { + throw new Error( + `OAuth token request failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `OAuth token endpoint returned ${response.status}: ${body.slice(0, 200)}`, + ); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type: string; + scope?: string; + }; + + this._oauthToken = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + tokenType: data.token_type ?? 'Bearer', + scope: data.scope, + }; + } + + /** + * Get the OAuth authorization URL that the user should visit. + * + * Builds the full authorization URL with all required parameters so the + * user can be redirected to the OAuth provider's consent screen. + */ + getAuthorizationUrl(): string { + const oauth = this.config.oauth; + if (!oauth) { + throw new Error('OAuth is not configured for this MCP client'); + } + + const params = new URLSearchParams(); + params.set('response_type', 'code'); + params.set('client_id', oauth.clientId); + params.set('redirect_uri', oauth.redirectUri ?? 'http://localhost:0/callback'); + if (oauth.scope) { + params.set('scope', oauth.scope); + } + params.set('state', randomUUID()); + + return `${oauth.authorizationUrl}?${params.toString()}`; + } + + // ------------------------------------------------------------------- + // Public: Tool discovery + // ------------------------------------------------------------------- + + /** + * List all tools available on the connected MCP server. + * + * Results are cached after the first call. Use refreshTools() to + * invalidate the cache and re-query. + */ + async listTools(): Promise { + if (this.cachedTools) return this.cachedTools; + + this.ensureConnected(); + + const result = await this.sendRequest('tools/list', {}); + const tools = (result as { tools: MCPTool[] }).tools ?? []; + + this.cachedTools = tools; + return tools; + } + + /** + * Refresh the tool list by clearing the cache and re-querying. + */ + async refreshTools(): Promise { + this.cachedTools = null; + return this.listTools(); + } + + /** + * Convert MCP tools to Coder ToolDefinition format. + */ + async getToolDefinitions(): Promise { + const tools = await this.listTools(); + + return tools.map((tool) => ({ + name: `mcp__${this.config.label}__${tool.name}`, + description: `[MCP: ${this.config.label}] ${tool.description}`, + inputSchema: tool.inputSchema as ToolDefinition['inputSchema'], + riskLevel: RiskLevel.MUTATION, + })); + } + + /** + * Wrap all MCP tools as BaseTool instances suitable for ToolRegistry. + * + * Each tool is namespaced as `mcp__