Просмотр исходного кода

Merge branch 'main' into fix/4482-duplicate-tool-use-ids

Kevin van Dijk 1 день назад
Родитель
Сommit
d2b0342edc
100 измененных файлов с 13118 добавлено и 61 удалено
  1. 1 1
      .changeset/config.json
  2. 5 0
      .changeset/fifty-baboons-shine.md
  3. 5 0
      .changeset/filter-internal-verification-tags.md
  4. 5 0
      .changeset/fix-context-flickering.md
  5. 5 0
      .changeset/fix-file-deletion-auto-approve.md
  6. 5 0
      .changeset/fix-model-no-tools-used.md
  7. 0 5
      .changeset/fix-openai-codex-type.md
  8. 5 0
      .changeset/fix-settings-search-ui.md
  9. 5 0
      .changeset/flat-eels-press.md
  10. 5 0
      .changeset/gentle-laws-allow.md
  11. 0 5
      .changeset/glm-47-temperature.md
  12. 5 0
      .changeset/kill-command-fix.md
  13. 5 0
      .changeset/lucky-lands-tickle.md
  14. 5 0
      .changeset/old-planes-start.md
  15. 0 6
      .changeset/openai-responses-config.md
  16. 5 0
      .changeset/persist-deleted-api-costs.md
  17. 5 0
      .changeset/strange-files-unite.md
  18. 5 0
      .changeset/thin-forks-draw.md
  19. 5 0
      .changeset/weak-seas-add.md
  20. 5 0
      .changeset/young-emus-obey.md
  21. 1 1
      .github/copilot-instructions.md
  22. 2 2
      .github/workflows/markdoc-build.yml
  23. 6 0
      .gitignore
  24. 1 1
      .kilocode/skills/translation/SKILL.md
  25. 1 1
      .kilocode/workflows/add-missing-translations.md
  26. 22 22
      AGENTS.md
  27. 260 0
      CHANGELOG.md
  28. 135 11
      CONTRIBUTING.md
  29. 22 6
      README.md
  30. 116 0
      apps/cli/CHANGELOG.md
  31. 262 0
      apps/cli/README.md
  32. 355 0
      apps/cli/docs/AGENT_LOOP.md
  33. 4 0
      apps/cli/eslint.config.mjs
  34. 305 0
      apps/cli/install.sh
  35. 48 0
      apps/cli/package.json
  36. 714 0
      apps/cli/scripts/release.sh
  37. 126 0
      apps/cli/src/__tests__/index.test.ts
  38. 858 0
      apps/cli/src/agent/__tests__/extension-client.test.ts
  39. 596 0
      apps/cli/src/agent/__tests__/extension-host.test.ts
  40. 466 0
      apps/cli/src/agent/agent-state.ts
  41. 681 0
      apps/cli/src/agent/ask-dispatcher.ts
  42. 372 0
      apps/cli/src/agent/events.ts
  43. 580 0
      apps/cli/src/agent/extension-client.ts
  44. 542 0
      apps/cli/src/agent/extension-host.ts
  45. 1 0
      apps/cli/src/agent/index.ts
  46. 479 0
      apps/cli/src/agent/message-processor.ts
  47. 414 0
      apps/cli/src/agent/output-manager.ts
  48. 297 0
      apps/cli/src/agent/prompt-manager.ts
  49. 415 0
      apps/cli/src/agent/state-store.ts
  50. 3 0
      apps/cli/src/commands/auth/index.ts
  51. 186 0
      apps/cli/src/commands/auth/login.ts
  52. 27 0
      apps/cli/src/commands/auth/logout.ts
  53. 97 0
      apps/cli/src/commands/auth/status.ts
  54. 1 0
      apps/cli/src/commands/cli/index.ts
  55. 219 0
      apps/cli/src/commands/cli/run.ts
  56. 2 0
      apps/cli/src/commands/index.ts
  57. 65 0
      apps/cli/src/index.ts
  58. 1 0
      apps/cli/src/lib/auth/index.ts
  59. 61 0
      apps/cli/src/lib/auth/token.ts
  60. 30 0
      apps/cli/src/lib/sdk/client.ts
  61. 2 0
      apps/cli/src/lib/sdk/index.ts
  62. 31 0
      apps/cli/src/lib/sdk/types.ts
  63. 152 0
      apps/cli/src/lib/storage/__tests__/credentials.test.ts
  64. 240 0
      apps/cli/src/lib/storage/__tests__/history.test.ts
  65. 22 0
      apps/cli/src/lib/storage/config-dir.ts
  66. 72 0
      apps/cli/src/lib/storage/credentials.ts
  67. 10 0
      apps/cli/src/lib/storage/ephemeral.ts
  68. 109 0
      apps/cli/src/lib/storage/history.ts
  69. 4 0
      apps/cli/src/lib/storage/index.ts
  70. 40 0
      apps/cli/src/lib/storage/settings.ts
  71. 102 0
      apps/cli/src/lib/utils/__tests__/commands.test.ts
  72. 54 0
      apps/cli/src/lib/utils/__tests__/extension.test.ts
  73. 128 0
      apps/cli/src/lib/utils/__tests__/input.test.ts
  74. 68 0
      apps/cli/src/lib/utils/__tests__/path.test.ts
  75. 34 0
      apps/cli/src/lib/utils/__tests__/provider.test.ts
  76. 62 0
      apps/cli/src/lib/utils/commands.ts
  77. 67 0
      apps/cli/src/lib/utils/context-window.ts
  78. 33 0
      apps/cli/src/lib/utils/extension.ts
  79. 122 0
      apps/cli/src/lib/utils/input.ts
  80. 33 0
      apps/cli/src/lib/utils/onboarding.ts
  81. 35 0
      apps/cli/src/lib/utils/path.ts
  82. 61 0
      apps/cli/src/lib/utils/provider.ts
  83. 6 0
      apps/cli/src/lib/utils/version.ts
  84. 26 0
      apps/cli/src/types/constants.ts
  85. 2 0
      apps/cli/src/types/index.ts
  86. 49 0
      apps/cli/src/types/types.ts
  87. 621 0
      apps/cli/src/ui/App.tsx
  88. 279 0
      apps/cli/src/ui/__tests__/store.test.ts
  89. 252 0
      apps/cli/src/ui/components/ChatHistoryItem.tsx
  90. 75 0
      apps/cli/src/ui/components/Header.tsx
  91. 14 0
      apps/cli/src/ui/components/HorizontalLine.tsx
  92. 174 0
      apps/cli/src/ui/components/Icon.tsx
  93. 41 0
      apps/cli/src/ui/components/LoadingText.tsx
  94. 68 0
      apps/cli/src/ui/components/MetricsDisplay.tsx
  95. 493 0
      apps/cli/src/ui/components/MultilineTextInput.tsx
  96. 61 0
      apps/cli/src/ui/components/ProgressBar.tsx
  97. 398 0
      apps/cli/src/ui/components/ScrollArea.tsx
  98. 26 0
      apps/cli/src/ui/components/ScrollIndicator.tsx
  99. 56 0
      apps/cli/src/ui/components/ToastDisplay.tsx
  100. 142 0
      apps/cli/src/ui/components/TodoChangeDisplay.tsx

+ 1 - 1
.changeset/config.json

@@ -7,5 +7,5 @@
 	"access": "restricted",
 	"baseBranch": "main",
 	"updateInternalDependencies": "patch",
-	"ignore": []
+	"ignore": ["@roo-code/cli"]
 }

+ 5 - 0
.changeset/fifty-baboons-shine.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Updates some visual bugs in Agent Behaviour settings page

+ 5 - 0
.changeset/filter-internal-verification-tags.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Filter internal verification tags from assistant messages before displaying to users

+ 5 - 0
.changeset/fix-context-flickering.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+fix: prevent context token indicator flickering

+ 5 - 0
.changeset/fix-file-deletion-auto-approve.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix file deletion auto-approve checkbox not being clickable

+ 5 - 0
.changeset/fix-model-no-tools-used.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix recurring MODEL_NO_TOOLS_USED error loop by detecting text-based tool call hallucinations and instructing the model to use the native API.

+ 0 - 5
.changeset/fix-openai-codex-type.md

@@ -1,5 +0,0 @@
----
-"@kilocode/core-schemas": patch
----
-
-Add missing openai-codex provider type definition

+ 5 - 0
.changeset/fix-settings-search-ui.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fixed UI issues in Settings search bar: clipping of results and layout shift when expanding

+ 5 - 0
.changeset/flat-eels-press.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": minor
+---
+
+Added Voyage AI embedder support

+ 5 - 0
.changeset/gentle-laws-allow.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+feat: support preserving reasoning content in OpenAI format conversion

+ 0 - 5
.changeset/glm-47-temperature.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Set default temperature to 1.0 for Cerebras zai-glm-4.7 model

+ 5 - 0
.changeset/kill-command-fix.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix: "Kill Command" button now reliably terminates processes on all platforms, including those running in the background.

+ 5 - 0
.changeset/lucky-lands-tickle.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+fix(nano-gpt): Add native reasoning field extraction

+ 5 - 0
.changeset/old-planes-start.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Support custom embed dimensions for Ollama provider

+ 0 - 6
.changeset/openai-responses-config.md

@@ -1,6 +0,0 @@
----
-"@kilocode/cli": patch
-"@kilocode/core-schemas": patch
----
-
-Add openai-responses provider support in CLI config validation.

+ 5 - 0
.changeset/persist-deleted-api-costs.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix: Persist total API cost after message deletion

+ 5 - 0
.changeset/strange-files-unite.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Enhance Anthropic extended thinking compatibility

+ 5 - 0
.changeset/thin-forks-draw.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Fix tool use failure for providers returning numeric tool call IDs (e.g. MiniMax) by coercing ID to string in the shared stream parser

+ 5 - 0
.changeset/weak-seas-add.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+fix: improve symlink handling in skills directory

+ 5 - 0
.changeset/young-emus-obey.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Implement better formatting for low cost values

+ 1 - 1
.github/copilot-instructions.md

@@ -65,5 +65,5 @@ If you're creating a completely new file that doesn't exist in Roo, add this com
 - all the following folders are kilocode-specific and need no marking with comments:
     - jetbrains/
     - cli/
-    - src/services/ghost/
+    - src/services/autocomplete/
     - src/services/continuedev/

+ 2 - 2
.github/workflows/docusaurus-build.yml → .github/workflows/markdoc-build.yml

@@ -1,4 +1,4 @@
-name: Docusaurus Build Check
+name: Markdoc Build Check
 
 env:
     NODE_VERSION: 20.20.0
@@ -15,7 +15,7 @@ permissions:
 
 jobs:
     build:
-        name: Build Docusaurus Site
+        name: Build Markdoc Site
         runs-on: ubuntu-latest
 
         steps:

+ 6 - 0
.gitignore

@@ -53,6 +53,10 @@ logs
 # Nix / Direnv
 .direnv
 
+# Mise
+mise.toml
+mise.local.toml
+
 # Exclude Conport Directory (MCP server)
 context_portal/
 
@@ -71,3 +75,5 @@ qdrant_storage/
 .secrets
 # Architect plans
 ./plans/
+
+roo-cli-*.tar.gz*

+ 1 - 1
.kilocode/skills/translation/SKILL.md

@@ -13,7 +13,7 @@ For the translation workflow, use the `/add-missing-translations` command or see
 
 # 1. SUPPORTED LANGUAGES AND LOCATION
 
-- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW
+- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, sk, th, tr, uk, vi, zh-CN, zh-TW
 - The VSCode extension has two main areas that require localization:
     - Core Extension: src/i18n/locales/, src/package.nls.json, src/package.nls.<locale>.json (extension backend)
     - WebView UI: webview-ui/src/i18n/locales/ (user interface)

+ 1 - 1
.kilocode/workflows/add-missing-translations.md

@@ -13,7 +13,7 @@ For each language that is missing translations:
 
 When translating, follow these key rules:
 
-1. **Supported Languages**: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW
+1. **Supported Languages**: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, sk, th, tr, uk, vi, zh-CN, zh-TW
 2. **Voice**: Always use informal speech (e.g., "du" not "Sie" in German)
 3. **Technical Terms**: Don't translate "token", "API", "prompt" and domain-specific technical terms
 4. **Placeholders**: Keep `{{variable}}` placeholders exactly as in the English source

+ 22 - 22
AGENTS.md

@@ -45,34 +45,34 @@ Agents are forked processes configured via the `AGENT_CONFIG` environment variab
 import { fork } from "child_process"
 
 const agent = fork(require.resolve("@kilocode/agent-runtime/process"), [], {
-  env: {
-    AGENT_CONFIG: JSON.stringify({
-      workspace: "/path/to/project",
-      providerSettings: { apiProvider: "anthropic", apiKey: "..." },
-      mode: "code",
-      autoApprove: false,
-    }),
-  },
-  stdio: ["pipe", "pipe", "pipe", "ipc"],
+	env: {
+		AGENT_CONFIG: JSON.stringify({
+			workspace: "/path/to/project",
+			providerSettings: { apiProvider: "anthropic", apiKey: "..." },
+			mode: "code",
+			autoApprove: false,
+		}),
+	},
+	stdio: ["pipe", "pipe", "pipe", "ipc"],
 })
 
 agent.on("message", (msg) => {
-  if (msg.type === "ready") {
-    agent.send({ type: "sendMessage", payload: { type: "newTask", text: "Fix the bug" } })
-  }
+	if (msg.type === "ready") {
+		agent.send({ type: "sendMessage", payload: { type: "newTask", text: "Fix the bug" } })
+	}
 })
 ```
 
 ### Message Protocol
 
-| Direction | Type | Description |
-|-----------|------|-------------|
-| Parent → Agent | `sendMessage` | Send user message to extension |
+| Direction      | Type           | Description                    |
+| -------------- | -------------- | ------------------------------ |
+| Parent → Agent | `sendMessage`  | Send user message to extension |
 | Parent → Agent | `injectConfig` | Update extension configuration |
-| Parent → Agent | `shutdown` | Gracefully terminate agent |
-| Agent → Parent | `ready` | Agent initialized |
-| Agent → Parent | `message` | Extension message |
-| Agent → Parent | `stateChange` | State updated |
+| Parent → Agent | `shutdown`     | Gracefully terminate agent     |
+| Agent → Parent | `ready`        | Agent initialized              |
+| Agent → Parent | `message`      | Extension message              |
+| Agent → Parent | `stateChange`  | State updated                  |
 
 ### Detecting Agent Context
 
@@ -80,7 +80,7 @@ Code running in agent processes can check for the `AGENT_CONFIG` environment var
 
 ```typescript
 if (process.env.AGENT_CONFIG) {
-  // Running as spawned agent - disable worker pools, etc.
+	// Running as spawned agent - disable worker pools, etc.
 }
 ```
 
@@ -93,7 +93,7 @@ The Agent Manager follows a **read-shared, write-isolated** pattern:
 
 ```typescript
 fork(agentRuntimePath, [], {
-  env: { AGENT_CONFIG: JSON.stringify({ workspace, providerSettings, mode, sessionId }) }
+	env: { AGENT_CONFIG: JSON.stringify({ workspace, providerSettings, mode, sessionId }) },
 })
 ```
 
@@ -180,7 +180,7 @@ Code in these directories is Kilo Code-specific and doesn't need markers:
 - `jetbrains/` - JetBrains plugin
 - `agent-manager/` directories
 - Any path containing `kilocode` in filename or directory name
-- `src/services/ghost/` - Ghost service
+- `src/services/autocomplete/ - Autocomplete service
 
 ### When markers ARE needed
 

+ 260 - 0
CHANGELOG.md

@@ -1,5 +1,265 @@
 # kilo-code
 
+## 5.7.0
+
+### Minor Changes
+
+- [#4768](https://github.com/Kilo-Org/kilocode/pull/4768) [`626f18a`](https://github.com/Kilo-Org/kilocode/commit/626f18a91fde30b9a303708b3c42897aa91bcd98) Thanks [@hsp-sz](https://github.com/hsp-sz)! - feat: add Zenmux provider
+
+### Patch Changes
+
+- [#4714](https://github.com/Kilo-Org/kilocode/pull/4714) [`69b36b5`](https://github.com/Kilo-Org/kilocode/commit/69b36b537d5a5f6817dbc60567623ffcdfac9acf) Thanks [@otterDeveloper](https://github.com/otterDeveloper)! - feat (fireworks.ai): add minimax 2.1, glm 4.7, updated other models
+
+- [#4926](https://github.com/Kilo-Org/kilocode/pull/4926) [`079dffd`](https://github.com/Kilo-Org/kilocode/commit/079dffd17e2612ac22f5aaf9430f18363088c4cd) Thanks [@YuriNachos](https://github.com/YuriNachos)! - fix: disable zsh history expansion (#4926)
+
+- [#5162](https://github.com/Kilo-Org/kilocode/pull/5162) [`cad3c68`](https://github.com/Kilo-Org/kilocode/commit/cad3c688dc2493ef7a750fc47c60db9507da4a9d) Thanks [@hdcodedev](https://github.com/hdcodedev)! - Fix attached images being lost when editing a message with checkpoint
+
+    When editing a message that has a checkpoint, the images attached to the edited message were not being included in the `editMessageConfirm` webview message. This caused images to be silently dropped and not sent to the backend.
+
+    The fix adds the `images` field to the message payload in both the checkpoint and non-checkpoint edit confirmation paths.
+
+    Fixes #3489
+
+- [#5139](https://github.com/Kilo-Org/kilocode/pull/5139) [`932c692`](https://github.com/Kilo-Org/kilocode/commit/932c692b2f35e7bd4ffa59f74640ab27e984ef2c) Thanks [@naga-k](https://github.com/naga-k)! - Prevent sending thinkingLevel to unsupporting Gemini models
+
+- [#4945](https://github.com/Kilo-Org/kilocode/pull/4945) [`43bc7ac`](https://github.com/Kilo-Org/kilocode/commit/43bc7acc815d81ba0f775c9e2d7965336c0feb50) Thanks [@CaiDingxian](https://github.com/CaiDingxian)! - feat: add chars count to ListFilesTool
+
+- [#5805](https://github.com/Kilo-Org/kilocode/pull/5805) [`918f767`](https://github.com/Kilo-Org/kilocode/commit/918f767136cb073a71767d76708da40e25c03f06) Thanks [@Neonsy](https://github.com/Neonsy)! - Add support for GLM 5 and set Z.ai default to `glm-5` and align Z.ai API line model selection in VS Code and webview settings
+
+## 5.6.0
+
+### Minor Changes
+
+- [#5040](https://github.com/Kilo-Org/kilocode/pull/5040) [`abe3047`](https://github.com/Kilo-Org/kilocode/commit/abe30473feffb84e885fc8abd5595033fe8b5431) Thanks [@luthraansh](https://github.com/luthraansh)! - Added Corethink as a new AI provider
+
+### Patch Changes
+
+- [#5749](https://github.com/Kilo-Org/kilocode/pull/5749) [`b2fa0a9`](https://github.com/Kilo-Org/kilocode/commit/b2fa0a9b239a396feee39d14eb60eafb088c0ed4) Thanks [@skaldamramra](https://github.com/skaldamramra)! - Add Slovak (sk) language translation for Kilo Code extension and UI
+
+- [#5681](https://github.com/Kilo-Org/kilocode/pull/5681) [`b5ef707`](https://github.com/Kilo-Org/kilocode/commit/b5ef70717068a791da5c3b3068eadb8e189ff484) Thanks [@Drilmo](https://github.com/Drilmo)! - fix(agent-manager): Fix double scrollbar in mode selector dropdowns
+
+- [#5722](https://github.com/Kilo-Org/kilocode/pull/5722) [`f7cf4fd`](https://github.com/Kilo-Org/kilocode/commit/f7cf4fd5002b697f1e41e744b01f096e57666acf) Thanks [@Neonsy](https://github.com/Neonsy)! - Improve Chutes Kimi reliability by preventing terminated-stream retry loops and handling tool/reasoning chunks more safely.
+
+- [#5747](https://github.com/Kilo-Org/kilocode/pull/5747) [`95be119`](https://github.com/Kilo-Org/kilocode/commit/95be1193449184869e49d44b7fe9f09e1620b3ce) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Fix JetBrains build failure by adding missing vsix dependency for build pipeline
+
+- [#5733](https://github.com/Kilo-Org/kilocode/pull/5733) [`1b5c4f4`](https://github.com/Kilo-Org/kilocode/commit/1b5c4f4fab28f03b81a9bdf3cd789b1425108765) Thanks [@krisztian-gajdar](https://github.com/krisztian-gajdar)! - Show loading spinner immediately when opening review scope dialog while scope information is being computed, improving perceived performance for repositories with many changes
+
+- [#5699](https://github.com/Kilo-Org/kilocode/pull/5699) [`e560e47`](https://github.com/Kilo-Org/kilocode/commit/e560e47e39f605f78a6d18fdbfc0dd680ceb5557) Thanks [@Patel230](https://github.com/Patel230)! - Fix unreadable text and poor contrast issues in Agent Manager
+
+- [#5722](https://github.com/Kilo-Org/kilocode/pull/5722) [`a834092`](https://github.com/Kilo-Org/kilocode/commit/a8340925c72e9ee0494e1bffd47dbc1aaddc1c8e) Thanks [@Neonsy](https://github.com/Neonsy)! - Fixed Moonshot Kimi tool-calling and thinking-mode behavior for `kimi-k2.5` and `kimi-for-coding`.
+
+- [#4749](https://github.com/Kilo-Org/kilocode/pull/4749) [`ed70dad`](https://github.com/Kilo-Org/kilocode/commit/ed70dad320a80160dc793bf34f52b87d995285ff) Thanks [@lgrgic](https://github.com/lgrgic)! - Fix 'Delete' toggle button in Auto Approve settings
+
+- [#5756](https://github.com/Kilo-Org/kilocode/pull/5756) [`5d9d4d1`](https://github.com/Kilo-Org/kilocode/commit/5d9d4d1c4a6236fccf7082ea9e8d83d95bbd207a) Thanks [@bernaferrari](https://github.com/bernaferrari)! - Remove duplicate "Kilo Code Marketplace" title in toolbar (thanks @bernaferrari!)
+
+- [#3807](https://github.com/Kilo-Org/kilocode/pull/3807) [`e37717e`](https://github.com/Kilo-Org/kilocode/commit/e37717ee2fad8efb53bea92752dd9ea25f79bbed) Thanks [@davidraedev](https://github.com/davidraedev)! - Hook embedding timeout into settings for ollama
+
+## 5.5.0
+
+### Minor Changes
+
+- [#4890](https://github.com/Kilo-Org/kilocode/pull/4890) [`535e3d1`](https://github.com/Kilo-Org/kilocode/commit/535e3d1751255487b4a0217fbae6e7b357b85a56) Thanks [@Drilmo](https://github.com/Drilmo)! - feat(agent-manager): add YOLO mode toggle and session rename
+
+    **New Features:**
+
+    - Add YOLO mode toggle button in new agent form to enable/disable auto-approval of tools
+    - Add YOLO mode indicator (⚡) in session header and sidebar for sessions running in YOLO mode
+    - Add inline session rename - click on session title to edit
+
+    **Technical Details:**
+
+    - `yoloMode` maps to `autoApprove` config in agent-runtime
+    - Added translations for all 22 supported locales
+
+### Patch Changes
+
+- [#5744](https://github.com/Kilo-Org/kilocode/pull/5744) [`870cdd5`](https://github.com/Kilo-Org/kilocode/commit/870cdd57e7b096caca536ca0aa0da393a68eb730) Thanks [@fstanis](https://github.com/fstanis)! - Fix Opus 4.6 model name
+
+- [#5767](https://github.com/Kilo-Org/kilocode/pull/5767) [`57daae1`](https://github.com/Kilo-Org/kilocode/commit/57daae1c3765bd1c37ee5791cb465edc7bd9a861) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Update Discord link in docs footer to use kilo.ai/discord
+
+- [#5758](https://github.com/Kilo-Org/kilocode/pull/5758) [`25f0043`](https://github.com/Kilo-Org/kilocode/commit/25f0043f66248cb12c1c353c9cd9935a0d2d9d60) Thanks [@markijbema](https://github.com/markijbema)! - Minor improvement of auto-execute commands with input redirection
+
+## 5.4.1
+
+### Patch Changes
+
+- [#5695](https://github.com/Kilo-Org/kilocode/pull/5695) [`8097ad6`](https://github.com/Kilo-Org/kilocode/commit/8097ad63b455dca2224f2811af69a0333a43fd79) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Add support for GPT 5.3 codex in OpenAI Codex provider
+
+- [#5584](https://github.com/Kilo-Org/kilocode/pull/5584) [`bd34af4`](https://github.com/Kilo-Org/kilocode/commit/bd34af4170ec3146f1c9c8ca8d8df28502b4b1fa) Thanks [@Neonsy](https://github.com/Neonsy)! - Add a favorited-task checkbox to batch delete in task history.
+
+- [#4770](https://github.com/Kilo-Org/kilocode/pull/4770) [`abaf633`](https://github.com/Kilo-Org/kilocode/commit/abaf6334f22d14496e38151c329887346525f090) Thanks [@JustinReyes28](https://github.com/JustinReyes28)! - feat: Add new "devstral-2512" Mistral model configuration
+
+## 5.4.0
+
+### Minor Changes
+
+- [#4096](https://github.com/Kilo-Org/kilocode/pull/4096) [`4eb0646`](https://github.com/Kilo-Org/kilocode/commit/4eb06462f78ab7446b319e1736fa837e86e3f1df) Thanks [@OlivierBarbier](https://github.com/OlivierBarbier)! - Fix: Importing a configuration file blocks the configuration of provider parameters #2349
+
+### Patch Changes
+
+- [#5686](https://github.com/Kilo-Org/kilocode/pull/5686) [`e6c26b7`](https://github.com/Kilo-Org/kilocode/commit/e6c26b7e8e468a565017fb05958cd4814d69daa1) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add Claude Opus 4.6 model with adaptive thinking support
+
+- [#4021](https://github.com/Kilo-Org/kilocode/pull/4021) [`b8a6c4e`](https://github.com/Kilo-Org/kilocode/commit/b8a6c4e6b4eab9397efbbaa04202f92816e5afd4) Thanks [@In-line](https://github.com/In-line)! - Add React Compiler integration to improve UI responsiveness
+
+## 5.3.0
+
+### Minor Changes
+
+- [#5649](https://github.com/Kilo-Org/kilocode/pull/5649) [`6fbb740`](https://github.com/Kilo-Org/kilocode/commit/6fbb74084f4090d42ad583dd6ce62c2d3f7826f2) Thanks [@iscekic](https://github.com/iscekic)! - send x-kilocode-mode header
+
+- [#5531](https://github.com/Kilo-Org/kilocode/pull/5531) [`66dbaf2`](https://github.com/Kilo-Org/kilocode/commit/66dbaf2dac3f0d1163b7a9409805d32a9a80af1c) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Add new welcome screen for improved onboarding
+
+### Patch Changes
+
+- [#5582](https://github.com/Kilo-Org/kilocode/pull/5582) [`dc669ab`](https://github.com/Kilo-Org/kilocode/commit/dc669ab484a3d015cea1cadb57271b58a23ef796) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Use brand-colored Kilo Code icons throughout the extension for better visibility
+
+- [#5616](https://github.com/Kilo-Org/kilocode/pull/5616) [`9e139f5`](https://github.com/Kilo-Org/kilocode/commit/9e139f50bc52913fa7e42d3ba4c9090263a14f0b) Thanks [@EloiRamos](https://github.com/EloiRamos)! - fix(ui): prevent TypeError when trimming input during model switching
+
+- [#2792](https://github.com/Kilo-Org/kilocode/pull/2792) [`907fb53`](https://github.com/Kilo-Org/kilocode/commit/907fb53aca1f70b1e3e2f91fbb3bcbdc6b514a48) Thanks [@Honyii](https://github.com/Honyii)! - Added CONTRIBUTING.md file for onboarding new contributors
+
+- [#5638](https://github.com/Kilo-Org/kilocode/pull/5638) [`a5b9106`](https://github.com/Kilo-Org/kilocode/commit/a5b9106e6cebc1a63c1ef5fa507cfaab65aa8ebc) Thanks [@Drilmo](https://github.com/Drilmo)! - fix(agent-manager): sync messages when panel is reopened
+
+    Fixed a bug where closing and reopening the Agent Manager panel would show "Waiting for agent response..." instead of the conversation messages.
+
+- [#5644](https://github.com/Kilo-Org/kilocode/pull/5644) [`e3f353f`](https://github.com/Kilo-Org/kilocode/commit/e3f353f596288b9b8e60b00fa88e60f179160c9a) Thanks [@bernaferrari](https://github.com/bernaferrari)! - Fix contrast on "ideas" intro screen
+
+- [#5583](https://github.com/Kilo-Org/kilocode/pull/5583) [`a23c936`](https://github.com/Kilo-Org/kilocode/commit/a23c9361a5a15cf7bd59efd9c8ea9987e2ec82cc) Thanks [@crazyrabbit0](https://github.com/crazyrabbit0)! - Fix double scroll bar in ModelSelector and KiloProfileSelector by increasing max-height.
+
+- [#5567](https://github.com/Kilo-Org/kilocode/pull/5567) [`9729ab2`](https://github.com/Kilo-Org/kilocode/commit/9729ab2c808a69fadbb8c095e5a626fa75e42859) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Updated chat UI theme to use muted, theme-aware colors for Checkpoint, Thinking, and user message styling
+
+- [#5577](https://github.com/Kilo-Org/kilocode/pull/5577) [`a57f9ac`](https://github.com/Kilo-Org/kilocode/commit/a57f9acb2c07b0888fcfa566c2d345879f890941) Thanks [@Patel230](https://github.com/Patel230)! - fix: allow Ollama models without tool support for autocomplete
+
+- [#5628](https://github.com/Kilo-Org/kilocode/pull/5628) [`84c6db2`](https://github.com/Kilo-Org/kilocode/commit/84c6db2ff906b6d18625dc0de21a77a0e573f4ac) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Prevent chat auto-scroll from jumping while you read older messages.
+
+- [#5214](https://github.com/Kilo-Org/kilocode/pull/5214) [`28a46d1`](https://github.com/Kilo-Org/kilocode/commit/28a46d17fe91f13ec0687bb6834b31e2ec454687) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Add GLM-4.7 Flash model to recommended models list for Z.ai provider
+
+- [#5662](https://github.com/Kilo-Org/kilocode/pull/5662) [`228745b`](https://github.com/Kilo-Org/kilocode/commit/228745b4159cd28b7a8fb8d1db1b89e9beb49539) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Add improved support for Kimi 2.5 reasoning through AI SDK
+
+## 5.2.2
+
+### Patch Changes
+
+- [#5497](https://github.com/Kilo-Org/kilocode/pull/5497) [`95f9214`](https://github.com/Kilo-Org/kilocode/commit/95f92143d254741e6e0628f43ad90a3464fa7a09) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Show sign in prompt when trying paid model when not logged in
+
+- [#5529](https://github.com/Kilo-Org/kilocode/pull/5529) [`1fe7b92`](https://github.com/Kilo-Org/kilocode/commit/1fe7b929e1c218614b9ae71270b304ab47dbf894) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Streamline getting started view: move logo to top, reduce suggestions to 2, remove footer hint text
+
+## 5.2.1
+
+### Patch Changes
+
+- [#5501](https://github.com/Kilo-Org/kilocode/pull/5501) [`cecefc1`](https://github.com/Kilo-Org/kilocode/commit/cecefc1dd660100631eecf8517f2c0c918f6cdb3) Thanks [@Neonsy](https://github.com/Neonsy)! - Adding Kimi K2.5
+
+## 5.2.0
+
+### Minor Changes
+
+- [#5477](https://github.com/Kilo-Org/kilocode/pull/5477) [`59a792e`](https://github.com/Kilo-Org/kilocode/commit/59a792eeb461497fe2968ca17e2858389c55894a) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Improve idea box during onboarding experience
+
+### Patch Changes
+
+- [#5503](https://github.com/Kilo-Org/kilocode/pull/5503) [`e53f086`](https://github.com/Kilo-Org/kilocode/commit/e53f0865d32296cb5e4db5f853466f5fa7671371) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Fix mode selection after anonymous usage
+
+- [#5426](https://github.com/Kilo-Org/kilocode/pull/5426) [`56d086b`](https://github.com/Kilo-Org/kilocode/commit/56d086b4853abfeebff6b1afb6c8d0431c232542) Thanks [@lambertjosh](https://github.com/lambertjosh)! - OpenAI Codex: Add ChatGPT subscription usage limits dashboard
+
+- [#4947](https://github.com/Kilo-Org/kilocode/pull/4947) [`53080fd`](https://github.com/Kilo-Org/kilocode/commit/53080fddfc62a171ebae09fe38629aec8b0e6098) Thanks [@CaiDingxian](https://github.com/CaiDingxian)! - feat(moonshot): add new Kimi models and coding API endpoint
+
+- [#5451](https://github.com/Kilo-Org/kilocode/pull/5451) [`af25644`](https://github.com/Kilo-Org/kilocode/commit/af25644f8482bd1a10e6645ed3061421ac23045e) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Updated welcome screen model names in all translations
+
+## 5.1.0
+
+### Minor Changes
+
+- [`8140071`](https://github.com/Kilo-Org/kilocode/commit/8140071cf0235906d06e14034372af5941b0b9cc) Thanks [@markijbema](https://github.com/markijbema)! - New users can now start using Kilo Code immediately without any configuration - a default Kilo Code Gateway profile with a free model is automatically set up on first launch
+
+- [#5288](https://github.com/Kilo-Org/kilocode/pull/5288) [`016ea49`](https://github.com/Kilo-Org/kilocode/commit/016ea49a3a875a8e60c846b314a7040852701262) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Remove Gemini CLI provider support.
+
+### Patch Changes
+
+- [#5420](https://github.com/Kilo-Org/kilocode/pull/5420) [`ebcfca8`](https://github.com/Kilo-Org/kilocode/commit/ebcfca8ea1dd3aad87e3a2598370208a1daaddc6) Thanks [@pedroheyerdahl](https://github.com/pedroheyerdahl)! - Improved Portuguese (Brazil) translation
+
+## 5.0.0
+
+### Major Changes
+
+- [#5400](https://github.com/Kilo-Org/kilocode/pull/5400) [`5a49128`](https://github.com/Kilo-Org/kilocode/commit/5a49128a570f1725b705b2da7b19486649e526ed) Thanks [@Sureshkumars](https://github.com/Sureshkumars)! - Add Local review mode
+
+### Minor Changes
+
+- [#5234](https://github.com/Kilo-Org/kilocode/pull/5234) [`796e188`](https://github.com/Kilo-Org/kilocode/commit/796e188f6213f8093e3e6cadd5b019d55993f948) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Include changes from Roo Code v3.39.0-v3.41.2
+
+    - Add button to open markdown in VSCode preview for easier reading of formatted content (PR #10773 by @brunobergher)
+    - Fix: Add openai-codex to providers that don't require an API key (PR #10786 by @roomote)
+    - Fix: Detect Gemini models with space-separated names for proper thought signature injection in LiteLLM (PR #10787 by @daniel-lxs)
+    - Feat: Aggregate subtask costs in parent task (#5376 by @hannesrudolph, PR #10757 by @taltas)
+    - Fix: Prevent duplicate tool_use IDs causing API 400 errors (PR #10760 by @daniel-lxs)
+    - Fix: Handle missing tool identity in OpenAI Native streams (PR #10719 by @hannesrudolph)
+    - Fix: Truncate call_id to 64 chars for OpenAI Responses API (PR #10763 by @daniel-lxs)
+    - Fix: Gemini thought signature validation errors (PR #10694 by @daniel-lxs)
+    - Fix: Filter out empty text blocks from user messages for Gemini compatibility (PR #10728 by @daniel-lxs)
+    - Fix: Flatten top-level anyOf/oneOf/allOf in MCP tool schemas (PR #10726 by @daniel-lxs)
+    - Fix: Filter Ollama models without native tool support (PR #10735 by @daniel-lxs)
+    - Feat: Add settings tab titles to search index (PR #10761 by @roomote)
+    - Fix: Clear terminal output buffers to prevent memory leaks that could cause gray screens and performance degradation (#10666, PR #7666 by @hannesrudolph)
+    - Fix: Inject dummy thought signatures on ALL tool calls for Gemini models, resolving issues with Gemini tool call handling through LiteLLM (PR #10743 by @daniel-lxs)
+    - Fix: Add allowedFunctionNames support for Gemini to prevent mode switch errors (#10711 by @hannesrudolph, PR #10708 by @hannesrudolph)
+    - Add settings search functionality to quickly find and navigate to specific settings (PR #10619 by @mrubens)
+    - Improve settings search UI with better styling and usability (PR #10633 by @brunobergher)
+    - Display edit_file errors in UI after consecutive failures for better debugging feedback (PR #10581 by @daniel-lxs)
+    - Improve error display styling and visibility in chat messages (PR #10692 by @brunobergher)
+    - Improve stop button visibility and streamline error handling (PR #10696 by @brunobergher)
+    - Fix: Omit parallel_tool_calls when not explicitly enabled to prevent API errors (#10553 by @Idlebrand, PR #10671 by @daniel-lxs)
+    - Fix: Encode hyphens in MCP tool names before sanitization (#10642 by @pdecat, PR #10644 by @pdecat)
+    - Fix: Correct Gemini 3 thought signature injection format via OpenRouter (PR #10640 by @daniel-lxs)
+    - Fix: Sanitize tool_use IDs to match API validation pattern (PR #10649 by @daniel-lxs)
+    - Fix: Use placeholder for empty tool result content to fix Gemini API validation (PR #10672 by @daniel-lxs)
+    - Fix: Return empty string from getReadablePath when path is empty (PR #10638 by @daniel-lxs)
+    - Optimize message block cloning in presentAssistantMessage for better performance (PR #10616 by @ArchimedesCrypto)
+    - Improve ExtensionHost code organization and cleanup (PR #10600 by @cte)
+    - Fix: Ensure all tools have consistent strict mode values for Cerebras compatibility (#10334 by @brianboysen51, PR #10589 by @app/roomote)
+    - Fix: Remove convertToSimpleMessages to restore tool calling for OpenAI-compatible providers (PR #10575 by @daniel-lxs)
+    - Fix: Make edit_file matching more resilient to prevent false negatives (PR #10585 by @hannesrudolph)
+    - Fix: Order text parts before tool calls in assistant messages for vscode-lm (PR #10573 by @daniel-lxs)
+    - Fix: Ensure assistant message content is never undefined for Gemini compatibility (PR #10559 by @daniel-lxs)
+    - Fix: Merge approval feedback into tool result instead of pushing duplicate messages (PR #10519 by @daniel-lxs)
+    - Fix: Round-trip Gemini thought signatures for tool calls (PR #10590 by @hannesrudolph)
+    - Feature: Improve error messaging for stream termination errors from provider (PR #10548 by @daniel-lxs)
+    - Feature: Add debug setting to settings page for easier troubleshooting (PR #10580 by @hannesrudolph)
+    - Chore: Disable edit_file tool for Gemini/Vertex providers (PR #10594 by @hannesrudolph)
+    - Chore: Stop overriding tool allow/deny lists for Gemini (PR #10592 by @hannesrudolph)
+    - Fix: Stabilize file paths during native tool call streaming to prevent path corruption (PR #10555 by @daniel-lxs)
+    - Fix: Disable Gemini thought signature persistence to prevent corrupted signature errors (PR #10554 by @daniel-lxs)
+    - Fix: Change minItems from 2 to 1 for Anthropic API compatibility (PR #10551 by @daniel-lxs)
+    - Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph)
+    - Add support for image file @mentions (PR #10189 by @hannesrudolph)
+    - Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph)
+    - Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote)
+    - Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote)
+    - Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras)
+    - Tweak the style of follow up suggestion modes (PR #9260 by @mrubens)
+    - Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote)
+    - Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote)
+    - Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs)
+    - Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs)
+    - Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs)
+    - Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote)
+    - Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote)
+    - Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote)
+    - Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote) @objectiveSee)
+
+### Patch Changes
+
+- [#5354](https://github.com/Kilo-Org/kilocode/pull/5354) [`7156a35`](https://github.com/Kilo-Org/kilocode/commit/7156a35649d97a10694229a8a89fd10c5a9f9607) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fixed broken image display in Agent Manager message list. Images pasted or attached to messages now render correctly as thumbnails in both user feedback messages and queued messages.
+
+- [#5373](https://github.com/Kilo-Org/kilocode/pull/5373) [`cb41705`](https://github.com/Kilo-Org/kilocode/commit/cb41705691d4be7dc915d9d2f42fbcfaa033d9a8) Thanks [@sebastiand-cerebras](https://github.com/sebastiand-cerebras)! - Set default temperature to 1.0 for Cerebras zai-glm-4.7 model
+
+- [#5402](https://github.com/Kilo-Org/kilocode/pull/5402) [`930931e`](https://github.com/Kilo-Org/kilocode/commit/930931eefe2d5da11ef1b98dc2f8145cb26feb2f) Thanks [@PeterDaveHello](https://github.com/PeterDaveHello)! - Improve zh-TW translations
+
+- [#5407](https://github.com/Kilo-Org/kilocode/pull/5407) [`77cfa54`](https://github.com/Kilo-Org/kilocode/commit/77cfa54e05dbd57d8c2e333da67b1b049bdebdf8) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Add native tool calling support to Nano-GPT provider
+
+- [#5396](https://github.com/Kilo-Org/kilocode/pull/5396) [`fdae881`](https://github.com/Kilo-Org/kilocode/commit/fdae881bfb3483066117db54bb85c7497d4bff5f) Thanks [@markijbema](https://github.com/markijbema)! - Revert "Using Kilo for work?" button in low credit warning, restore free models link
+
+- [#5364](https://github.com/Kilo-Org/kilocode/pull/5364) [`5e8ed35`](https://github.com/Kilo-Org/kilocode/commit/5e8ed3526110f6868b8b8af203eb3e733493a387) Thanks [@huangdaxianer](https://github.com/huangdaxianer)! - Removed forced context compression for volces.com
+
 ## 4.153.0
 
 ### Minor Changes

+ 135 - 11
CONTRIBUTING.md

@@ -1,16 +1,140 @@
-# Welcome to the Kilocode Project
+# Contributing to Kilo Code
 
-See [the Documentation for details on contributing](https://kilo.ai/docs/extending/contributing-to-kilo)
+First off, thanks for taking the time to contribute! ❤️
 
-## TL;DR
+All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for the team and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
 
-There are lots of ways to contribute to the project
+If you don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
 
-- **Code Contributions** Implement new features or fix bugs
-- **Documentation:** Improve existing docs or create new guides
-- **Custom Modes:** Create and share specialized modes
-- **Bug Reports:** Report issues you encounter
-- **Feature Requests:** Suggest new features or improvements
-- **Community Support:** Help other users in the community
+- Star the project
+- Post on X or Linkedin about Kilo Code `#kilocode
+- Mention the project at local meetups and tell your friends/colleagues
 
-The Kilocode Community is [on Discord](https://kilo.ai/discord)
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [I Have a Question](#i-have-a-question)
+- [I Want To Contribute](#i-want-to-contribute)
+    - [Code Contributors](#code-contributors)
+    - [Reporting Bugs](#reporting-bugs)
+    - [Custom Modes](#custom-modes)
+    - [Feature Requests](#feature-requests)
+    - [Improving The Documentation](#improving-the-documentation)
+    - [Improving The Design](#improving-the-design)
+    - [Publish a Blog Post or Case Study](#publish-a-blog-post-or-case-study)
+    - [Commit Messages](#commit-messages)
+- [Pull requests](#pull-requests)
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by the [Code of Conduct](https://github.com/Kilo-Org/kilocode/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior
+to [[email protected]](mailto:[email protected]).
+
+## I Have a Question
+
+If you need clarification after reading this document, we encourage you to join our [discord](https://kilocode.ai/discord) workspace and join channels [kilo-dev-contributors](https://discord.com/channels/1349288496988160052/1391109167275577464) and [extensions-support channel](https://discord.com/channels/1349288496988160052/1349358641295265864).
+
+## I Want To Contribute
+
+### Code Contributors
+
+We’re excited that you’re interested in contributing code to Kilo Code! Before you start, please take a look at our [Development Guide](https://github.com/Kilo-Org/kilocode/blob/main/DEVELOPMENT.md), it includes setup instructions, build steps, and details on running tests locally.
+
+#### What to Expect
+
+- A GUI-based change with settings may involve 12–13 files, plus about 18 more for internationalization (i18n).
+
+- A new feature or major update might also require corresponding tests, translations, and settings configuration updates.
+
+Don’t let that scare you off, we just want you to have a realistic idea of what’s involved before diving in. You’ll learn a lot, and we’re here to help if you get stuck.
+
+#### Tips Before You Start
+
+- If your change affects any UI elements or Settings, expect it to touch multiple files and translations.
+
+- You can use our translation workflow to automate adding i18n strings instead of editing each language manually.
+
+Unsure if your contribution is “small” or “large”? Start a quick discussion in [kilo-dev-contributors](https://discord.com/channels/1349288496988160052/1391109167275577464) channel on discord or open an issue with good context, follow the commit and pull request guidelines below once you’re ready to open a PR.
+
+### Reporting Bugs
+
+Please use our issues templates that provide hints on what information we need to help you.
+
+> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to [[email protected]](mailto:[email protected]).
+
+### Custom Modes
+
+Custom modes are a powerful way to extend Kilo Code's capabilities. To create and share a custom mode:
+
+- Follow the [Custom Modes documentation](https://kilo.ai/docs/customize/custom-modes) to create your mode.
+
+- Test your mode thoroughly
+
+- Share your mode with the community on [Reddit](https://www.reddit.com/r/kilocode/) or you can show off / start a discussion on [show-off-your-builds](https://discord.com/channels/1349288496988160052/1375399779760214037) or [workflows-and-integration](https://discord.com/channels/1349288496988160052/1420236932780130418) on discord.
+
+### Feature Requests
+
+Suggest feature requests in [Discussion](https://github.com/Kilo-Org/kilocode/discussions), only open an [Issue](https://github.com/Kilo-Org/kilocode/issues/new/choose) for reporting a bug or actual contributions. Don't open issues for questions or support, instead join our [Discord workspace](https://kilocode.ai/discord) and ask there.
+
+- Provide as much context as you can about what you're running into.
+
+### Improving The Documentation
+
+If you notice outdated information or areas that could be clarified, kindly start a discussion in the [general](https://discord.com/channels/1349288496988160052/1349288496988160055) channel on discord.
+Please note that the main [documentation](https://github.com/Kilo-Org/docs) repository has been archived, you can still view it for reference.
+
+### Improving The Design
+
+Design contributions are welcome! To ensure smooth collaboration, please use the Design Improvement Template when opening a design-related issue.
+This helps us gather the right context (such as wireframes, mockups, or visual references) and maintain a consistent design language across the project. Feedback and iterations are highly encouraged, design is always a shared process.
+
+### Publish a Blog Post or Case Study
+
+We love hearing how people use or extend Kilo Code in their own projects. If you’ve written about your experience, we’re happy to review it!
+Our blog and case study repository has been archived, you can still access it [here](https://github.com/Kilo-Org/docs/tree/main/blog-posts) for reference. To share your work, please start a discussion in the [general](https://discord.com/channels/1349288496988160052/1349288496988160055) channel on discord, summarizing your post or case study, with a link to the full content.
+
+### Commit Messages
+
+Writing clear and consistent commit messages helps maintainers understand the purpose of your changes. A good commit message should:
+
+- Be written in the present tense (e.g., Add new feature, not Added new feature)
+
+- Be short (50 characters or less for the summary line)
+
+- Include additional context in the body if needed
+
+- Reference related issue numbers (e.g., Fixes `#123)
+
+- Keep each commit focused on one logical change
+
+## Pull Requests
+
+When you’re ready to contribute your changes, follow these steps to create a clear and reviewable pull request:
+
+- Push your changes to your fork:
+
+    ```bash
+    git push origin your-branch-name
+    ```
+
+- Open a Pull Request against the main Kilo Code repository.
+
+- Select "Compare across forks" and choose your fork and branch.
+
+- Fill out the PR template with:
+
+- A clear description of your changes
+
+    - Any related issues (e.g., “Fixes `#123”)
+
+    - Testing steps or screenshots (if applicable)
+
+    - Notes for reviewers, if special attention is needed
+
+For more context, kindly read the official [contributing docs](https://kilo.ai/docs/contributing).
+
+Your contributions, big or small help make Kilo Code better for everyone!🫶
+
+## References
+
+This document was adapted from [https://contributing.md](https://contributing.md/)!

+ 22 - 6
README.md

@@ -9,7 +9,7 @@
 # 🚀 Kilo
 
 > Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
-> #1 on OpenRouter. 1M+ Kilo Coders. 20T+ tokens processed
+> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed
 
 - ✨ Generate code from natural language
 - ✅ Checks its own work
@@ -24,6 +24,8 @@
   <img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
 </p>
 
+## Quick Links
+
 - [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
 - [Official Kilo.ai Home page](https://kilo.ai) (learn more)
 
@@ -31,12 +33,12 @@
 
 - **Code Generation:** Kilo can generate code using natural language.
 - **Inline Autocomplete:** Get intelligent code completions as you type, powered by AI.
-- **Task Automation:** Kilo can automate repetitive coding tasks.
-- **Automated Refactoring:** Kilo can refactor and improve existing code.
+- **Task Automation:** Kilo can automate repetitive coding tasks to save time..
+- **Automated Refactoring:** Kilo can refactor and improve existing code efficiently.
 - **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities.
 - **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes.
 
-## How to get started with Kilo
+## Get Started
 
 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
 2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
@@ -44,9 +46,23 @@
 
 [![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig)
 
-## Extension Development
+## Developer Setup
+
+If you want to contribute or modify the extension locally, see the [DEVELOPMENT.md](/DEVELOPMENT.md) file for build and setup instructions.
+
+## Contributing
+
+We welcome contributions from developers, writers, and enthusiasts!
+To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
+
+## Code of Conduct
+
+Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
+
+## License
 
-For details on building and developing the extension, see [DEVELOPMENT.md](/DEVELOPMENT.md)
+This project is licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
+You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE).
 
 ## Contributing
 

+ 116 - 0
apps/cli/CHANGELOG.md

@@ -0,0 +1,116 @@
+# Changelog
+
+All notable changes to the `@roo-code/cli` package will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.0.45] - 2026-01-08
+
+### Changed
+
+- **Major Refactor**: Extracted ~1400 lines from [`App.tsx`](src/ui/App.tsx) into reusable hooks and utilities for better maintainability:
+
+    - [`useExtensionHost`](src/ui/hooks/useExtensionHost.ts) - Extension host connection and lifecycle management
+    - [`useMessageHandlers`](src/ui/hooks/useMessageHandlers.ts) - Message processing and state updates
+    - [`useTaskSubmit`](src/ui/hooks/useTaskSubmit.ts) - Task submission logic
+    - [`useGlobalInput`](src/ui/hooks/useGlobalInput.ts) - Global keyboard shortcut handling
+    - [`useFollowupCountdown`](src/ui/hooks/useFollowupCountdown.ts) - Auto-approval countdown logic
+    - [`useFocusManagement`](src/ui/hooks/useFocusManagement.ts) - Input focus state management
+    - [`usePickerHandlers`](src/ui/hooks/usePickerHandlers.ts) - Picker component event handling
+    - [`uiStateStore`](src/ui/stores/uiStateStore.ts) - UI-specific state (showExitHint, countdown, etc.)
+    - Tool data utilities ([`extractToolData`](src/ui/utils/toolDataUtils.ts), `formatToolOutput`, etc.)
+    - [`HorizontalLine`](src/ui/components/HorizontalLine.tsx) component
+
+- **Performance Optimizations**:
+
+    - Added RAF-style scroll throttling to reduce state updates
+    - Stabilized `useExtensionHost` hook return values with `useCallback`/`useMemo`
+    - Added streaming message debouncing to batch rapid partial updates
+    - Added shallow array equality checks to prevent unnecessary re-renders
+
+- Simplified [`ModeTool`](src/ui/components/tools/ModeTool.tsx) layout to horizontal with mode suffix
+- Simplified logging by removing verbose debug output and adding first/last partial message logging pattern
+- Updated Nerd Font icon codepoints in [`Icon`](src/ui/components/Icon.tsx) component
+
+### Added
+
+- `#` shortcut in help trigger for quick access to task history autocomplete
+
+### Fixed
+
+- Fixed a crash in message handling
+- Added protected file warning in tool approval prompts
+- Enabled `alwaysAllowWriteProtected` for non-interactive mode
+
+### Removed
+
+- Removed unused `renderLogger.ts` utility file
+
+### Tests
+
+- Updated extension-host tests to expect `[Tool Request]` format
+- Updated Icon tests to expect single-char Nerd Font icons
+
+## [0.0.44] - 2026-01-08
+
+### Added
+
+- **Tool Renderer Components**: Specialized renderers for displaying tool outputs with optimized formatting for each tool type. Each renderer provides a focused view of its data structure.
+
+    - [`FileReadTool`](src/ui/components/tools/FileReadTool.tsx) - Display file read operations with syntax highlighting
+    - [`FileWriteTool`](src/ui/components/tools/FileWriteTool.tsx) - Show file write/edit operations with diff views
+    - [`SearchTool`](src/ui/components/tools/SearchTool.tsx) - Render search results with context
+    - [`CommandTool`](src/ui/components/tools/CommandTool.tsx) - Display command execution with output
+    - [`BrowserTool`](src/ui/components/tools/BrowserTool.tsx) - Show browser automation actions
+    - [`ModeTool`](src/ui/components/tools/ModeTool.tsx) - Display mode switching operations
+    - [`CompletionTool`](src/ui/components/tools/CompletionTool.tsx) - Show task completion status
+    - [`GenericTool`](src/ui/components/tools/GenericTool.tsx) - Fallback renderer for other tools
+
+- **History Trigger**: New `#` trigger for task history autocomplete with fuzzy search support. Type `#` at the start of a line to browse and resume previous tasks.
+
+    - [`HistoryTrigger.tsx`](src/ui/components/autocomplete/triggers/HistoryTrigger.tsx) - Trigger implementation with fuzzy filtering
+    - Shows task status, mode, and relative timestamps
+    - Supports keyboard navigation for quick task selection
+
+- **Release Confirmation Prompt**: The release script now prompts for confirmation before creating a release.
+
+### Fixed
+
+- Task history picker selection and navigation issues
+- Mode switcher keyboard handling bug
+
+### Changed
+
+- Reorganized test files into `__tests__` directories for better project structure
+- Refactored utility modules into dedicated `utils/` directory
+
+## [0.0.43] - 2026-01-07
+
+### Added
+
+- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store.
+
+    - New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages
+    - New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications
+
+- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components.
+
+    - New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module
+    - Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility
+    - Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling)
+
+- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds.
+
+### Changed
+
+- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling.
+
+### Tests
+
+- Added comprehensive tests for the toast notification system
+- Added tests for global input sequence matching
+
+## [0.0.42] - 2025-01-07
+
+The cli is alive!

+ 262 - 0
apps/cli/README.md

@@ -0,0 +1,262 @@
+# @roo-code/cli
+
+Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode.
+
+## Overview
+
+This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment.
+
+## Installation
+
+### Quick Install (Recommended)
+
+Install the Roo Code CLI with a single command:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+**Requirements:**
+
+- Node.js 20 or higher
+- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
+
+**Custom installation directory:**
+
+```bash
+ROO_INSTALL_DIR=/opt/roo-code ROO_BIN_DIR=/usr/local/bin curl -fsSL ... | sh
+```
+
+**Install a specific version:**
+
+```bash
+ROO_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+### Updating
+
+Re-run the install script to update to the latest version:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+### Uninstalling
+
+```bash
+rm -rf ~/.roo/cli ~/.local/bin/roo
+```
+
+### Development Installation
+
+For contributing or development:
+
+```bash
+# From the monorepo root.
+pnpm install
+
+# Build the main extension first.
+pnpm --filter roo-cline bundle
+
+# Build the cli.
+pnpm --filter @roo-code/cli build
+```
+
+## Usage
+
+### Interactive Mode (Default)
+
+By default, the CLI prompts for approval before executing actions:
+
+```bash
+export OPENROUTER_API_KEY=sk-or-v1-...
+
+roo ~/Documents/my-project -P "What is this project?"
+```
+
+You can also run without a prompt and enter it interactively in TUI mode:
+
+```bash
+roo ~/Documents/my-project
+```
+
+In interactive mode:
+
+- Tool executions prompt for yes/no approval
+- Commands prompt for yes/no approval
+- Followup questions show suggestions and wait for user input
+- Browser and MCP actions prompt for approval
+
+### Non-Interactive Mode (`-y`)
+
+For automation and scripts, use `-y` to auto-approve all actions:
+
+```bash
+roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
+```
+
+In non-interactive mode:
+
+- Tool, command, browser, and MCP actions are auto-approved
+- Followup questions show a 60-second timeout, then auto-select the first suggestion
+- Typing any key cancels the timeout and allows manual input
+
+### Roo Code Cloud Authentication
+
+To use Roo Code Cloud features (like the provider proxy), you need to authenticate:
+
+```bash
+# Log in to Roo Code Cloud (opens browser)
+roo auth login
+
+# Check authentication status
+roo auth status
+
+# Log out
+roo auth logout
+```
+
+The `auth login` command:
+
+1. Opens your browser to authenticate with Roo Code Cloud
+2. Receives a secure token via localhost callback
+3. Stores the token in `~/.config/roo/credentials.json`
+
+Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires.
+
+**Authentication Flow:**
+
+```
+┌──────┐         ┌─────────┐         ┌───────────────┐
+│  CLI │         │ Browser │         │ Roo Code Cloud│
+└──┬───┘         └────┬────┘         └───────┬───────┘
+   │                  │                      │
+   │ Open auth URL    │                      │
+   │─────────────────>│                      │
+   │                  │                      │
+   │                  │ Authenticate         │
+   │                  │─────────────────────>│
+   │                  │                      │
+   │                  │<─────────────────────│
+   │                  │ Token via callback   │
+   │<─────────────────│                      │
+   │                  │                      │
+   │ Store token      │                      │
+   │                  │                      │
+```
+
+## Options
+
+| Option                            | Description                                                                             | Default                       |
+| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- |
+| `[workspace]`                     | Workspace path to operate in (positional argument)                                      | Current directory             |
+| `-P, --prompt <prompt>`           | The prompt/task to execute (optional in TUI mode)                                       | None                          |
+| `-e, --extension <path>`          | Path to the extension bundle directory                                                  | Auto-detected                 |
+| `-d, --debug`                     | Enable debug output (includes detailed debug information, prompts, paths, etc)          | `false`                       |
+| `-x, --exit-on-complete`          | Exit the process when task completes (useful for testing)                               | `false`                       |
+| `-y, --yes`                       | Non-interactive mode: auto-approve all actions                                          | `false`                       |
+| `-k, --api-key <key>`             | API key for the LLM provider                                                            | From env var                  |
+| `-p, --provider <provider>`       | API provider (anthropic, openai, openrouter, etc.)                                      | `openrouter`                  |
+| `-m, --model <model>`             | Model to use                                                                            | `anthropic/claude-sonnet-4.5` |
+| `-M, --mode <mode>`               | Mode to start in (code, architect, ask, debug, etc.)                                    | `code`                        |
+| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium`                      |
+| `--ephemeral`                     | Run without persisting state (uses temporary storage)                                   | `false`                       |
+| `--no-tui`                        | Disable TUI, use plain text output                                                      | `false`                       |
+
+## Auth Commands
+
+| Command           | Description                        |
+| ----------------- | ---------------------------------- |
+| `roo auth login`  | Authenticate with Roo Code Cloud   |
+| `roo auth logout` | Clear stored authentication token  |
+| `roo auth status` | Show current authentication status |
+
+## Environment Variables
+
+The CLI will look for API keys in environment variables if not provided via `--api-key`:
+
+| Provider      | Environment Variable |
+| ------------- | -------------------- |
+| anthropic     | `ANTHROPIC_API_KEY`  |
+| openai        | `OPENAI_API_KEY`     |
+| openrouter    | `OPENROUTER_API_KEY` |
+| google/gemini | `GOOGLE_API_KEY`     |
+| ...           | ...                  |
+
+**Authentication Environment Variables:**
+
+| Variable          | Description                                                          |
+| ----------------- | -------------------------------------------------------------------- |
+| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) |
+
+## Architecture
+
+```
+┌─────────────────┐
+│   CLI Entry     │
+│   (index.ts)    │
+└────────┬────────┘
+         │
+         ▼
+┌─────────────────┐
+│  ExtensionHost  │
+│  (extension-    │
+│   host.ts)      │
+└────────┬────────┘
+         │
+    ┌────┴────┐
+    │         │
+    ▼         ▼
+┌───────┐  ┌──────────┐
+│vscode │  │Extension │
+│-shim  │  │ Bundle   │
+└───────┘  └──────────┘
+```
+
+## How It Works
+
+1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost
+
+2. **ExtensionHost** (`extension-host.ts`):
+
+    - Creates a VSCode API mock using `@roo-code/vscode-shim`
+    - Intercepts `require('vscode')` to return the mock
+    - Loads and activates the extension bundle
+    - Manages bidirectional message flow
+
+3. **Message Flow**:
+    - CLI → Extension: `emit("webviewMessage", {...})`
+    - Extension → CLI: `emit("extensionWebviewMessage", {...})`
+
+## Development
+
+```bash
+# Watch mode for development
+pnpm dev
+
+# Run tests
+pnpm test
+
+# Type checking
+pnpm check-types
+
+# Linting
+pnpm lint
+```
+
+## Releasing
+
+To create a new release, execute the /cli-release slash command:
+
+```bash
+roo ~/Documents/Roo-Code -P "/cli-release" -y
+```
+
+The workflow will:
+
+1. Bump the version
+2. Update the CHANGELOG
+3. Build the extension and CLI
+4. Create a platform-specific tarball (for your current OS/architecture)
+5. Test the install script
+6. Create a GitHub release with the tarball attached

+ 355 - 0
apps/cli/docs/AGENT_LOOP.md

@@ -0,0 +1,355 @@
+# CLI Agent Loop
+
+This document explains how the Roo Code CLI detects and tracks the agent loop state.
+
+## Overview
+
+The CLI needs to know when the agent is:
+
+- **Running** (actively processing)
+- **Streaming** (receiving content from the API)
+- **Waiting for input** (needs user approval or answer)
+- **Idle** (task completed or failed)
+
+This is accomplished by analyzing the messages the extension sends to the client.
+
+## The Message Model
+
+All agent activity is communicated through **ClineMessages** - a stream of timestamped messages that represent everything the agent does.
+
+### Message Structure
+
+```typescript
+interface ClineMessage {
+	ts: number // Unique timestamp identifier
+	type: "ask" | "say" // Message category
+	ask?: ClineAsk // Specific ask type (when type="ask")
+	say?: ClineSay // Specific say type (when type="say")
+	text?: string // Message content
+	partial?: boolean // Is this message still streaming?
+}
+```
+
+### Two Types of Messages
+
+| Type    | Purpose                                        | Blocks Agent? |
+| ------- | ---------------------------------------------- | ------------- |
+| **say** | Informational - agent is telling you something | No            |
+| **ask** | Interactive - agent needs something from you   | Usually yes   |
+
+## The Key Insight
+
+> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).**
+
+The specific `ask` value tells you exactly what the agent needs.
+
+## Ask Categories
+
+The CLI categorizes asks into four groups:
+
+### 1. Interactive Asks → `WAITING_FOR_INPUT` state
+
+These require user action to continue:
+
+| Ask Type                | What It Means                     | Required Response |
+| ----------------------- | --------------------------------- | ----------------- |
+| `tool`                  | Wants to edit/create/delete files | Approve or Reject |
+| `command`               | Wants to run a terminal command   | Approve or Reject |
+| `followup`              | Asking a question                 | Text answer       |
+| `browser_action_launch` | Wants to use the browser          | Approve or Reject |
+| `use_mcp_server`        | Wants to use an MCP server        | Approve or Reject |
+
+### 2. Idle Asks → `IDLE` state
+
+These indicate the task has stopped:
+
+| Ask Type                        | What It Means               | Response Options            |
+| ------------------------------- | --------------------------- | --------------------------- |
+| `completion_result`             | Task completed successfully | New task or feedback        |
+| `api_req_failed`                | API request failed          | Retry or new task           |
+| `mistake_limit_reached`         | Too many errors             | Continue anyway or new task |
+| `auto_approval_max_req_reached` | Auto-approval limit hit     | Continue manually or stop   |
+| `resume_completed_task`         | Viewing completed task      | New task                    |
+
+### 3. Resumable Asks → `RESUMABLE` state
+
+| Ask Type      | What It Means             | Response Options  |
+| ------------- | ------------------------- | ----------------- |
+| `resume_task` | Task paused mid-execution | Resume or abandon |
+
+### 4. Non-Blocking Asks → `RUNNING` state
+
+| Ask Type         | What It Means      | Response Options  |
+| ---------------- | ------------------ | ----------------- |
+| `command_output` | Command is running | Continue or abort |
+
+## Streaming Detection
+
+The agent is **streaming** when:
+
+1. **`partial: true`** on the last message, OR
+2. **An `api_req_started` message exists** with `cost: undefined` in its text field
+
+```typescript
+// Streaming detection pseudocode
+function isStreaming(messages) {
+	const lastMessage = messages.at(-1)
+
+	// Check partial flag (primary indicator)
+	if (lastMessage?.partial === true) {
+		return true
+	}
+
+	// Check for in-progress API request
+	const apiReq = messages.findLast((m) => m.say === "api_req_started")
+	if (apiReq?.text) {
+		const data = JSON.parse(apiReq.text)
+		if (data.cost === undefined) {
+			return true // API request not yet complete
+		}
+	}
+
+	return false
+}
+```
+
+## State Machine
+
+```
+                    ┌─────────────────┐
+                    │    NO_TASK      │  (no messages)
+                    └────────┬────────┘
+                             │ newTask
+                             ▼
+              ┌─────────────────────────────┐
+         ┌───▶│         RUNNING             │◀───┐
+         │    └──────────┬──────────────────┘    │
+         │               │                       │
+         │    ┌──────────┼──────────────┐        │
+         │    │          │              │        │
+         │    ▼          ▼              ▼        │
+         │ ┌──────┐  ┌─────────┐  ┌──────────┐   │
+         │ │STREAM│  │WAITING_ │  │   IDLE   │   │
+         │ │ ING  │  │FOR_INPUT│  │          │   │
+         │ └──┬───┘  └────┬────┘  └────┬─────┘   │
+         │    │           │            │         │
+         │    │ done      │ approved   │ newTask │
+         └────┴───────────┴────────────┘         │
+                                                 │
+         ┌──────────────┐                        │
+         │  RESUMABLE   │────────────────────────┘
+         └──────────────┘  resumed
+```
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        ExtensionHost                            │
+│                                                                 │
+│  ┌──────────────────┐                                           │
+│  │   Extension      │──── extensionWebviewMessage ─────┐        │
+│  │   (Task.ts)      │                                  │        │
+│  └──────────────────┘                                  │        │
+│                                                        ▼        │
+│  ┌───────────────────────────────────────────────────────────┐  │
+│  │                    ExtensionClient                        │  │
+│  │                (Single Source of Truth)                   │  │
+│  │                                                           │  │
+│  │  ┌─────────────────┐    ┌────────────────────┐            │  │
+│  │  │ MessageProcessor │───▶│    StateStore     │            │  │
+│  │  │                 │    │  (clineMessages)   │            │  │
+│  │  └─────────────────┘    └────────┬───────────┘            │  │
+│  │                                  │                        │  │
+│  │                                  ▼                        │  │
+│  │                         detectAgentState()                │  │
+│  │                                  │                        │  │
+│  │                                  ▼                        │  │
+│  │  Events: stateChange, message, waitingForInput, etc.      │  │
+│  └───────────────────────────────────────────────────────────┘  │
+│                           │                                     │
+│                           ▼                                     │
+│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐     │
+│  │ OutputManager  │  │  AskDispatcher │  │ PromptManager  │     │
+│  │  (stdout)      │  │  (ask routing) │  │  (user input)  │     │
+│  └────────────────┘  └────────────────┘  └────────────────┘     │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Component Responsibilities
+
+### ExtensionClient
+
+The **single source of truth** for agent state, including the current mode. It:
+
+- Receives all messages from the extension
+- Stores them in the `StateStore`
+- Tracks the current mode from state messages
+- Computes the current state via `detectAgentState()`
+- Emits events when state changes (including mode changes)
+
+```typescript
+const client = new ExtensionClient({
+	sendMessage: (msg) => extensionHost.sendToExtension(msg),
+	debug: true, // Writes to ~/.roo/cli-debug.log
+})
+
+// Query state at any time
+const state = client.getAgentState()
+if (state.isWaitingForInput) {
+	console.log(`Agent needs: ${state.currentAsk}`)
+}
+
+// Query current mode
+const mode = client.getCurrentMode()
+console.log(`Current mode: ${mode}`) // e.g., "code", "architect", "ask"
+
+// Subscribe to events
+client.on("waitingForInput", (event) => {
+	console.log(`Waiting for: ${event.ask}`)
+})
+
+// Subscribe to mode changes
+client.on("modeChanged", (event) => {
+	console.log(`Mode changed: ${event.previousMode} -> ${event.currentMode}`)
+})
+```
+
+### StateStore
+
+Holds the `clineMessages` array, computed state, and current mode:
+
+```typescript
+interface StoreState {
+	messages: ClineMessage[] // The raw message array
+	agentState: AgentStateInfo // Computed state
+	isInitialized: boolean // Have we received any state?
+	currentMode: string | undefined // Current mode (e.g., "code", "architect")
+}
+```
+
+### MessageProcessor
+
+Handles incoming messages from the extension:
+
+- `"state"` messages → Update `clineMessages` array and track mode
+- `"messageUpdated"` messages → Update single message in array
+- Emits events for state transitions and mode changes
+
+### AskDispatcher
+
+Routes asks to appropriate handlers:
+
+- Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc.
+- Coordinates between `OutputManager` and `PromptManager`
+- In non-interactive mode (`-y` flag), auto-approves everything
+
+### OutputManager
+
+Handles all CLI output:
+
+- Streams partial content with delta computation
+- Tracks what's been displayed to avoid duplicates
+- Writes directly to `process.stdout` (bypasses quiet mode)
+
+### PromptManager
+
+Handles user input:
+
+- Yes/no prompts
+- Text input prompts
+- Timed prompts with auto-defaults
+
+## Response Messages
+
+When the agent is waiting, send these responses:
+
+```typescript
+// Approve an action (tool, command, browser, MCP)
+client.sendMessage({
+	type: "askResponse",
+	askResponse: "yesButtonClicked",
+})
+
+// Reject an action
+client.sendMessage({
+	type: "askResponse",
+	askResponse: "noButtonClicked",
+})
+
+// Answer a question
+client.sendMessage({
+	type: "askResponse",
+	askResponse: "messageResponse",
+	text: "My answer here",
+})
+
+// Start a new task
+client.sendMessage({
+	type: "newTask",
+	text: "Build a web app",
+})
+
+// Cancel current task
+client.sendMessage({
+	type: "cancelTask",
+})
+```
+
+## Type Guards
+
+The CLI uses type guards from `@roo-code/types` for categorization:
+
+```typescript
+import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "@roo-code/types"
+
+const ask = message.ask
+if (isInteractiveAsk(ask)) {
+	// Needs approval: tool, command, followup, etc.
+} else if (isIdleAsk(ask)) {
+	// Task stopped: completion_result, api_req_failed, etc.
+} else if (isResumableAsk(ask)) {
+	// Task paused: resume_task
+} else if (isNonBlockingAsk(ask)) {
+	// Command running: command_output
+}
+```
+
+## Debug Logging
+
+Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`:
+
+```bash
+roo -d -y -P "Build something" --no-tui
+```
+
+View logs:
+
+```bash
+tail -f ~/.roo/cli-debug.log
+```
+
+Example output:
+
+```
+[MessageProcessor] State update: {
+  "messageCount": 5,
+  "lastMessage": {
+    "msgType": "ask:completion_result"
+  },
+  "stateTransition": "running → idle",
+  "currentAsk": "completion_result",
+  "isWaitingForInput": true
+}
+[MessageProcessor] EMIT waitingForInput: { "ask": "completion_result" }
+[MessageProcessor] EMIT taskCompleted: { "success": true }
+```
+
+## Summary
+
+1. **Agent communicates via `ClineMessage` stream**
+2. **Last message determines state**
+3. **`ask` messages (non-partial) block the agent**
+4. **Ask category determines required action**
+5. **`partial: true` or `api_req_started` without cost = streaming**
+6. **`ExtensionClient` is the single source of truth**

+ 4 - 0
apps/cli/eslint.config.mjs

@@ -0,0 +1,4 @@
+import { config } from "@roo-code/config-eslint/base"
+
+/** @type {import("eslint").Linter.Config} */
+export default [...config]

+ 305 - 0
apps/cli/install.sh

@@ -0,0 +1,305 @@
+#!/bin/sh
+# Roo Code CLI Installer
+# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+#
+# Environment variables:
+#   ROO_INSTALL_DIR   - Installation directory (default: ~/.roo/cli)
+#   ROO_BIN_DIR       - Binary symlink directory (default: ~/.local/bin)
+#   ROO_VERSION       - Specific version to install (default: latest)
+#   ROO_LOCAL_TARBALL - Path to local tarball to install (skips download)
+
+set -e
+
+# Configuration
+INSTALL_DIR="${ROO_INSTALL_DIR:-$HOME/.roo/cli}"
+BIN_DIR="${ROO_BIN_DIR:-$HOME/.local/bin}"
+REPO="RooCodeInc/Roo-Code"
+MIN_NODE_VERSION=20
+
+# Color output (only if terminal supports it)
+if [ -t 1 ]; then
+    RED='\033[0;31m'
+    GREEN='\033[0;32m'
+    YELLOW='\033[1;33m'
+    BLUE='\033[0;34m'
+    BOLD='\033[1m'
+    NC='\033[0m'
+else
+    RED=''
+    GREEN=''
+    YELLOW=''
+    BLUE=''
+    BOLD=''
+    NC=''
+fi
+
+info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
+warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
+error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
+
+# Check Node.js version
+check_node() {
+    if ! command -v node >/dev/null 2>&1; then
+        error "Node.js is not installed. Please install Node.js $MIN_NODE_VERSION or higher.
+
+Install Node.js:
+  - macOS: brew install node
+  - Linux: https://nodejs.org/en/download/package-manager
+  - Or use a version manager like fnm, nvm, or mise"
+    fi
+    
+    NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
+    if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then
+        error "Node.js $MIN_NODE_VERSION+ required. Found: $(node -v)
+
+Please upgrade Node.js to version $MIN_NODE_VERSION or higher."
+    fi
+    
+    info "Found Node.js $(node -v)"
+}
+
+# Detect OS and architecture
+detect_platform() {
+    OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+    ARCH=$(uname -m)
+    
+    case "$OS" in
+        darwin) OS="darwin" ;;
+        linux) OS="linux" ;;
+        mingw*|msys*|cygwin*) 
+            error "Windows is not supported by this installer. Please use WSL or install manually."
+            ;;
+        *) error "Unsupported OS: $OS" ;;
+    esac
+    
+    case "$ARCH" in
+        x86_64|amd64) ARCH="x64" ;;
+        arm64|aarch64) ARCH="arm64" ;;
+        *) error "Unsupported architecture: $ARCH" ;;
+    esac
+    
+    PLATFORM="${OS}-${ARCH}"
+    info "Detected platform: $PLATFORM"
+}
+
+# Get latest release version or use specified version
+get_version() {
+    # Skip version fetch if using local tarball
+    if [ -n "$ROO_LOCAL_TARBALL" ]; then
+        VERSION="${ROO_VERSION:-local}"
+        info "Using local tarball (version: $VERSION)"
+        return
+    fi
+    
+    if [ -n "$ROO_VERSION" ]; then
+        VERSION="$ROO_VERSION"
+        info "Using specified version: $VERSION"
+        return
+    fi
+    
+    info "Fetching latest version..."
+    
+    # Try to get the latest cli release
+    RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) || {
+        error "Failed to fetch releases from GitHub. Check your internet connection."
+    }
+    
+    # Extract the latest cli-v* tag
+    VERSION=$(echo "$RELEASES_JSON" |
+              grep -o '"tag_name": "cli-v[^"]*"' |
+              head -1 |
+              sed 's/"tag_name": "cli-v//' |
+              sed 's/"//')
+    
+    if [ -z "$VERSION" ]; then
+        error "Could not find any CLI releases. The CLI may not have been released yet."
+    fi
+    
+    info "Latest version: $VERSION"
+}
+
+# Download and extract
+download_and_install() {
+    TARBALL="roo-cli-${PLATFORM}.tar.gz"
+    
+    # Create temp directory
+    TMP_DIR=$(mktemp -d)
+    trap "rm -rf $TMP_DIR" EXIT
+    
+    # Use local tarball if provided, otherwise download
+    if [ -n "$ROO_LOCAL_TARBALL" ]; then
+        if [ ! -f "$ROO_LOCAL_TARBALL" ]; then
+            error "Local tarball not found: $ROO_LOCAL_TARBALL"
+        fi
+        info "Using local tarball: $ROO_LOCAL_TARBALL"
+        cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL"
+    else
+        URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
+        
+        info "Downloading from $URL..."
+        
+        # Download with progress indicator
+        HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
+            if [ "$HTTP_CODE" = "404" ]; then
+                error "Release not found for platform $PLATFORM version $VERSION.
+
+Available at: https://github.com/$REPO/releases"
+            fi
+            error "Download failed. HTTP code: $HTTP_CODE"
+        }
+
+        # Verify we got something
+        if [ ! -s "$TMP_DIR/$TARBALL" ]; then
+            error "Downloaded file is empty. Please try again."
+        fi
+    fi
+
+    # Remove old installation if exists
+    if [ -d "$INSTALL_DIR" ]; then
+        info "Removing previous installation..."
+        rm -rf "$INSTALL_DIR"
+    fi
+    
+    mkdir -p "$INSTALL_DIR"
+    
+    # Extract
+    info "Extracting to $INSTALL_DIR..."
+    tar -xzf "$TMP_DIR/$TARBALL" -C "$INSTALL_DIR" --strip-components=1 || {
+        error "Failed to extract tarball. The download may be corrupted."
+    }
+    
+    # Save ripgrep binary before npm install (npm install will overwrite node_modules)
+    RIPGREP_BIN=""
+    if [ -f "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" ]; then
+        RIPGREP_BIN="$TMP_DIR/rg"
+        cp "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" "$RIPGREP_BIN"
+    fi
+    
+    # Install npm dependencies
+    info "Installing dependencies..."
+    cd "$INSTALL_DIR"
+    npm install --production --silent 2>/dev/null || {
+        warn "npm install failed, trying with --legacy-peer-deps..."
+        npm install --production --legacy-peer-deps --silent 2>/dev/null || {
+            error "Failed to install dependencies. Make sure npm is available."
+        }
+    }
+    cd - > /dev/null
+    
+    # Restore ripgrep binary after npm install
+    if [ -n "$RIPGREP_BIN" ] && [ -f "$RIPGREP_BIN" ]; then
+        mkdir -p "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin"
+        cp "$RIPGREP_BIN" "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
+        chmod +x "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
+    fi
+    
+    # Make executable
+    chmod +x "$INSTALL_DIR/bin/roo"
+    
+    # Also make ripgrep executable if it exists
+    if [ -f "$INSTALL_DIR/bin/rg" ]; then
+        chmod +x "$INSTALL_DIR/bin/rg"
+    fi
+}
+
+# Create symlink in bin directory
+setup_bin() {
+    mkdir -p "$BIN_DIR"
+    
+    # Remove old symlink if exists
+    if [ -L "$BIN_DIR/roo" ] || [ -f "$BIN_DIR/roo" ]; then
+        rm -f "$BIN_DIR/roo"
+    fi
+    
+    ln -sf "$INSTALL_DIR/bin/roo" "$BIN_DIR/roo"
+    info "Created symlink: $BIN_DIR/roo"
+}
+
+# Check if bin dir is in PATH and provide instructions
+check_path() {
+    case ":$PATH:" in
+        *":$BIN_DIR:"*) 
+            # Already in PATH
+            return 0
+            ;;
+    esac
+    
+    warn "$BIN_DIR is not in your PATH"
+    echo ""
+    echo "Add this line to your shell profile:"
+    echo ""
+    
+    # Detect shell and provide specific instructions
+    SHELL_NAME=$(basename "$SHELL")
+    case "$SHELL_NAME" in
+        zsh)
+            echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc"
+            echo "  source ~/.zshrc"
+            ;;
+        bash)
+            if [ -f "$HOME/.bashrc" ]; then
+                echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc"
+                echo "  source ~/.bashrc"
+            else
+                echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bash_profile"
+                echo "  source ~/.bash_profile"
+            fi
+            ;;
+        fish)
+            echo "  set -Ux fish_user_paths $BIN_DIR \$fish_user_paths"
+            ;;
+        *)
+            echo "  export PATH=\"$BIN_DIR:\$PATH\""
+            ;;
+    esac
+    echo ""
+}
+
+# Verify installation
+verify_install() {
+    if [ -x "$BIN_DIR/roo" ]; then
+        info "Verifying installation..."
+        # Just check if it runs without error
+        "$BIN_DIR/roo" --version >/dev/null 2>&1 || true
+    fi
+}
+
+# Print success message
+print_success() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n"
+    echo ""
+    echo "  Installation: $INSTALL_DIR"
+    echo "  Binary: $BIN_DIR/roo"
+    echo "  Version: $VERSION"
+    echo ""
+    echo "  ${BOLD}Get started:${NC}"
+    echo "    roo --help"
+    echo ""
+    echo "  ${BOLD}Example:${NC}"
+    echo "    export OPENROUTER_API_KEY=sk-or-v1-..."
+    echo "    roo ~/my-project -P \"What is this project?\""
+    echo ""
+}
+
+# Main
+main() {
+    echo ""
+    printf "${BLUE}${BOLD}"
+    echo "  ╭─────────────────────────────────╮"
+    echo "  │     Roo Code CLI Installer      │"
+    echo "  ╰─────────────────────────────────╯"
+    printf "${NC}"
+    echo ""
+    
+    check_node
+    detect_platform
+    get_version
+    download_and_install
+    setup_bin
+    check_path
+    verify_install
+    print_success
+}
+
+main "$@"

+ 48 - 0
apps/cli/package.json

@@ -0,0 +1,48 @@
+{
+	"name": "@roo-code/cli",
+	"version": "0.0.45",
+	"description": "Roo Code CLI - Run the Roo Code agent from the command line",
+	"private": true,
+	"type": "module",
+	"main": "dist/index.js",
+	"bin": {
+		"roo": "dist/index.js"
+	},
+	"scripts": {
+		"format": "prettier --write 'src/**/*.ts'",
+		"lint": "eslint src --ext .ts --max-warnings=0",
+		"check-types": "tsc --noEmit",
+		"test": "vitest run",
+		"build": "tsup",
+		"dev": "tsup --watch",
+		"start": "ROO_SDK_BASE_URL=http://localhost:3001 ROO_AUTH_BASE_URL=http://localhost:3000 node dist/index.js",
+		"start:production": "node dist/index.js",
+		"release": "scripts/release.sh",
+		"clean": "rimraf dist .turbo"
+	},
+	"dependencies": {
+		"@inkjs/ui": "^2.0.0",
+		"@roo-code/core": "workspace:^",
+		"@roo-code/types": "workspace:^",
+		"@roo-code/vscode-shim": "workspace:^",
+		"@trpc/client": "^11.8.1",
+		"@vscode/ripgrep": "^1.15.9",
+		"commander": "^12.1.0",
+		"fuzzysort": "^3.1.0",
+		"ink": "^6.6.0",
+		"p-wait-for": "^5.0.2",
+		"react": "^19.1.0",
+		"superjson": "^2.2.6",
+		"zustand": "^5.0.0"
+	},
+	"devDependencies": {
+		"@roo-code/config-eslint": "workspace:^",
+		"@roo-code/config-typescript": "workspace:^",
+		"@types/node": "^24.1.0",
+		"@types/react": "^19.1.6",
+		"ink-testing-library": "^4.0.0",
+		"rimraf": "^6.0.1",
+		"tsup": "^8.4.0",
+		"vitest": "^3.2.3"
+	}
+}

+ 714 - 0
apps/cli/scripts/release.sh

@@ -0,0 +1,714 @@
+#!/bin/bash
+# Roo Code CLI Release Script
+#
+# Usage:
+#   ./apps/cli/scripts/release.sh [options] [version]
+#
+# Options:
+#   --dry-run    Run all steps except creating the GitHub release
+#   --local      Build for local testing only (no GitHub checks, no changelog prompts)
+#   --install    Install locally after building (only with --local)
+#   --skip-verify Skip end-to-end verification tests (faster local builds)
+#
+# Examples:
+#   ./apps/cli/scripts/release.sh           # Use version from package.json
+#   ./apps/cli/scripts/release.sh 0.1.0     # Specify version
+#   ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing
+#   ./apps/cli/scripts/release.sh --dry-run 0.1.0  # Dry run with specific version
+#   ./apps/cli/scripts/release.sh --local   # Build for local testing
+#   ./apps/cli/scripts/release.sh --local --install  # Build and install locally
+#   ./apps/cli/scripts/release.sh --local --skip-verify  # Fast local build
+#
+# This script:
+# 1. Builds the extension and CLI
+# 2. Creates a tarball for the current platform
+# 3. Creates a GitHub release and uploads the tarball (unless --dry-run or --local)
+#
+# Prerequisites:
+#   - GitHub CLI (gh) installed and authenticated (not needed for --local)
+#   - pnpm installed
+#   - Run from the monorepo root directory
+
+set -e
+
+# Parse arguments
+DRY_RUN=false
+LOCAL_BUILD=false
+LOCAL_INSTALL=false
+SKIP_VERIFY=false
+VERSION_ARG=""
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+        --local)
+            LOCAL_BUILD=true
+            shift
+            ;;
+        --install)
+            LOCAL_INSTALL=true
+            shift
+            ;;
+        --skip-verify)
+            SKIP_VERIFY=true
+            shift
+            ;;
+        -*)
+            echo "Unknown option: $1" >&2
+            exit 1
+            ;;
+        *)
+            VERSION_ARG="$1"
+            shift
+            ;;
+    esac
+done
+
+# Validate option combinations
+if [ "$LOCAL_INSTALL" = true ] && [ "$LOCAL_BUILD" = false ]; then
+    echo "Error: --install can only be used with --local" >&2
+    exit 1
+fi
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
+warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
+error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
+step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; }
+
+# Get script directory and repo root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+CLI_DIR="$REPO_ROOT/apps/cli"
+
+# Detect current platform
+detect_platform() {
+    OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+    ARCH=$(uname -m)
+    
+    case "$OS" in
+        darwin) OS="darwin" ;;
+        linux) OS="linux" ;;
+        *) error "Unsupported OS: $OS" ;;
+    esac
+    
+    case "$ARCH" in
+        x86_64|amd64) ARCH="x64" ;;
+        arm64|aarch64) ARCH="arm64" ;;
+        *) error "Unsupported architecture: $ARCH" ;;
+    esac
+    
+    PLATFORM="${OS}-${ARCH}"
+}
+
+# Check prerequisites
+check_prerequisites() {
+    step "1/8" "Checking prerequisites..."
+    
+    # Skip GitHub CLI checks for local builds
+    if [ "$LOCAL_BUILD" = false ]; then
+        if ! command -v gh &> /dev/null; then
+            error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
+        fi
+        
+        if ! gh auth status &> /dev/null; then
+            error "GitHub CLI is not authenticated. Run: gh auth login"
+        fi
+    fi
+    
+    if ! command -v pnpm &> /dev/null; then
+        error "pnpm is not installed."
+    fi
+    
+    if ! command -v node &> /dev/null; then
+        error "Node.js is not installed."
+    fi
+    
+    info "Prerequisites OK"
+}
+
+# Get version
+get_version() {
+    if [ -n "$VERSION_ARG" ]; then
+        VERSION="$VERSION_ARG"
+    else
+        VERSION=$(node -p "require('$CLI_DIR/package.json').version")
+    fi
+    
+    # For local builds, append a local suffix with git short hash
+    # This creates versions like: 0.1.0-local.abc1234
+    if [ "$LOCAL_BUILD" = true ]; then
+        GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+        # Only append suffix if not already a local version
+        if ! echo "$VERSION" | grep -qE '\-local\.'; then
+            VERSION="${VERSION}-local.${GIT_SHORT_HASH}"
+        fi
+    fi
+    
+    # Validate semver format (allow -local.hash suffix)
+    if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
+        error "Invalid version format: $VERSION (expected semver like 0.1.0)"
+    fi
+    
+    TAG="cli-v$VERSION"
+    info "Version: $VERSION (tag: $TAG)"
+}
+
+# Extract changelog content for a specific version
+# Returns the content between the version header and the next version header (or EOF)
+get_changelog_content() {
+    CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md"
+    
+    if [ ! -f "$CHANGELOG_FILE" ]; then
+        warn "No CHANGELOG.md found at $CHANGELOG_FILE"
+        CHANGELOG_CONTENT=""
+        return
+    fi
+    
+    # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats)
+    # Also handles "Unreleased" marker
+    VERSION_PATTERN="^\#\# \[${VERSION}\]"
+    
+    # Check if the version exists in the changelog
+    if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then
+        warn "No changelog entry found for version $VERSION"
+        # Skip prompts for local builds
+        if [ "$LOCAL_BUILD" = true ]; then
+            info "Skipping changelog prompt for local build"
+            CHANGELOG_CONTENT=""
+            return
+        fi
+        warn "Please add an entry to $CHANGELOG_FILE before releasing"
+        echo ""
+        echo "Expected format:"
+        echo "  ## [$VERSION] - $(date +%Y-%m-%d)"
+        echo "  "
+        echo "  ### Added"
+        echo "  - Your changes here"
+        echo ""
+        read -p "Continue without changelog content? [y/N] " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+            error "Aborted. Please add a changelog entry and try again."
+        fi
+        CHANGELOG_CONTENT=""
+        return
+    fi
+    
+    # Extract content between this version and the next version header (or EOF)
+    # Uses awk to capture everything between ## [VERSION] and the next ## [
+    # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10)
+    CHANGELOG_CONTENT=$(awk -v version="$VERSION" '
+        BEGIN { found = 0; content = ""; target = "[" version "]" }
+        /^## \[/ {
+            if (found) { exit }
+            if (index($0, target) > 0) { found = 1; next }
+        }
+        found { content = content $0 "\n" }
+        END { print content }
+    ' "$CHANGELOG_FILE")
+    
+    # Trim leading/trailing whitespace
+    CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
+    
+    if [ -n "$CHANGELOG_CONTENT" ]; then
+        info "Found changelog content for version $VERSION"
+    else
+        warn "Changelog entry for $VERSION appears to be empty"
+    fi
+}
+
+# Build everything
+build() {
+    step "2/8" "Building extension bundle..."
+    cd "$REPO_ROOT"
+    pnpm bundle
+    
+    step "3/8" "Building CLI..."
+    pnpm --filter @roo-code/cli build
+    
+    info "Build complete"
+}
+
+# Create release tarball
+create_tarball() {
+    step "4/8" "Creating release tarball for $PLATFORM..."
+    
+    RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}"
+    TARBALL="roo-cli-${PLATFORM}.tar.gz"
+    
+    # Clean up any previous build
+    rm -rf "$RELEASE_DIR"
+    rm -f "$REPO_ROOT/$TARBALL"
+    
+    # Create directory structure
+    mkdir -p "$RELEASE_DIR/bin"
+    mkdir -p "$RELEASE_DIR/lib"
+    mkdir -p "$RELEASE_DIR/extension"
+    
+    # Copy CLI dist files
+    info "Copying CLI files..."
+    cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
+    
+    # Create package.json for npm install (runtime dependencies that can't be bundled)
+    info "Creating package.json..."
+    node -e "
+      const pkg = require('$CLI_DIR/package.json');
+      const newPkg = {
+        name: '@roo-code/cli',
+        version: '$VERSION',
+        type: 'module',
+        dependencies: {
+          '@inkjs/ui': pkg.dependencies['@inkjs/ui'],
+          '@trpc/client': pkg.dependencies['@trpc/client'],
+          'commander': pkg.dependencies.commander,
+          'fuzzysort': pkg.dependencies.fuzzysort,
+          'ink': pkg.dependencies.ink,
+          'react': pkg.dependencies.react,
+          'superjson': pkg.dependencies.superjson,
+          'zustand': pkg.dependencies.zustand
+        }
+      };
+      console.log(JSON.stringify(newPkg, null, 2));
+    " > "$RELEASE_DIR/package.json"
+    
+    # Copy extension bundle
+    info "Copying extension bundle..."
+    cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/"
+    
+    # Add package.json to extension directory to mark it as CommonJS
+    # This is necessary because the main package.json has "type": "module"
+    # but the extension bundle is CommonJS
+    echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
+    
+    # Find and copy ripgrep binary
+    # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg
+    # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there
+    info "Looking for ripgrep binary..."
+    RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
+    if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
+        info "Found ripgrep at: $RIPGREP_PATH"
+        # Create the expected directory structure for the extension to find ripgrep
+        mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
+        cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
+        chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
+        # Also keep a copy in bin/ for direct access
+        mkdir -p "$RELEASE_DIR/bin"
+        cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
+        chmod +x "$RELEASE_DIR/bin/rg"
+    else
+        warn "ripgrep binary not found - users will need ripgrep installed"
+    fi
+    
+    # Create the wrapper script
+    info "Creating wrapper script..."
+    cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF'
+#!/usr/bin/env node
+
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// Set environment variables for the CLI
+// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is)
+process.env.ROO_CLI_ROOT = join(__dirname, '..');
+process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
+process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
+
+// Import and run the actual CLI
+await import(join(__dirname, '..', 'lib', 'index.js'));
+WRAPPER_EOF
+
+    chmod +x "$RELEASE_DIR/bin/roo"
+    
+    # Create empty .env file to suppress dotenvx warnings
+    touch "$RELEASE_DIR/.env"
+    
+    # Create empty .env file to suppress dotenvx warnings
+    touch "$RELEASE_DIR/.env"
+    
+    # Create tarball
+    info "Creating tarball..."
+    cd "$REPO_ROOT"
+    tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
+    
+    # Clean up release directory
+    rm -rf "$RELEASE_DIR"
+    
+    # Show size
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}')
+    info "Created: $TARBALL ($TARBALL_SIZE)"
+}
+
+# Verify local installation
+verify_local_install() {
+    if [ "$SKIP_VERIFY" = true ]; then
+        step "5/8" "Skipping verification (--skip-verify)"
+        return
+    fi
+    
+    step "5/8" "Verifying local installation..."
+    
+    VERIFY_DIR="$REPO_ROOT/.verify-release"
+    VERIFY_INSTALL_DIR="$VERIFY_DIR/cli"
+    VERIFY_BIN_DIR="$VERIFY_DIR/bin"
+    
+    # Clean up any previous verification directory
+    rm -rf "$VERIFY_DIR"
+    mkdir -p "$VERIFY_DIR"
+    
+    # Run the actual install script with the local tarball
+    info "Running install script with local tarball..."
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    
+    ROO_LOCAL_TARBALL="$TARBALL_PATH" \
+    ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \
+    ROO_BIN_DIR="$VERIFY_BIN_DIR" \
+    ROO_VERSION="$VERSION" \
+    "$CLI_DIR/install.sh" || {
+        echo ""
+        warn "Install script failed. Showing tarball contents:"
+        tar -tzf "$TARBALL_PATH" 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "Installation verification failed! The install script could not complete successfully."
+    }
+    
+    # Verify the CLI runs correctly with basic commands
+    info "Testing installed CLI..."
+    
+    # Test --help
+    if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then
+        echo ""
+        warn "CLI --help output:"
+        "$VERIFY_BIN_DIR/roo" --help 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI --help check failed! The release tarball may have missing dependencies."
+    fi
+    info "CLI --help check passed"
+    
+    # Test --version
+    if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then
+        echo ""
+        warn "CLI --version output:"
+        "$VERIFY_BIN_DIR/roo" --version 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI --version check failed! The release tarball may have missing dependencies."
+    fi
+    info "CLI --version check passed"
+    
+    # Run a simple end-to-end test to verify the CLI actually works
+    info "Running end-to-end verification test..."
+    
+    # Create a temporary workspace for the test
+    VERIFY_WORKSPACE="$VERIFY_DIR/workspace"
+    mkdir -p "$VERIFY_WORKSPACE"
+    
+    # Run the CLI with a simple prompt
+    # Use timeout to prevent hanging if something goes wrong
+    if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then
+        info "End-to-end test passed"
+    else
+        EXIT_CODE=$?
+        echo ""
+        warn "End-to-end test failed (exit code: $EXIT_CODE). Output:"
+        cat "$VERIFY_DIR/test-output.log" 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI end-to-end test failed! The CLI may be broken."
+    fi
+    
+    # Clean up verification directory
+    cd "$REPO_ROOT"
+    rm -rf "$VERIFY_DIR"
+    
+    info "Local verification passed!"
+}
+
+# Create checksum
+create_checksum() {
+    step "6/8" "Creating checksum..."
+    cd "$REPO_ROOT"
+    
+    if command -v sha256sum &> /dev/null; then
+        sha256sum "$TARBALL" > "${TARBALL}.sha256"
+    elif command -v shasum &> /dev/null; then
+        shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
+    else
+        warn "No sha256sum or shasum found, skipping checksum"
+        return
+    fi
+    
+    info "Checksum: $(cat "${TARBALL}.sha256")"
+}
+
+# Check if release already exists
+check_existing_release() {
+    step "7/8" "Checking for existing release..."
+    
+    if gh release view "$TAG" &> /dev/null; then
+        warn "Release $TAG already exists"
+        read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r
+        echo
+        if [[ $REPLY =~ ^[Yy]$ ]]; then
+            info "Deleting existing release..."
+            gh release delete "$TAG" --yes
+            # Also delete the tag if it exists
+            git tag -d "$TAG" 2>/dev/null || true
+            git push origin ":refs/tags/$TAG" 2>/dev/null || true
+        else
+            error "Aborted. Use a different version or delete the existing release manually."
+        fi
+    fi
+}
+
+# Create GitHub release
+create_release() {
+    step "8/8" "Creating GitHub release..."
+    cd "$REPO_ROOT"
+
+    # Get the current commit SHA for the release target
+    COMMIT_SHA=$(git rev-parse HEAD)
+    
+    # Verify the commit exists on GitHub before attempting to create the release
+    # This prevents the "Release.target_commitish is invalid" error
+    info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..."
+    git fetch origin 2>/dev/null || true
+    if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then
+        warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub"
+        echo ""
+        echo "The release script needs to create a release at your current commit,"
+        echo "but this commit hasn't been pushed to GitHub yet."
+        echo ""
+        read -p "Push current branch to origin now? [Y/n] " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Nn]$ ]]; then
+            info "Pushing to origin..."
+            git push origin HEAD || error "Failed to push to origin. Please push manually and try again."
+        else
+            error "Aborted. Please push your commits to GitHub and try again."
+        fi
+    fi
+    info "Commit verified on GitHub"
+
+    # Build the What's New section from changelog content
+    WHATS_NEW_SECTION=""
+    if [ -n "$CHANGELOG_CONTENT" ]; then
+        WHATS_NEW_SECTION="## What's New
+
+$CHANGELOG_CONTENT
+
+"
+    fi
+    
+    RELEASE_NOTES=$(cat << EOF
+${WHATS_NEW_SECTION}## Installation
+
+\`\`\`bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+\`\`\`
+
+Or install a specific version:
+\`\`\`bash
+ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+\`\`\`
+
+## Requirements
+
+- Node.js 20 or higher
+- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
+
+## Usage
+
+\`\`\`bash
+# Set your API key
+export OPENROUTER_API_KEY=sk-or-v1-...
+
+# Run a task
+roo "What is this project?" ~/my-project
+
+# See all options
+roo --help
+\`\`\`
+
+## Platform Support
+
+This release includes:
+- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m)
+
+> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue.
+
+## Checksum
+
+\`\`\`
+$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A")
+\`\`\`
+EOF
+)
+
+    info "Creating release at commit: ${COMMIT_SHA:0:8}"
+    
+    # Create release (gh will create the tag automatically)
+    info "Creating release..."
+    RELEASE_FILES="$TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256"
+    fi
+    
+    gh release create "$TAG" \
+        --title "Roo Code CLI v$VERSION" \
+        --notes "$RELEASE_NOTES" \
+        --prerelease \
+        --target "$COMMIT_SHA" \
+        $RELEASE_FILES
+    
+    info "Release created!"
+}
+
+# Cleanup
+cleanup() {
+    info "Cleaning up..."
+    cd "$REPO_ROOT"
+    rm -f "$TARBALL" "${TARBALL}.sha256"
+}
+
+# Print summary
+print_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n"
+    echo ""
+    echo "  Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG"
+    echo ""
+    echo "  Install with:"
+    echo "    curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
+    echo ""
+}
+
+# Print dry-run summary
+print_dry_run_summary() {
+    echo ""
+    printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n"
+    echo ""
+    echo "  The following artifacts were created:"
+    echo "    - $TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        echo "    - ${TARBALL}.sha256"
+    fi
+    echo ""
+    echo "  To complete the release, run without --dry-run:"
+    echo "    ./apps/cli/scripts/release.sh $VERSION"
+    echo ""
+    echo "  Or manually upload the tarball to a new GitHub release."
+    echo ""
+}
+
+# Print local build summary
+print_local_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n"
+    echo ""
+    echo "  Tarball: $REPO_ROOT/$TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        echo "  Checksum: $REPO_ROOT/${TARBALL}.sha256"
+    fi
+    echo ""
+    echo "  To install manually:"
+    echo "    ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh"
+    echo ""
+    echo "  Or re-run with --install to install automatically:"
+    echo "    ./apps/cli/scripts/release.sh --local --install"
+    echo ""
+}
+
+# Install locally using the install script
+install_local() {
+    step "7/8" "Installing locally..."
+    
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    
+    ROO_LOCAL_TARBALL="$TARBALL_PATH" \
+    ROO_VERSION="$VERSION" \
+    "$CLI_DIR/install.sh" || {
+        error "Local installation failed!"
+    }
+    
+    info "Local installation complete!"
+}
+
+# Print local install summary
+print_local_install_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Local build installed for v$VERSION${NC}\n"
+    echo ""
+    echo "  Tarball: $REPO_ROOT/$TARBALL"
+    echo "  Installed to: ~/.roo/cli"
+    echo "  Binary: ~/.local/bin/roo"
+    echo ""
+    echo "  Test it out:"
+    echo "    roo --version"
+    echo "    roo --help"
+    echo ""
+}
+
+# Main
+main() {
+    echo ""
+    printf "${BLUE}${BOLD}"
+    echo "  ╭─────────────────────────────────╮"
+    echo "  │   Roo Code CLI Release Script   │"
+    echo "  ╰─────────────────────────────────╯"
+    printf "${NC}"
+    
+    if [ "$DRY_RUN" = true ]; then
+        printf "${YELLOW}         (DRY RUN MODE)${NC}\n"
+    elif [ "$LOCAL_BUILD" = true ]; then
+        printf "${YELLOW}         (LOCAL BUILD MODE)${NC}\n"
+    fi
+    echo ""
+    
+    detect_platform
+    check_prerequisites
+    get_version
+    get_changelog_content
+    build
+    create_tarball
+    verify_local_install
+    create_checksum
+    
+    if [ "$LOCAL_BUILD" = true ]; then
+        step "7/8" "Skipping GitHub checks (local build)"
+        if [ "$LOCAL_INSTALL" = true ]; then
+            install_local
+            print_local_install_summary
+        else
+            step "8/8" "Skipping installation (use --install to auto-install)"
+            print_local_summary
+        fi
+    elif [ "$DRY_RUN" = true ]; then
+        step "7/8" "Skipping existing release check (dry run)"
+        step "8/8" "Skipping GitHub release creation (dry run)"
+        print_dry_run_summary
+    else
+        check_existing_release
+        create_release
+        cleanup
+        print_summary
+    fi
+}
+
+main

+ 126 - 0
apps/cli/src/__tests__/index.test.ts

@@ -0,0 +1,126 @@
+/**
+ * Integration tests for CLI
+ *
+ * These tests require:
+ * 1. RUN_CLI_INTEGRATION_TESTS=true environment variable (opt-in)
+ * 2. A valid OPENROUTER_API_KEY environment variable
+ * 3. A built CLI at apps/cli/dist (will auto-build if missing)
+ * 4. A built extension at src/dist (will auto-build if missing)
+ *
+ * Run with: RUN_CLI_INTEGRATION_TESTS=true OPENROUTER_API_KEY=sk-or-v1-... pnpm test
+ */
+
+// pnpm --filter @roo-code/cli test src/__tests__/index.test.ts
+
+import path from "path"
+import fs from "fs"
+import { execSync, spawn, type ChildProcess } from "child_process"
+import { fileURLToPath } from "url"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+const RUN_INTEGRATION_TESTS = process.env.RUN_CLI_INTEGRATION_TESTS === "true"
+const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
+const hasApiKey = !!OPENROUTER_API_KEY
+
+function findCliRoot(): string {
+	// From apps/cli/src/__tests__, go up to apps/cli.
+	return path.resolve(__dirname, "../..")
+}
+
+function findMonorepoRoot(): string {
+	// From apps/cli/src/__tests__, go up to monorepo root.
+	return path.resolve(__dirname, "../../../..")
+}
+
+function isCliBuilt(): boolean {
+	return fs.existsSync(path.join(findCliRoot(), "dist", "index.js"))
+}
+
+function isExtensionBuilt(): boolean {
+	const monorepoRoot = findMonorepoRoot()
+	const extensionPath = path.join(monorepoRoot, "src/dist")
+	return fs.existsSync(path.join(extensionPath, "extension.js"))
+}
+
+function buildCliIfNeeded(): void {
+	if (!isCliBuilt()) {
+		execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" })
+		console.log("CLI build complete.")
+	}
+}
+
+function buildExtensionIfNeeded(): void {
+	if (!isExtensionBuilt()) {
+		execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" })
+		console.log("Extension build complete.")
+	}
+}
+
+function runCli(
+	args: string[],
+	options: { timeout?: number } = {},
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+	return new Promise((resolve) => {
+		const timeout = options.timeout ?? 60000
+
+		let stdout = ""
+		let stderr = ""
+		let timedOut = false
+
+		const proc: ChildProcess = spawn("pnpm", ["start", ...args], {
+			cwd: findCliRoot(),
+			env: { ...process.env, OPENROUTER_API_KEY, NO_COLOR: "1", FORCE_COLOR: "0" },
+			stdio: ["pipe", "pipe", "pipe"],
+		})
+
+		const timeoutId = setTimeout(() => {
+			timedOut = true
+			proc.kill("SIGTERM")
+		}, timeout)
+
+		proc.stdout?.on("data", (data: Buffer) => {
+			stdout += data.toString()
+		})
+
+		proc.stderr?.on("data", (data: Buffer) => {
+			stderr += data.toString()
+		})
+
+		proc.on("close", (code: number | null) => {
+			clearTimeout(timeoutId)
+			resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) })
+		})
+
+		proc.on("error", (error: Error) => {
+			clearTimeout(timeoutId)
+			stderr += error.message
+			resolve({ stdout, stderr, exitCode: 1 })
+		})
+	})
+}
+
+describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => {
+	beforeAll(() => {
+		buildExtensionIfNeeded()
+		buildCliIfNeeded()
+	})
+
+	it("should complete end-to-end task execution via CLI", async () => {
+		const result = await runCli(
+			["--no-tui", "-m", "anthropic/claude-sonnet-4.5", "-M", "ask", "-r", "disabled", "-P", "1+1=?"],
+			{ timeout: 30_000 },
+		)
+
+		console.log("CLI stdout:", result.stdout)
+
+		if (result.stderr) {
+			console.log("CLI stderr:", result.stderr)
+		}
+
+		expect(result.exitCode).toBe(0)
+		expect(result.stdout).toContain("2")
+		expect(result.stdout).toContain("[task complete]")
+	}, 30_000)
+})

+ 858 - 0
apps/cli/src/agent/__tests__/extension-client.test.ts

@@ -0,0 +1,858 @@
+import {
+	type ClineMessage,
+	type ExtensionMessage,
+	isIdleAsk,
+	isResumableAsk,
+	isInteractiveAsk,
+	isNonBlockingAsk,
+} from "@roo-code/types"
+
+import { AgentLoopState, detectAgentState } from "../agent-state.js"
+import { createMockClient } from "../extension-client.js"
+
+function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
+	return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
+}
+
+function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage {
+	return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage
+}
+
+describe("detectAgentState", () => {
+	describe("NO_TASK state", () => {
+		it("should return NO_TASK for empty messages array", () => {
+			const state = detectAgentState([])
+			expect(state.state).toBe(AgentLoopState.NO_TASK)
+			expect(state.isWaitingForInput).toBe(false)
+			expect(state.isRunning).toBe(false)
+		})
+
+		it("should return NO_TASK for undefined messages", () => {
+			const state = detectAgentState(undefined as unknown as ClineMessage[])
+			expect(state.state).toBe(AgentLoopState.NO_TASK)
+		})
+	})
+
+	describe("STREAMING state", () => {
+		it("should detect streaming when partial is true", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: true })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+			expect(state.isWaitingForInput).toBe(false)
+		})
+
+		it("should detect streaming when api_req_started has no cost", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ tokensIn: 100 }), // No cost field.
+				}),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should NOT be streaming when api_req_started has cost", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001, tokensIn: 100 }),
+				}),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+	})
+
+	describe("WAITING_FOR_INPUT state", () => {
+		it("should detect waiting for tool approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.currentAsk).toBe("tool")
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for command approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "command", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("command")
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for followup answer", () => {
+			const messages = [createMessage({ type: "ask", ask: "followup", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+			expect(state.requiredAction).toBe("answer")
+		})
+
+		it("should detect waiting for browser_action_launch approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for use_mcp_server approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.requiredAction).toBe("approve")
+		})
+	})
+
+	describe("IDLE state", () => {
+		it("should detect completion_result as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "completion_result", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.requiredAction).toBe("start_task")
+		})
+
+		it("should detect api_req_failed as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "api_req_failed", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("retry_or_new_task")
+		})
+
+		it("should detect mistake_limit_reached as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("proceed_or_new_task")
+		})
+
+		it("should detect auto_approval_max_req_reached as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "auto_approval_max_req_reached", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("start_new_task")
+		})
+
+		it("should detect resume_completed_task as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("start_new_task")
+		})
+	})
+
+	describe("RESUMABLE state", () => {
+		it("should detect resume_task as resumable", () => {
+			const messages = [createMessage({ type: "ask", ask: "resume_task", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RESUMABLE)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.requiredAction).toBe("resume_or_abandon")
+		})
+	})
+
+	describe("RUNNING state", () => {
+		it("should detect running for say messages", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "Working on it..." }),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isRunning).toBe(true)
+			expect(state.isWaitingForInput).toBe(false)
+		})
+
+		it("should detect running for command_output (non-blocking)", () => {
+			const messages = [createMessage({ type: "ask", ask: "command_output", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.requiredAction).toBe("continue_or_abort")
+		})
+	})
+})
+
+describe("Type Guards", () => {
+	describe("isIdleAsk", () => {
+		it("should return true for idle asks", () => {
+			expect(isIdleAsk("completion_result")).toBe(true)
+			expect(isIdleAsk("api_req_failed")).toBe(true)
+			expect(isIdleAsk("mistake_limit_reached")).toBe(true)
+			expect(isIdleAsk("auto_approval_max_req_reached")).toBe(true)
+			expect(isIdleAsk("resume_completed_task")).toBe(true)
+		})
+
+		it("should return false for non-idle asks", () => {
+			expect(isIdleAsk("tool")).toBe(false)
+			expect(isIdleAsk("followup")).toBe(false)
+			expect(isIdleAsk("resume_task")).toBe(false)
+		})
+	})
+
+	describe("isInteractiveAsk", () => {
+		it("should return true for interactive asks", () => {
+			expect(isInteractiveAsk("tool")).toBe(true)
+			expect(isInteractiveAsk("command")).toBe(true)
+			expect(isInteractiveAsk("followup")).toBe(true)
+			expect(isInteractiveAsk("browser_action_launch")).toBe(true)
+			expect(isInteractiveAsk("use_mcp_server")).toBe(true)
+		})
+
+		it("should return false for non-interactive asks", () => {
+			expect(isInteractiveAsk("completion_result")).toBe(false)
+			expect(isInteractiveAsk("command_output")).toBe(false)
+		})
+	})
+
+	describe("isResumableAsk", () => {
+		it("should return true for resumable asks", () => {
+			expect(isResumableAsk("resume_task")).toBe(true)
+		})
+
+		it("should return false for non-resumable asks", () => {
+			expect(isResumableAsk("completion_result")).toBe(false)
+			expect(isResumableAsk("tool")).toBe(false)
+		})
+	})
+
+	describe("isNonBlockingAsk", () => {
+		it("should return true for non-blocking asks", () => {
+			expect(isNonBlockingAsk("command_output")).toBe(true)
+		})
+
+		it("should return false for blocking asks", () => {
+			expect(isNonBlockingAsk("tool")).toBe(false)
+			expect(isNonBlockingAsk("followup")).toBe(false)
+		})
+	})
+})
+
+describe("ExtensionClient", () => {
+	describe("State queries", () => {
+		it("should return NO_TASK when not initialized", () => {
+			const { client } = createMockClient()
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+			expect(client.isInitialized()).toBe(false)
+		})
+
+		it("should update state when receiving messages", () => {
+			const { client } = createMockClient()
+
+			const message = createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])
+
+			client.handleMessage(message)
+
+			expect(client.isInitialized()).toBe(true)
+			expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(client.isWaitingForInput()).toBe(true)
+			expect(client.getCurrentAsk()).toBe("tool")
+		})
+	})
+
+	describe("Event emission", () => {
+		it("should emit stateChange events", () => {
+			const { client } = createMockClient()
+			const stateChanges: AgentLoopState[] = []
+
+			client.onStateChange((event) => {
+				stateChanges.push(event.currentState.state)
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+
+			expect(stateChanges).toContain(AgentLoopState.WAITING_FOR_INPUT)
+		})
+
+		it("should emit waitingForInput events", () => {
+			const { client } = createMockClient()
+			const waitingEvents: string[] = []
+
+			client.onWaitingForInput((event) => {
+				waitingEvents.push(event.ask)
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "followup", partial: false })]))
+
+			expect(waitingEvents).toContain("followup")
+		})
+
+		it("should allow unsubscribing from events", () => {
+			const { client } = createMockClient()
+			let callCount = 0
+
+			const unsubscribe = client.onStateChange(() => {
+				callCount++
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
+			expect(callCount).toBe(1)
+
+			unsubscribe()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
+			expect(callCount).toBe(1) // Should not increase.
+		})
+
+		it("should emit modeChanged events", () => {
+			const { client } = createMockClient()
+			const modeChanges: { previousMode: string | undefined; currentMode: string }[] = []
+
+			client.onModeChanged((event) => {
+				modeChanges.push(event)
+			})
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+
+			expect(modeChanges).toHaveLength(1)
+			expect(modeChanges[0]).toEqual({ previousMode: undefined, currentMode: "code" })
+
+			// Change mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
+
+			expect(modeChanges).toHaveLength(2)
+			expect(modeChanges[1]).toEqual({ previousMode: "code", currentMode: "architect" })
+		})
+
+		it("should not emit modeChanged when mode stays the same", () => {
+			const { client } = createMockClient()
+			let modeChangeCount = 0
+
+			client.onModeChanged(() => {
+				modeChangeCount++
+			})
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(modeChangeCount).toBe(1)
+
+			// Same mode - should not emit
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "code"))
+			expect(modeChangeCount).toBe(1)
+		})
+	})
+
+	describe("Response methods", () => {
+		it("should send approve response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.approve()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "askResponse",
+				askResponse: "yesButtonClicked",
+				text: undefined,
+				images: undefined,
+			})
+		})
+
+		it("should send reject response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.reject()
+
+			expect(sentMessages).toHaveLength(1)
+			const msg = sentMessages[0]
+			expect(msg).toBeDefined()
+			expect(msg?.askResponse).toBe("noButtonClicked")
+		})
+
+		it("should send text response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.respond("My answer", ["image-data"])
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "askResponse",
+				askResponse: "messageResponse",
+				text: "My answer",
+				images: ["image-data"],
+			})
+		})
+
+		it("should send newTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.newTask("Build a web app")
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "newTask",
+				text: "Build a web app",
+				images: undefined,
+			})
+		})
+
+		it("should send clearTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.clearTask()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "clearTask",
+			})
+		})
+
+		it("should send cancelTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.cancelTask()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "cancelTask",
+			})
+		})
+
+		it("should send terminal continue operation", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.continueTerminal()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "terminalOperation",
+				terminalOperation: "continue",
+			})
+		})
+
+		it("should send terminal abort operation", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.abortTerminal()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "terminalOperation",
+				terminalOperation: "abort",
+			})
+		})
+	})
+
+	describe("Message handling", () => {
+		it("should handle JSON string messages", () => {
+			const { client } = createMockClient()
+
+			const message = JSON.stringify(
+				createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
+			)
+
+			client.handleMessage(message)
+
+			expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
+		})
+
+		it("should ignore invalid JSON", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage("not valid json")
+
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should handle messageUpdated messages", () => {
+			const { client } = createMockClient()
+
+			// First, set initial state.
+			client.handleMessage(
+				createStateMessage([createMessage({ ts: 123, type: "ask", ask: "tool", partial: true })]),
+			)
+
+			expect(client.isStreaming()).toBe(true)
+
+			// Now update the message.
+			client.handleMessage({
+				type: "messageUpdated",
+				clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }),
+			})
+
+			expect(client.isStreaming()).toBe(false)
+			expect(client.isWaitingForInput()).toBe(true)
+		})
+	})
+
+	describe("Reset functionality", () => {
+		it("should reset state", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+
+			expect(client.isInitialized()).toBe(true)
+			expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
+
+			client.reset()
+
+			expect(client.isInitialized()).toBe(false)
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should reset mode on reset", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			client.reset()
+
+			expect(client.getCurrentMode()).toBeUndefined()
+		})
+	})
+
+	describe("Mode tracking", () => {
+		it("should return undefined mode when not initialized", () => {
+			const { client } = createMockClient()
+			expect(client.getCurrentMode()).toBeUndefined()
+		})
+
+		it("should track mode from state messages", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+
+			expect(client.getCurrentMode()).toBe("code")
+		})
+
+		it("should update mode when it changes", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "architect"))
+			expect(client.getCurrentMode()).toBe("architect")
+		})
+
+		it("should preserve mode when state message has no mode", () => {
+			const { client } = createMockClient()
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			// State update without mode - should preserve existing mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
+			expect(client.getCurrentMode()).toBe("code")
+		})
+
+		it("should preserve mode when task is cleared", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
+			expect(client.getCurrentMode()).toBe("architect")
+
+			client.clearTask()
+			// Mode should be preserved after clear
+			expect(client.getCurrentMode()).toBe("architect")
+		})
+	})
+})
+
+describe("Integration", () => {
+	it("should handle a complete task flow", () => {
+		const { client } = createMockClient()
+		const states: AgentLoopState[] = []
+
+		client.onStateChange((event) => {
+			states.push(event.currentState.state)
+		})
+
+		// 1. Task starts, API request begins.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({}), // No cost = streaming.
+				}),
+			]),
+		)
+		expect(client.isStreaming()).toBe(true)
+
+		// 2. API request completes.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+			]),
+		)
+		expect(client.isStreaming()).toBe(false)
+		expect(client.isRunning()).toBe(true)
+
+		// 3. Tool ask (partial).
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: true }),
+			]),
+		)
+		expect(client.isStreaming()).toBe(true)
+
+		// 4. Tool ask (complete).
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+			]),
+		)
+		expect(client.isWaitingForInput()).toBe(true)
+		expect(client.getCurrentAsk()).toBe("tool")
+
+		// 5. User approves, task completes.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+				createMessage({ say: "text", text: "File created." }),
+				createMessage({ type: "ask", ask: "completion_result", partial: false }),
+			]),
+		)
+		expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
+		expect(client.getCurrentAsk()).toBe("completion_result")
+
+		// Verify we saw the expected state transitions.
+		expect(states).toContain(AgentLoopState.STREAMING)
+		expect(states).toContain(AgentLoopState.RUNNING)
+		expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
+		expect(states).toContain(AgentLoopState.IDLE)
+	})
+})
+
+describe("Edge Cases", () => {
+	describe("Messages with missing or empty text field", () => {
+		it("should handle ask message with missing text field", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
+			// Text is undefined by default.
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("tool")
+		})
+
+		it("should handle ask message with empty text field", () => {
+			const messages = [createMessage({ type: "ask", ask: "followup", partial: false, text: "" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+		})
+
+		it("should handle say message with missing text field", () => {
+			const messages = [createMessage({ say: "text" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+	})
+
+	describe("api_req_started edge cases", () => {
+		it("should handle api_req_started with empty text field as streaming", () => {
+			const messages = [createMessage({ say: "api_req_started", text: "" })]
+			const state = detectAgentState(messages)
+			// Empty text is treated as "no text yet" = still in progress (streaming).
+			// This matches the behavior: !message.text is true for "" (falsy).
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should handle api_req_started with invalid JSON", () => {
+			const messages = [createMessage({ say: "api_req_started", text: "not valid json" })]
+			const state = detectAgentState(messages)
+			// Invalid JSON should not crash, should return not streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should handle api_req_started with null text", () => {
+			const messages = [createMessage({ say: "api_req_started", text: undefined })]
+			const state = detectAgentState(messages)
+			// No text means still in progress (streaming).
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should handle api_req_started with cost of 0", () => {
+			const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: 0 }) })]
+			const state = detectAgentState(messages)
+			// cost: 0 is defined (not undefined), so NOT streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should handle api_req_started with cost of null", () => {
+			const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: null }) })]
+			const state = detectAgentState(messages)
+			// cost: null is defined (not undefined), so NOT streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should find api_req_started when it's not the last message", () => {
+			const messages = [
+				createMessage({ say: "api_req_started", text: JSON.stringify({ tokensIn: 100 }) }), // No cost = streaming
+				createMessage({ say: "text", text: "Some text" }),
+			]
+			const state = detectAgentState(messages)
+			// Last message is say:text, but api_req_started has no cost.
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+	})
+
+	describe("Rapid state transitions", () => {
+		it("should handle multiple rapid state changes", () => {
+			const { client } = createMockClient()
+			const states: AgentLoopState[] = []
+
+			client.onStateChange((event) => {
+				states.push(event.currentState.state)
+			})
+
+			// Rapid updates.
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: true })]))
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+			client.handleMessage(
+				createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
+			)
+
+			// Should have tracked all transitions.
+			expect(states.length).toBeGreaterThanOrEqual(3)
+			expect(states).toContain(AgentLoopState.STREAMING)
+			expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
+			expect(states).toContain(AgentLoopState.IDLE)
+		})
+	})
+
+	describe("Message array edge cases", () => {
+		it("should handle single message array", () => {
+			const messages = [createMessage({ say: "text", text: "Hello" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.lastMessage).toBeDefined()
+			expect(state.lastMessageTs).toBe(messages[0]!.ts)
+		})
+
+		it("should use last message for state detection", () => {
+			// Multiple messages, last one determines state.
+			const messages = [
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+				createMessage({ say: "text", text: "Tool executed" }),
+				createMessage({ type: "ask", ask: "completion_result", partial: false }),
+			]
+			const state = detectAgentState(messages)
+			// Last message is completion_result, so IDLE.
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.currentAsk).toBe("completion_result")
+		})
+
+		it("should handle very long message arrays", () => {
+			// Create many messages.
+			const messages: ClineMessage[] = []
+
+			for (let i = 0; i < 100; i++) {
+				messages.push(createMessage({ say: "text", text: `Message ${i}` }))
+			}
+
+			messages.push(createMessage({ type: "ask", ask: "followup", partial: false }))
+
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+		})
+	})
+
+	describe("State message edge cases", () => {
+		it("should handle state message with empty clineMessages", () => {
+			const { client } = createMockClient()
+			client.handleMessage({ type: "state", state: { clineMessages: [] } } as unknown as ExtensionMessage)
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+			expect(client.isInitialized()).toBe(true)
+		})
+
+		it("should handle state message with missing clineMessages", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage({
+				type: "state",
+				// eslint-disable-next-line @typescript-eslint/no-explicit-any
+				state: {} as any,
+			})
+
+			// Should not crash, state should remain unchanged.
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should handle state message with missing state field", () => {
+			const { client } = createMockClient()
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			client.handleMessage({ type: "state" } as any)
+
+			// Should not crash
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+	})
+
+	describe("Partial to complete transitions", () => {
+		it("should transition from streaming to waiting when partial becomes false", () => {
+			const ts = Date.now()
+			const messages1 = [createMessage({ ts, type: "ask", ask: "tool", partial: true })]
+			const messages2 = [createMessage({ ts, type: "ask", ask: "tool", partial: false })]
+
+			const state1 = detectAgentState(messages1)
+			const state2 = detectAgentState(messages2)
+
+			expect(state1.state).toBe(AgentLoopState.STREAMING)
+			expect(state1.isWaitingForInput).toBe(false)
+
+			expect(state2.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state2.isWaitingForInput).toBe(true)
+		})
+
+		it("should handle partial say messages", () => {
+			const messages = [createMessage({ say: "text", text: "Typing...", partial: true })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+	})
+
+	describe("Unknown message types", () => {
+		it("should handle unknown ask types gracefully", () => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const messages = [createMessage({ type: "ask", ask: "unknown_type" as any, partial: false })]
+			const state = detectAgentState(messages)
+			// Unknown ask type should default to RUNNING.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+
+		it("should handle unknown say types gracefully", () => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const messages = [createMessage({ say: "unknown_say_type" as any })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+	})
+})

+ 596 - 0
apps/cli/src/agent/__tests__/extension-host.test.ts

@@ -0,0 +1,596 @@
+// pnpm --filter @roo-code/cli test src/agent/__tests__/extension-host.test.ts
+
+import { EventEmitter } from "events"
+import fs from "fs"
+
+import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
+
+import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
+import { ExtensionClient } from "../extension-client.js"
+import { AgentLoopState } from "../agent-state.js"
+
+vi.mock("@roo-code/vscode-shim", () => ({
+	createVSCodeAPI: vi.fn(() => ({
+		context: { extensionPath: "/test/extension" },
+	})),
+	setRuntimeConfigValues: vi.fn(),
+}))
+
+vi.mock("@/lib/storage/index.js", () => ({
+	createEphemeralStorageDir: vi.fn(() => Promise.resolve("/tmp/roo-cli-test-ephemeral")),
+}))
+
+/**
+ * Create a test ExtensionHost with default options.
+ */
+function createTestHost({
+	mode = "code",
+	provider = "openrouter",
+	model = "test-model",
+	...options
+}: Partial<ExtensionHostOptions> = {}): ExtensionHost {
+	return new ExtensionHost({
+		mode,
+		user: null,
+		provider,
+		model,
+		workspacePath: "/test/workspace",
+		extensionPath: "/test/extension",
+		...options,
+	})
+}
+
+// Type for accessing private members
+type PrivateHost = Record<string, unknown>
+
+/**
+ * Helper to access private members for testing
+ */
+function getPrivate<T>(host: ExtensionHost, key: string): T {
+	return (host as unknown as PrivateHost)[key] as T
+}
+
+/**
+ * Helper to set private members for testing
+ */
+function setPrivate(host: ExtensionHost, key: string, value: unknown): void {
+	;(host as unknown as PrivateHost)[key] = value
+}
+
+/**
+ * Helper to call private methods for testing
+ * This uses a more permissive type to avoid TypeScript errors with private methods
+ */
+function callPrivate<T>(host: ExtensionHost, method: string, ...args: unknown[]): T {
+	const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined
+	if (!fn) throw new Error(`Method ${method} not found`)
+	return fn.apply(host, args)
+}
+
+/**
+ * Helper to spy on private methods
+ * This uses a more permissive type to avoid TypeScript errors with vi.spyOn on private methods
+ */
+function spyOnPrivate(host: ExtensionHost, method: string) {
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	return vi.spyOn(host as any, method)
+}
+
+describe("ExtensionHost", () => {
+	beforeEach(() => {
+		vi.resetAllMocks()
+		// Clean up globals
+		delete (global as Record<string, unknown>).vscode
+		delete (global as Record<string, unknown>).__extensionHost
+	})
+
+	describe("constructor", () => {
+		it("should store options correctly", () => {
+			const options: ExtensionHostOptions = {
+				mode: "code",
+				workspacePath: "/my/workspace",
+				extensionPath: "/my/extension",
+				user: null,
+				apiKey: "test-key",
+				provider: "openrouter",
+				model: "test-model",
+			}
+
+			const host = new ExtensionHost(options)
+
+			// Options are stored but integrationTest is set to true
+			const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(storedOptions.mode).toBe(options.mode)
+			expect(storedOptions.workspacePath).toBe(options.workspacePath)
+			expect(storedOptions.extensionPath).toBe(options.extensionPath)
+			expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
+		})
+
+		it("should be an EventEmitter instance", () => {
+			const host = createTestHost()
+			expect(host).toBeInstanceOf(EventEmitter)
+		})
+
+		it("should initialize with default state values", () => {
+			const host = createTestHost()
+
+			expect(getPrivate(host, "isReady")).toBe(false)
+			expect(getPrivate(host, "vscode")).toBeNull()
+			expect(getPrivate(host, "extensionModule")).toBeNull()
+		})
+
+		it("should initialize managers", () => {
+			const host = createTestHost()
+
+			// Should have client, outputManager, promptManager, and askDispatcher
+			expect(getPrivate(host, "client")).toBeDefined()
+			expect(getPrivate(host, "outputManager")).toBeDefined()
+			expect(getPrivate(host, "promptManager")).toBeDefined()
+			expect(getPrivate(host, "askDispatcher")).toBeDefined()
+		})
+	})
+
+	describe("webview provider registration", () => {
+		it("should register webview provider without throwing", () => {
+			const host = createTestHost()
+			const mockProvider = { resolveWebviewView: vi.fn() }
+
+			// registerWebviewProvider is now a no-op, just ensure it doesn't throw
+			expect(() => {
+				host.registerWebviewProvider("test-view", mockProvider)
+			}).not.toThrow()
+		})
+
+		it("should unregister webview provider without throwing", () => {
+			const host = createTestHost()
+			const mockProvider = { resolveWebviewView: vi.fn() }
+
+			host.registerWebviewProvider("test-view", mockProvider)
+
+			// unregisterWebviewProvider is now a no-op, just ensure it doesn't throw
+			expect(() => {
+				host.unregisterWebviewProvider("test-view")
+			}).not.toThrow()
+		})
+
+		it("should handle unregistering non-existent provider gracefully", () => {
+			const host = createTestHost()
+
+			expect(() => {
+				host.unregisterWebviewProvider("non-existent")
+			}).not.toThrow()
+		})
+	})
+
+	describe("webview ready state", () => {
+		describe("isInInitialSetup", () => {
+			it("should return true before webview is ready", () => {
+				const host = createTestHost()
+				expect(host.isInInitialSetup()).toBe(true)
+			})
+
+			it("should return false after markWebviewReady is called", () => {
+				const host = createTestHost()
+				host.markWebviewReady()
+				expect(host.isInInitialSetup()).toBe(false)
+			})
+		})
+
+		describe("markWebviewReady", () => {
+			it("should set isReady to true", () => {
+				const host = createTestHost()
+				host.markWebviewReady()
+				expect(getPrivate(host, "isReady")).toBe(true)
+			})
+
+			it("should send webviewDidLaunch message", () => {
+				const host = createTestHost()
+				const emitSpy = vi.spyOn(host, "emit")
+
+				host.markWebviewReady()
+
+				expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "webviewDidLaunch" })
+			})
+
+			it("should send updateSettings message", () => {
+				const host = createTestHost()
+				const emitSpy = vi.spyOn(host, "emit")
+
+				host.markWebviewReady()
+
+				// Check that updateSettings was called
+				const updateSettingsCall = emitSpy.mock.calls.find(
+					(call) =>
+						call[0] === "webviewMessage" &&
+						typeof call[1] === "object" &&
+						call[1] !== null &&
+						(call[1] as WebviewMessage).type === "updateSettings",
+				)
+				expect(updateSettingsCall).toBeDefined()
+			})
+		})
+	})
+
+	describe("sendToExtension", () => {
+		it("should throw error when extension not ready", () => {
+			const host = createTestHost()
+			const message: WebviewMessage = { type: "requestModes" }
+
+			expect(() => {
+				host.sendToExtension(message)
+			}).toThrow("You cannot send messages to the extension before it is ready")
+		})
+
+		it("should emit webviewMessage event when webview is ready", () => {
+			const host = createTestHost()
+			const emitSpy = vi.spyOn(host, "emit")
+			const message: WebviewMessage = { type: "requestModes" }
+
+			host.markWebviewReady()
+			emitSpy.mockClear() // Clear the markWebviewReady calls
+			host.sendToExtension(message)
+
+			expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message)
+		})
+
+		it("should not throw when webview is ready", () => {
+			const host = createTestHost()
+
+			host.markWebviewReady()
+
+			expect(() => {
+				host.sendToExtension({ type: "requestModes" })
+			}).not.toThrow()
+		})
+	})
+
+	describe("message handling via client", () => {
+		it("should forward extension messages to the client", () => {
+			const host = createTestHost()
+			const client = getPrivate(host, "client") as ExtensionClient
+
+			// Simulate extension message.
+			host.emit("extensionWebviewMessage", {
+				type: "state",
+				state: { clineMessages: [] },
+			} as unknown as ExtensionMessage)
+
+			// Message listener is set up in activate(), which we can't easily call in unit tests.
+			// But we can verify the client exists and has the handleMessage method.
+			expect(typeof client.handleMessage).toBe("function")
+		})
+	})
+
+	describe("public agent state API", () => {
+		it("should return agent state from getAgentState()", () => {
+			const host = createTestHost()
+			const state = host.getAgentState()
+
+			expect(state).toBeDefined()
+			expect(state.state).toBeDefined()
+			expect(state.isWaitingForInput).toBeDefined()
+			expect(state.isRunning).toBeDefined()
+		})
+
+		it("should return isWaitingForInput() status", () => {
+			const host = createTestHost()
+			expect(typeof host.isWaitingForInput()).toBe("boolean")
+		})
+	})
+
+	describe("quiet mode", () => {
+		describe("setupQuietMode", () => {
+			it("should not modify console when integrationTest is true", () => {
+				// By default, constructor sets integrationTest = true
+				const host = createTestHost()
+				const originalLog = console.log
+
+				callPrivate(host, "setupQuietMode")
+
+				// Console should not be modified since integrationTest is true
+				expect(console.log).toBe(originalLog)
+			})
+
+			it("should suppress console when integrationTest is false", () => {
+				const host = createTestHost()
+				const originalLog = console.log
+
+				// Override integrationTest to false
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+
+				// Console should be modified
+				expect(console.log).not.toBe(originalLog)
+
+				// Restore for other tests
+				callPrivate(host, "restoreConsole")
+			})
+
+			it("should preserve console.error even when suppressing", () => {
+				const host = createTestHost()
+				const originalError = console.error
+
+				// Override integrationTest to false
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+
+				expect(console.error).toBe(originalError)
+
+				callPrivate(host, "restoreConsole")
+			})
+		})
+
+		describe("restoreConsole", () => {
+			it("should restore original console methods when suppressed", () => {
+				const host = createTestHost()
+				const originalLog = console.log
+
+				// Override integrationTest to false to actually suppress
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+				callPrivate(host, "restoreConsole")
+
+				expect(console.log).toBe(originalLog)
+			})
+
+			it("should handle case where console was not suppressed", () => {
+				const host = createTestHost()
+
+				expect(() => {
+					callPrivate(host, "restoreConsole")
+				}).not.toThrow()
+			})
+		})
+	})
+
+	describe("dispose", () => {
+		let host: ExtensionHost
+
+		beforeEach(() => {
+			host = createTestHost()
+		})
+
+		it("should remove message listener", async () => {
+			const listener = vi.fn()
+			setPrivate(host, "messageListener", listener)
+			host.on("extensionWebviewMessage", listener)
+
+			await host.dispose()
+
+			expect(getPrivate(host, "messageListener")).toBeNull()
+		})
+
+		it("should call extension deactivate if available", async () => {
+			const deactivateMock = vi.fn()
+			setPrivate(host, "extensionModule", {
+				deactivate: deactivateMock,
+			})
+
+			await host.dispose()
+
+			expect(deactivateMock).toHaveBeenCalled()
+		})
+
+		it("should clear vscode reference", async () => {
+			setPrivate(host, "vscode", { context: {} })
+
+			await host.dispose()
+
+			expect(getPrivate(host, "vscode")).toBeNull()
+		})
+
+		it("should clear extensionModule reference", async () => {
+			setPrivate(host, "extensionModule", {})
+
+			await host.dispose()
+
+			expect(getPrivate(host, "extensionModule")).toBeNull()
+		})
+
+		it("should delete global vscode", async () => {
+			;(global as Record<string, unknown>).vscode = {}
+
+			await host.dispose()
+
+			expect((global as Record<string, unknown>).vscode).toBeUndefined()
+		})
+
+		it("should delete global __extensionHost", async () => {
+			;(global as Record<string, unknown>).__extensionHost = {}
+
+			await host.dispose()
+
+			expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
+		})
+
+		it("should call restoreConsole", async () => {
+			const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole")
+
+			await host.dispose()
+
+			expect(restoreConsoleSpy).toHaveBeenCalled()
+		})
+	})
+
+	describe("runTask", () => {
+		it("should send newTask message when called", async () => {
+			const host = createTestHost()
+			host.markWebviewReady()
+
+			const emitSpy = vi.spyOn(host, "emit")
+			const client = getPrivate(host, "client") as ExtensionClient
+
+			// Start the task (will hang waiting for completion)
+			const taskPromise = host.runTask("test prompt")
+
+			// Emit completion to resolve the promise via the client's emitter
+			const taskCompletedEvent = {
+				success: true,
+				stateInfo: {
+					state: AgentLoopState.IDLE,
+					isWaitingForInput: false,
+					isRunning: false,
+					isStreaming: false,
+					requiredAction: "start_task" as const,
+					description: "Task completed",
+				},
+			}
+			setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
+
+			await taskPromise
+
+			expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
+		})
+
+		it("should resolve when taskCompleted is emitted on client", async () => {
+			const host = createTestHost()
+			host.markWebviewReady()
+
+			const client = getPrivate(host, "client") as ExtensionClient
+			const taskPromise = host.runTask("test prompt")
+
+			// Emit completion after a short delay via the client's emitter
+			const taskCompletedEvent = {
+				success: true,
+				stateInfo: {
+					state: AgentLoopState.IDLE,
+					isWaitingForInput: false,
+					isRunning: false,
+					isStreaming: false,
+					requiredAction: "start_task" as const,
+					description: "Task completed",
+				},
+			}
+			setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
+
+			await expect(taskPromise).resolves.toBeUndefined()
+		})
+	})
+
+	describe("initial settings", () => {
+		it("should set mode from options", () => {
+			const host = createTestHost({ mode: "architect" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.mode).toBe("architect")
+		})
+
+		it("should enable auto-approval in non-interactive mode", () => {
+			const host = createTestHost({ nonInteractive: true })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.autoApprovalEnabled).toBe(true)
+			expect(initialSettings.alwaysAllowReadOnly).toBe(true)
+			expect(initialSettings.alwaysAllowWrite).toBe(true)
+			expect(initialSettings.alwaysAllowExecute).toBe(true)
+		})
+
+		it("should disable auto-approval in interactive mode", () => {
+			const host = createTestHost({ nonInteractive: false })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.autoApprovalEnabled).toBe(false)
+		})
+
+		it("should set reasoning effort when specified", () => {
+			const host = createTestHost({ reasoningEffort: "high" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBe(true)
+			expect(initialSettings.reasoningEffort).toBe("high")
+		})
+
+		it("should disable reasoning effort when set to disabled", () => {
+			const host = createTestHost({ reasoningEffort: "disabled" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBe(false)
+		})
+
+		it("should not set reasoning effort when unspecified", () => {
+			const host = createTestHost({ reasoningEffort: "unspecified" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBeUndefined()
+			expect(initialSettings.reasoningEffort).toBeUndefined()
+		})
+	})
+
+	describe("ephemeral mode", () => {
+		it("should store ephemeral option correctly", () => {
+			const host = createTestHost({ ephemeral: true })
+
+			const options = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(options.ephemeral).toBe(true)
+		})
+
+		it("should default ephemeralStorageDir to null", () => {
+			const host = createTestHost()
+
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+		})
+
+		it("should clean up ephemeral storage directory on dispose", async () => {
+			const host = createTestHost({ ephemeral: true })
+
+			// Set up a mock ephemeral storage directory
+			const mockEphemeralDir = "/tmp/roo-cli-test-ephemeral-cleanup"
+			setPrivate(host, "ephemeralStorageDir", mockEphemeralDir)
+
+			// Mock fs.promises.rm
+			const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
+
+			await host.dispose()
+
+			expect(rmMock).toHaveBeenCalledWith(mockEphemeralDir, { recursive: true, force: true })
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+
+			rmMock.mockRestore()
+		})
+
+		it("should not clean up when ephemeralStorageDir is null", async () => {
+			const host = createTestHost()
+
+			// ephemeralStorageDir is null by default
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+
+			const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
+
+			await host.dispose()
+
+			// rm should not be called when there's no ephemeral storage
+			expect(rmMock).not.toHaveBeenCalled()
+
+			rmMock.mockRestore()
+		})
+
+		it("should handle ephemeral storage cleanup errors gracefully", async () => {
+			const host = createTestHost({ ephemeral: true })
+
+			// Set up a mock ephemeral storage directory
+			setPrivate(host, "ephemeralStorageDir", "/tmp/roo-cli-test-ephemeral-error")
+
+			// Mock fs.promises.rm to throw an error
+			const rmMock = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("Cleanup failed"))
+
+			// dispose should not throw even if cleanup fails
+			await expect(host.dispose()).resolves.toBeUndefined()
+
+			rmMock.mockRestore()
+		})
+
+		it("should not affect normal mode when ephemeral is false", () => {
+			const host = createTestHost({ ephemeral: false })
+
+			const options = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(options.ephemeral).toBe(false)
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+		})
+	})
+})

+ 466 - 0
apps/cli/src/agent/agent-state.ts

@@ -0,0 +1,466 @@
+/**
+ * Agent Loop State Detection
+ *
+ * This module provides the core logic for detecting the current state of the
+ * Roo Code agent loop. The state is determined by analyzing the clineMessages
+ * array, specifically the last message's type and properties.
+ *
+ * Key insight: The agent loop stops whenever a message with `type: "ask"` arrives,
+ * and the specific `ask` value determines what kind of response the agent is waiting for.
+ */
+
+import { ClineMessage, ClineAsk, isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "@roo-code/types"
+
+// =============================================================================
+// Agent Loop State Enum
+// =============================================================================
+
+/**
+ * The possible states of the agent loop.
+ *
+ * State Machine:
+ * ```
+ *                    ┌─────────────────┐
+ *                    │   NO_TASK       │ (initial state)
+ *                    └────────┬────────┘
+ *                             │ newTask
+ *                             ▼
+ *              ┌─────────────────────────────┐
+ *         ┌───▶│         RUNNING             │◀───┐
+ *         │    └──────────┬──────────────────┘    │
+ *         │               │                       │
+ *         │    ┌──────────┼──────────────┐        │
+ *         │    │          │              │        │
+ *         │    ▼          ▼              ▼        │
+ *         │ ┌──────┐  ┌─────────┐  ┌──────────┐   │
+ *         │ │STREAM│  │INTERACT │  │  IDLE    │   │
+ *         │ │ ING  │  │  IVE    │  │          │   │
+ *         │ └──┬───┘  └────┬────┘  └────┬─────┘   │
+ *         │    │           │            │         │
+ *         │    │ done      │ approved   │ newTask │
+ *         └────┴───────────┴────────────┘         │
+ *                                                 │
+ *         ┌──────────────┐                        │
+ *         │  RESUMABLE   │────────────────────────┘
+ *         └──────────────┘  resumed
+ * ```
+ */
+export enum AgentLoopState {
+	/**
+	 * No active task. This is the initial state before any task is started,
+	 * or after a task has been cleared.
+	 */
+	NO_TASK = "no_task",
+
+	/**
+	 * Agent is actively processing. This means:
+	 * - The last message is a "say" type (informational), OR
+	 * - The last message is a non-blocking ask (command_output)
+	 *
+	 * In this state, the agent may be:
+	 * - Executing tools
+	 * - Thinking/reasoning
+	 * - Processing between API calls
+	 */
+	RUNNING = "running",
+
+	/**
+	 * Agent is streaming a response. This is detected when:
+	 * - `partial === true` on the last message, OR
+	 * - The last `api_req_started` message has no `cost` in its text field
+	 *
+	 * Do NOT consider the agent "waiting" while streaming.
+	 */
+	STREAMING = "streaming",
+
+	/**
+	 * Agent is waiting for user approval or input. This includes:
+	 * - Tool approvals (file operations)
+	 * - Command execution permission
+	 * - Browser action permission
+	 * - MCP server permission
+	 * - Follow-up questions
+	 *
+	 * User must approve, reject, or provide input to continue.
+	 */
+	WAITING_FOR_INPUT = "waiting_for_input",
+
+	/**
+	 * Task is in an idle/terminal state. This includes:
+	 * - Task completed successfully (completion_result)
+	 * - API request failed (api_req_failed)
+	 * - Too many errors (mistake_limit_reached)
+	 * - Auto-approval limit reached
+	 * - Completed task waiting to be resumed
+	 *
+	 * User can start a new task or retry.
+	 */
+	IDLE = "idle",
+
+	/**
+	 * Task is paused and can be resumed. This happens when:
+	 * - User navigated away from a task
+	 * - Extension was restarted mid-task
+	 *
+	 * User can resume or abandon the task.
+	 */
+	RESUMABLE = "resumable",
+}
+
+// =============================================================================
+// Detailed State Info
+// =============================================================================
+
+/**
+ * What action the user should/can take in the current state.
+ */
+export type RequiredAction =
+	| "none" // No action needed (running/streaming)
+	| "approve" // Can approve/reject (tool, command, browser, mcp)
+	| "answer" // Need to answer a question (followup)
+	| "retry_or_new_task" // Can retry or start new task (api_req_failed)
+	| "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
+	| "start_task" // Should start a new task (completion_result)
+	| "resume_or_abandon" // Can resume or abandon (resume_task)
+	| "start_new_task" // Should start new task (resume_completed_task, no_task)
+	| "continue_or_abort" // Can continue or abort (command_output)
+
+/**
+ * Detailed information about the current agent state.
+ * Provides everything needed to render UI or make decisions.
+ */
+export interface AgentStateInfo {
+	/** The high-level state of the agent loop */
+	state: AgentLoopState
+
+	/** Whether the agent is waiting for user input/action */
+	isWaitingForInput: boolean
+
+	/** Whether the agent loop is actively processing */
+	isRunning: boolean
+
+	/** Whether content is being streamed */
+	isStreaming: boolean
+
+	/** The specific ask type if waiting on an ask, undefined otherwise */
+	currentAsk?: ClineAsk
+
+	/** What action the user should/can take */
+	requiredAction: RequiredAction
+
+	/** The timestamp of the last message, useful for tracking */
+	lastMessageTs?: number
+
+	/** The full last message for advanced usage */
+	lastMessage?: ClineMessage
+
+	/** Human-readable description of the current state */
+	description: string
+}
+
+// =============================================================================
+// State Detection Functions
+// =============================================================================
+
+/**
+ * Structure of the text field in api_req_started messages.
+ * Used to determine if the API request has completed (cost is defined).
+ */
+export interface ApiReqStartedText {
+	cost?: number // Undefined while streaming, defined when complete.
+	tokensIn?: number
+	tokensOut?: number
+	cacheWrites?: number
+	cacheReads?: number
+}
+
+/**
+ * Check if an API request is still in progress (streaming).
+ *
+ * API requests are considered in-progress when:
+ * - An api_req_started message exists
+ * - Its text field, when parsed, has `cost: undefined`
+ *
+ * Once the request completes, the cost field will be populated.
+ */
+function isApiRequestInProgress(messages: ClineMessage[]): boolean {
+	// Find the last api_req_started message.
+	// Using reverse iteration for efficiency (most recent first).
+	for (let i = messages.length - 1; i >= 0; i--) {
+		const message = messages[i]
+
+		if (!message) {
+			continue
+		}
+
+		if (message.say === "api_req_started") {
+			if (!message.text) {
+				// No text yet means still in progress.
+				return true
+			}
+
+			try {
+				const data: ApiReqStartedText = JSON.parse(message.text)
+				// cost is undefined while streaming, defined when complete.
+				return data.cost === undefined
+			} catch {
+				// Parse error - assume not in progress.
+				return false
+			}
+		}
+	}
+	return false
+}
+
+/**
+ * Determine the required action based on the current ask type.
+ */
+function getRequiredAction(ask: ClineAsk): RequiredAction {
+	switch (ask) {
+		case "followup":
+			return "answer"
+		case "command":
+		case "tool":
+		case "browser_action_launch":
+		case "use_mcp_server":
+			return "approve"
+		case "command_output":
+			return "continue_or_abort"
+		case "api_req_failed":
+			return "retry_or_new_task"
+		case "mistake_limit_reached":
+			return "proceed_or_new_task"
+		case "completion_result":
+			return "start_task"
+		case "resume_task":
+			return "resume_or_abandon"
+		case "resume_completed_task":
+		case "auto_approval_max_req_reached":
+			return "start_new_task"
+		default:
+			return "none"
+	}
+}
+
+/**
+ * Get a human-readable description for the current state.
+ */
+function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
+	switch (state) {
+		case AgentLoopState.NO_TASK:
+			return "No active task. Ready to start a new task."
+
+		case AgentLoopState.RUNNING:
+			return "Agent is actively processing."
+
+		case AgentLoopState.STREAMING:
+			return "Agent is streaming a response."
+
+		case AgentLoopState.WAITING_FOR_INPUT:
+			switch (ask) {
+				case "followup":
+					return "Agent is asking a follow-up question. Please provide an answer."
+				case "command":
+					return "Agent wants to execute a command. Approve or reject."
+				case "tool":
+					return "Agent wants to perform a file operation. Approve or reject."
+				case "browser_action_launch":
+					return "Agent wants to use the browser. Approve or reject."
+				case "use_mcp_server":
+					return "Agent wants to use an MCP server. Approve or reject."
+				default:
+					return "Agent is waiting for user input."
+			}
+
+		case AgentLoopState.IDLE:
+			switch (ask) {
+				case "completion_result":
+					return "Task completed successfully. You can provide feedback or start a new task."
+				case "api_req_failed":
+					return "API request failed. You can retry or start a new task."
+				case "mistake_limit_reached":
+					return "Too many errors encountered. You can proceed anyway or start a new task."
+				case "auto_approval_max_req_reached":
+					return "Auto-approval limit reached. Manual approval required."
+				case "resume_completed_task":
+					return "Previously completed task. Start a new task to continue."
+				default:
+					return "Task is idle."
+			}
+
+		case AgentLoopState.RESUMABLE:
+			return "Task is paused. You can resume or start a new task."
+
+		default:
+			return "Unknown state."
+	}
+}
+
+/**
+ * Detect the current state of the agent loop from the clineMessages array.
+ *
+ * This is the main state detection function. It analyzes the messages array
+ * and returns detailed information about the current agent state.
+ *
+ * @param messages - The clineMessages array from extension state
+ * @returns Detailed state information
+ */
+export function detectAgentState(messages: ClineMessage[]): AgentStateInfo {
+	// No messages means no task
+	if (!messages || messages.length === 0) {
+		return {
+			state: AgentLoopState.NO_TASK,
+			isWaitingForInput: false,
+			isRunning: false,
+			isStreaming: false,
+			requiredAction: "start_new_task",
+			description: getStateDescription(AgentLoopState.NO_TASK),
+		}
+	}
+
+	const lastMessage = messages[messages.length - 1]
+
+	// Guard against undefined (should never happen after length check, but TypeScript requires it)
+	if (!lastMessage) {
+		return {
+			state: AgentLoopState.NO_TASK,
+			isWaitingForInput: false,
+			isRunning: false,
+			isStreaming: false,
+			requiredAction: "start_new_task",
+			description: getStateDescription(AgentLoopState.NO_TASK),
+		}
+	}
+
+	// Check if the message is still streaming (partial)
+	// This is the PRIMARY indicator of streaming
+	if (lastMessage.partial === true) {
+		return {
+			state: AgentLoopState.STREAMING,
+			isWaitingForInput: false,
+			isRunning: true,
+			isStreaming: true,
+			currentAsk: lastMessage.ask,
+			requiredAction: "none",
+			lastMessageTs: lastMessage.ts,
+			lastMessage,
+			description: getStateDescription(AgentLoopState.STREAMING),
+		}
+	}
+
+	// Handle "ask" type messages
+	if (lastMessage.type === "ask" && lastMessage.ask) {
+		const ask = lastMessage.ask
+
+		// Non-blocking asks (command_output) - agent is running but can be interrupted
+		if (isNonBlockingAsk(ask)) {
+			return {
+				state: AgentLoopState.RUNNING,
+				isWaitingForInput: false,
+				isRunning: true,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: "continue_or_abort",
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: "Command is running. You can continue or abort.",
+			}
+		}
+
+		// Idle asks - task has stopped
+		if (isIdleAsk(ask)) {
+			return {
+				state: AgentLoopState.IDLE,
+				isWaitingForInput: true, // User needs to decide what to do next
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.IDLE, ask),
+			}
+		}
+
+		// Resumable asks - task is paused
+		if (isResumableAsk(ask)) {
+			return {
+				state: AgentLoopState.RESUMABLE,
+				isWaitingForInput: true,
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.RESUMABLE, ask),
+			}
+		}
+
+		// Interactive asks - waiting for approval/input
+		if (isInteractiveAsk(ask)) {
+			return {
+				state: AgentLoopState.WAITING_FOR_INPUT,
+				isWaitingForInput: true,
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask),
+			}
+		}
+	}
+
+	// For "say" type messages, check if API request is in progress
+	if (isApiRequestInProgress(messages)) {
+		return {
+			state: AgentLoopState.STREAMING,
+			isWaitingForInput: false,
+			isRunning: true,
+			isStreaming: true,
+			requiredAction: "none",
+			lastMessageTs: lastMessage.ts,
+			lastMessage,
+			description: getStateDescription(AgentLoopState.STREAMING),
+		}
+	}
+
+	// Default: agent is running
+	return {
+		state: AgentLoopState.RUNNING,
+		isWaitingForInput: false,
+		isRunning: true,
+		isStreaming: false,
+		requiredAction: "none",
+		lastMessageTs: lastMessage.ts,
+		lastMessage,
+		description: getStateDescription(AgentLoopState.RUNNING),
+	}
+}
+
+/**
+ * Quick check: Is the agent waiting for user input?
+ *
+ * This is a convenience function for simple use cases where you just need
+ * to know if user action is required.
+ */
+export function isAgentWaitingForInput(messages: ClineMessage[]): boolean {
+	return detectAgentState(messages).isWaitingForInput
+}
+
+/**
+ * Quick check: Is the agent actively running (not waiting)?
+ */
+export function isAgentRunning(messages: ClineMessage[]): boolean {
+	const state = detectAgentState(messages)
+	return state.isRunning && !state.isWaitingForInput
+}
+
+/**
+ * Quick check: Is content currently streaming?
+ */
+export function isContentStreaming(messages: ClineMessage[]): boolean {
+	return detectAgentState(messages).isStreaming
+}

+ 681 - 0
apps/cli/src/agent/ask-dispatcher.ts

@@ -0,0 +1,681 @@
+/**
+ * AskDispatcher - Routes ask messages to appropriate handlers
+ *
+ * This dispatcher is responsible for:
+ * - Categorizing ask types using type guards from client module
+ * - Routing to the appropriate handler based on ask category
+ * - Coordinating between OutputManager and PromptManager
+ * - Tracking which asks have been handled (to avoid duplicates)
+ *
+ * Design notes:
+ * - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards
+ * - Single responsibility: Ask routing and handling only
+ * - Delegates output to OutputManager, input to PromptManager
+ * - Sends responses back through a provided callback
+ */
+
+import {
+	type WebviewMessage,
+	type ClineMessage,
+	type ClineAsk,
+	type ClineAskResponse,
+	isIdleAsk,
+	isInteractiveAsk,
+	isResumableAsk,
+	isNonBlockingAsk,
+} from "@roo-code/types"
+import { debugLog } from "@roo-code/core/cli"
+
+import { FOLLOWUP_TIMEOUT_SECONDS } from "@/types/index.js"
+
+import type { OutputManager } from "./output-manager.js"
+import type { PromptManager } from "./prompt-manager.js"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Configuration for AskDispatcher.
+ */
+export interface AskDispatcherOptions {
+	/**
+	 * OutputManager for displaying ask-related output.
+	 */
+	outputManager: OutputManager
+
+	/**
+	 * PromptManager for collecting user input.
+	 */
+	promptManager: PromptManager
+
+	/**
+	 * Callback to send responses to the extension.
+	 */
+	sendMessage: (message: WebviewMessage) => void
+
+	/**
+	 * Whether running in non-interactive mode (auto-approve).
+	 */
+	nonInteractive?: boolean
+
+	/**
+	 * Whether to disable ask handling (for TUI mode).
+	 * In TUI mode, the TUI handles asks directly.
+	 */
+	disabled?: boolean
+}
+
+/**
+ * Result of handling an ask.
+ */
+export interface AskHandleResult {
+	/** Whether the ask was handled */
+	handled: boolean
+	/** The response sent (if any) */
+	response?: ClineAskResponse
+	/** Any error that occurred */
+	error?: Error
+}
+
+// =============================================================================
+// AskDispatcher Class
+// =============================================================================
+
+export class AskDispatcher {
+	private outputManager: OutputManager
+	private promptManager: PromptManager
+	private sendMessage: (message: WebviewMessage) => void
+	private nonInteractive: boolean
+	private disabled: boolean
+
+	/**
+	 * Track which asks have been handled to avoid duplicates.
+	 * Key: message ts
+	 */
+	private handledAsks = new Set<number>()
+
+	constructor(options: AskDispatcherOptions) {
+		this.outputManager = options.outputManager
+		this.promptManager = options.promptManager
+		this.sendMessage = options.sendMessage
+		this.nonInteractive = options.nonInteractive ?? false
+		this.disabled = options.disabled ?? false
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Handle an ask message.
+	 * Routes to the appropriate handler based on ask type.
+	 *
+	 * @param message - The ClineMessage with type="ask"
+	 * @returns Promise<AskHandleResult>
+	 */
+	async handleAsk(message: ClineMessage): Promise<AskHandleResult> {
+		// Disabled in TUI mode - TUI handles asks directly
+		if (this.disabled) {
+			return { handled: false }
+		}
+
+		const ts = message.ts
+		const ask = message.ask
+		const text = message.text || ""
+
+		// Check if already handled
+		if (this.handledAsks.has(ts)) {
+			return { handled: true }
+		}
+
+		// Must be an ask message
+		if (message.type !== "ask" || !ask) {
+			return { handled: false }
+		}
+
+		// Skip partial messages (wait for complete)
+		if (message.partial) {
+			return { handled: false }
+		}
+
+		// Mark as being handled
+		this.handledAsks.add(ts)
+
+		try {
+			// Route based on ask category
+			if (isNonBlockingAsk(ask)) {
+				return await this.handleNonBlockingAsk(ts, ask, text)
+			}
+
+			if (isIdleAsk(ask)) {
+				return await this.handleIdleAsk(ts, ask, text)
+			}
+
+			if (isResumableAsk(ask)) {
+				return await this.handleResumableAsk(ts, ask, text)
+			}
+
+			if (isInteractiveAsk(ask)) {
+				return await this.handleInteractiveAsk(ts, ask, text)
+			}
+
+			// Unknown ask type - log and handle generically
+			debugLog("[AskDispatcher] Unknown ask type", { ask, ts })
+			return await this.handleUnknownAsk(ts, ask, text)
+		} catch (error) {
+			// Re-allow handling on error
+			this.handledAsks.delete(ts)
+			return {
+				handled: false,
+				error: error instanceof Error ? error : new Error(String(error)),
+			}
+		}
+	}
+
+	/**
+	 * Check if an ask has been handled.
+	 */
+	isHandled(ts: number): boolean {
+		return this.handledAsks.has(ts)
+	}
+
+	/**
+	 * Clear handled asks (call when starting new task).
+	 */
+	clear(): void {
+		this.handledAsks.clear()
+	}
+
+	// ===========================================================================
+	// Category Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle non-blocking asks (command_output).
+	 * These don't actually block the agent - just need acknowledgment.
+	 */
+	private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise<AskHandleResult> {
+		// command_output - output is handled by OutputManager
+		// Just send approval to continue
+		this.sendApprovalResponse(true)
+		return { handled: true, response: "yesButtonClicked" }
+	}
+
+	/**
+	 * Handle idle asks (completion_result, api_req_failed, etc.).
+	 * These indicate the task has stopped.
+	 */
+	private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		switch (ask) {
+			case "completion_result":
+				// Task complete - nothing to do here, TaskCompleted event handles it
+				return { handled: true }
+
+			case "api_req_failed":
+				return await this.handleApiFailedRetry(ts, text)
+
+			case "mistake_limit_reached":
+				return await this.handleMistakeLimitReached(ts, text)
+
+			case "resume_completed_task":
+				return await this.handleResumeTask(ts, ask, text)
+
+			case "auto_approval_max_req_reached":
+				return await this.handleAutoApprovalMaxReached(ts, text)
+
+			default:
+				return { handled: false }
+		}
+	}
+
+	/**
+	 * Handle resumable asks (resume_task).
+	 */
+	private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		return await this.handleResumeTask(ts, ask, text)
+	}
+
+	/**
+	 * Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
+	 * These require user approval or input.
+	 */
+	private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		switch (ask) {
+			case "followup":
+				return await this.handleFollowupQuestion(ts, text)
+
+			case "command":
+				return await this.handleCommandApproval(ts, text)
+
+			case "tool":
+				return await this.handleToolApproval(ts, text)
+
+			case "browser_action_launch":
+				return await this.handleBrowserApproval(ts, text)
+
+			case "use_mcp_server":
+				return await this.handleMcpApproval(ts, text)
+
+			default:
+				return { handled: false }
+		}
+	}
+
+	/**
+	 * Handle unknown ask types.
+	 */
+	private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		if (this.nonInteractive) {
+			if (text) {
+				this.outputManager.output(`\n[${ask}]`, text)
+			}
+			return { handled: true }
+		}
+
+		return await this.handleGenericApproval(ts, ask, text)
+	}
+
+	// ===========================================================================
+	// Specific Ask Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle followup questions - prompt for text input with suggestions.
+	 */
+	private async handleFollowupQuestion(ts: number, text: string): Promise<AskHandleResult> {
+		let question = text
+		let suggestions: Array<{ answer: string; mode?: string | null }> = []
+
+		try {
+			const data = JSON.parse(text)
+			question = data.question || text
+			suggestions = Array.isArray(data.suggest) ? data.suggest : []
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		this.outputManager.output("\n[question]", question)
+
+		if (suggestions.length > 0) {
+			this.outputManager.output("\nSuggested answers:")
+			suggestions.forEach((suggestion, index) => {
+				const suggestionText = suggestion.answer || String(suggestion)
+				const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : ""
+				this.outputManager.output(`  ${index + 1}. ${suggestionText}${modeHint}`)
+			})
+			this.outputManager.output("")
+		}
+
+		const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null
+		const defaultAnswer = firstSuggestion?.answer ?? ""
+
+		if (this.nonInteractive) {
+			// Use timeout prompt in non-interactive mode
+			const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000
+			const result = await this.promptManager.promptWithTimeout(
+				suggestions.length > 0
+					? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `
+					: `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `,
+				timeoutMs,
+				defaultAnswer,
+			)
+
+			let responseText = result.value.trim()
+			responseText = this.resolveNumberedSuggestion(responseText, suggestions)
+
+			if (result.timedOut || result.cancelled) {
+				this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
+			}
+
+			this.sendFollowupResponse(responseText)
+			return { handled: true, response: "messageResponse" }
+		}
+
+		// Interactive mode
+		try {
+			const answer = await this.promptManager.promptForInput(
+				suggestions.length > 0
+					? `Enter number (1-${suggestions.length}) or type your answer: `
+					: "Your answer: ",
+			)
+
+			let responseText = answer.trim()
+			responseText = this.resolveNumberedSuggestion(responseText, suggestions)
+
+			this.sendFollowupResponse(responseText)
+			return { handled: true, response: "messageResponse" }
+		} catch {
+			this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
+			this.sendFollowupResponse(defaultAnswer)
+			return { handled: true, response: "messageResponse" }
+		}
+	}
+
+	/**
+	 * Handle command execution approval.
+	 */
+	private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[command request]")
+		this.outputManager.output(`  Command: ${text || "(no command specified)"}`)
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle tool execution approval.
+	 */
+	private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
+		let toolName = "unknown"
+		let toolInfo: Record<string, unknown> = {}
+
+		try {
+			toolInfo = JSON.parse(text) as Record<string, unknown>
+			toolName = (toolInfo.tool as string) || "unknown"
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		const isProtected = toolInfo.isProtected === true
+
+		if (isProtected) {
+			this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
+			this.outputManager.output(`⚠️  WARNING: This tool wants to modify a protected configuration file.`)
+			this.outputManager.output(
+				`    Protected files include .rooignore, .roo/*, and other sensitive config files.`,
+			)
+		} else {
+			this.outputManager.output(`\n[Tool Request] ${toolName}`)
+		}
+
+		// Display tool details
+		for (const [key, value] of Object.entries(toolInfo)) {
+			if (key === "tool" || key === "isProtected") continue
+
+			let displayValue: string
+			if (typeof value === "string") {
+				displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
+			} else if (typeof value === "object" && value !== null) {
+				const json = JSON.stringify(value)
+				displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
+			} else {
+				displayValue = String(value)
+			}
+
+			this.outputManager.output(`  ${key}: ${displayValue}`)
+		}
+
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings (unless protected)
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle browser action approval.
+	 */
+	private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[browser action request]")
+		if (text) {
+			this.outputManager.output(`  Action: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle MCP server access approval.
+	 */
+	private async handleMcpApproval(ts: number, text: string): Promise<AskHandleResult> {
+		let serverName = "unknown"
+		let toolName = ""
+		let resourceUri = ""
+
+		try {
+			const mcpInfo = JSON.parse(text)
+			serverName = mcpInfo.server_name || "unknown"
+
+			if (mcpInfo.type === "use_mcp_tool") {
+				toolName = mcpInfo.tool_name || ""
+			} else if (mcpInfo.type === "access_mcp_resource") {
+				resourceUri = mcpInfo.uri || ""
+			}
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		this.outputManager.output("\n[mcp request]")
+		this.outputManager.output(`  Server: ${serverName}`)
+		if (toolName) {
+			this.outputManager.output(`  Tool: ${toolName}`)
+		}
+		if (resourceUri) {
+			this.outputManager.output(`  Resource: ${resourceUri}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle API request failed - retry prompt.
+	 */
+	private async handleApiFailedRetry(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[api request failed]")
+		this.outputManager.output(`  Error: ${text || "Unknown error"}`)
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			this.outputManager.output("\n[retrying api request]")
+			// Auto-retry in non-interactive mode
+			return { handled: true }
+		}
+
+		try {
+			const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ")
+			this.sendApprovalResponse(retry)
+			return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle mistake limit reached.
+	 */
+	private async handleMistakeLimitReached(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[mistake limit reached]")
+		if (text) {
+			this.outputManager.output(`  Details: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-proceed in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ")
+			this.sendApprovalResponse(proceed)
+			return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle auto-approval max reached.
+	 */
+	private async handleAutoApprovalMaxReached(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[auto-approval limit reached]")
+		if (text) {
+			this.outputManager.output(`  Details: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-proceed in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ")
+			this.sendApprovalResponse(proceed)
+			return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle task resume prompt.
+	 */
+	private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		const isCompleted = ask === "resume_completed_task"
+		this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`)
+		if (text) {
+			this.outputManager.output(`  ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			this.outputManager.output("\n[continuing task]")
+			// Auto-resume in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ")
+			this.sendApprovalResponse(resume)
+			return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle generic approval prompts for unknown ask types.
+	 */
+	private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		this.outputManager.output(`\n[${ask}]`)
+		if (text) {
+			this.outputManager.output(`  ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	// ===========================================================================
+	// Response Helpers
+	// ===========================================================================
+
+	/**
+	 * Send a followup response (text answer) to the extension.
+	 */
+	private sendFollowupResponse(text: string): void {
+		this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text })
+	}
+
+	/**
+	 * Send an approval response (yes/no) to the extension.
+	 */
+	private sendApprovalResponse(approved: boolean): void {
+		this.sendMessage({
+			type: "askResponse",
+			askResponse: approved ? "yesButtonClicked" : "noButtonClicked",
+		})
+	}
+
+	/**
+	 * Resolve a numbered suggestion selection.
+	 */
+	private resolveNumberedSuggestion(
+		input: string,
+		suggestions: Array<{ answer: string; mode?: string | null }>,
+	): string {
+		const num = parseInt(input, 10)
+		if (!isNaN(num) && num >= 1 && num <= suggestions.length) {
+			const selectedSuggestion = suggestions[num - 1]
+			if (selectedSuggestion) {
+				const selected = selectedSuggestion.answer || String(selectedSuggestion)
+				this.outputManager.output(`Selected: ${selected}`)
+				return selected
+			}
+		}
+		return input
+	}
+}

+ 372 - 0
apps/cli/src/agent/events.ts

@@ -0,0 +1,372 @@
+/**
+ * Event System for Agent State Changes
+ *
+ * This module provides a strongly-typed event emitter specifically designed
+ * for tracking agent state changes. It uses Node.js EventEmitter under the hood
+ * but provides type safety for all events.
+ */
+
+import { EventEmitter } from "events"
+
+import { ClineMessage, ClineAsk } from "@roo-code/types"
+
+import type { AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Event Types
+// =============================================================================
+
+/**
+ * All events that can be emitted by the client.
+ *
+ * Design note: We use a string literal union type for event names to ensure
+ * type safety when subscribing to events. The payload type is determined by
+ * the event name.
+ */
+export interface ClientEventMap {
+	/**
+	 * Emitted whenever the agent state changes.
+	 * This is the primary event for tracking state.
+	 */
+	stateChange: AgentStateChangeEvent
+
+	/**
+	 * Emitted when a new message is added to the message list.
+	 */
+	message: ClineMessage
+
+	/**
+	 * Emitted when an existing message is updated (e.g., partial -> complete).
+	 */
+	messageUpdated: ClineMessage
+
+	/**
+	 * Emitted when the agent starts waiting for user input.
+	 * Convenience event - you can also use stateChange.
+	 */
+	waitingForInput: WaitingForInputEvent
+
+	/**
+	 * Emitted when the agent stops waiting and resumes running.
+	 */
+	resumedRunning: void
+
+	/**
+	 * Emitted when the agent starts streaming content.
+	 */
+	streamingStarted: void
+
+	/**
+	 * Emitted when streaming ends.
+	 */
+	streamingEnded: void
+
+	/**
+	 * Emitted when a task completes (either successfully or with error).
+	 */
+	taskCompleted: TaskCompletedEvent
+
+	/**
+	 * Emitted when a task is cleared/cancelled.
+	 */
+	taskCleared: void
+
+	/**
+	 * Emitted when the current mode changes.
+	 */
+	modeChanged: ModeChangedEvent
+
+	/**
+	 * Emitted on any error during message processing.
+	 */
+	error: Error
+}
+
+/**
+ * Event payload for state changes.
+ */
+export interface AgentStateChangeEvent {
+	/** The previous state info */
+	previousState: AgentStateInfo
+	/** The new/current state info */
+	currentState: AgentStateInfo
+	/** Whether this is a significant state transition (state enum changed) */
+	isSignificantChange: boolean
+}
+
+/**
+ * Event payload when agent starts waiting for input.
+ */
+export interface WaitingForInputEvent {
+	/** The specific ask type */
+	ask: ClineAsk
+	/** Full state info for context */
+	stateInfo: AgentStateInfo
+	/** The message that triggered this wait */
+	message: ClineMessage
+}
+
+/**
+ * Event payload when a task completes.
+ */
+export interface TaskCompletedEvent {
+	/** Whether the task completed successfully */
+	success: boolean
+	/** The final state info */
+	stateInfo: AgentStateInfo
+	/** The completion message if available */
+	message?: ClineMessage
+}
+
+/**
+ * Event payload when mode changes.
+ */
+export interface ModeChangedEvent {
+	/** The previous mode (undefined if first mode set) */
+	previousMode: string | undefined
+	/** The new/current mode */
+	currentMode: string
+}
+
+// =============================================================================
+// Typed Event Emitter
+// =============================================================================
+
+/**
+ * Type-safe event emitter for client events.
+ *
+ * Usage:
+ * ```typescript
+ * const emitter = new TypedEventEmitter()
+ *
+ * // Type-safe subscription
+ * emitter.on('stateChange', (event) => {
+ *   console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent
+ * })
+ *
+ * // Type-safe emission
+ * emitter.emit('stateChange', { previousState, currentState, isSignificantChange })
+ * ```
+ */
+export class TypedEventEmitter {
+	private emitter = new EventEmitter()
+
+	/**
+	 * Subscribe to an event.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function
+	 * @returns Function to unsubscribe
+	 */
+	on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
+		this.emitter.on(event, listener)
+		return () => this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Subscribe to an event, but only once.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function
+	 */
+	once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.once(event, listener)
+	}
+
+	/**
+	 * Unsubscribe from an event.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function to remove
+	 */
+	off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Emit an event.
+	 *
+	 * @param event - The event name
+	 * @param payload - The event payload
+	 */
+	emit<K extends keyof ClientEventMap>(event: K, payload: ClientEventMap[K]): void {
+		this.emitter.emit(event, payload)
+	}
+
+	/**
+	 * Remove all listeners for an event, or all events.
+	 *
+	 * @param event - Optional event name. If not provided, removes all listeners.
+	 */
+	removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
+		if (event) {
+			this.emitter.removeAllListeners(event)
+		} else {
+			this.emitter.removeAllListeners()
+		}
+	}
+
+	/**
+	 * Get the number of listeners for an event.
+	 */
+	listenerCount<K extends keyof ClientEventMap>(event: K): number {
+		return this.emitter.listenerCount(event)
+	}
+}
+
+// =============================================================================
+// State Change Detector
+// =============================================================================
+
+/**
+ * Helper to determine if a state change is "significant".
+ *
+ * A significant change is when the AgentLoopState enum value changes,
+ * as opposed to just internal state updates within the same state.
+ */
+export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.state !== current.state
+}
+
+/**
+ * Helper to determine if we transitioned to waiting for input.
+ */
+export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return !previous.isWaitingForInput && current.isWaitingForInput
+}
+
+/**
+ * Helper to determine if we transitioned from waiting to running.
+ */
+export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning
+}
+
+/**
+ * Helper to determine if streaming started.
+ */
+export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return !previous.isStreaming && current.isStreaming
+}
+
+/**
+ * Helper to determine if streaming ended.
+ */
+export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.isStreaming && !current.isStreaming
+}
+
+/**
+ * Helper to determine if task completed.
+ */
+export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
+	const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
+	const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
+	return wasNotComplete && isNowComplete
+}
+
+// =============================================================================
+// Observable Pattern (Alternative API)
+// =============================================================================
+
+/**
+ * Subscription function type for observable pattern.
+ */
+export type Observer<T> = (value: T) => void
+
+/**
+ * Unsubscribe function type.
+ */
+export type Unsubscribe = () => void
+
+/**
+ * Simple observable for state.
+ *
+ * This provides an alternative to the event emitter pattern
+ * for those who prefer a more functional approach.
+ *
+ * Usage:
+ * ```typescript
+ * const stateObservable = new Observable<AgentStateInfo>()
+ *
+ * const unsubscribe = stateObservable.subscribe((state) => {
+ *   console.log('New state:', state)
+ * })
+ *
+ * // Later...
+ * unsubscribe()
+ * ```
+ */
+export class Observable<T> {
+	private observers: Set<Observer<T>> = new Set()
+	private currentValue: T | undefined
+
+	/**
+	 * Create an observable with an optional initial value.
+	 */
+	constructor(initialValue?: T) {
+		this.currentValue = initialValue
+	}
+
+	/**
+	 * Subscribe to value changes.
+	 *
+	 * @param observer - Function called when value changes
+	 * @returns Unsubscribe function
+	 */
+	subscribe(observer: Observer<T>): Unsubscribe {
+		this.observers.add(observer)
+
+		// Immediately emit current value if we have one
+		if (this.currentValue !== undefined) {
+			observer(this.currentValue)
+		}
+
+		return () => {
+			this.observers.delete(observer)
+		}
+	}
+
+	/**
+	 * Update the value and notify all subscribers.
+	 */
+	next(value: T): void {
+		this.currentValue = value
+		for (const observer of this.observers) {
+			try {
+				observer(value)
+			} catch (error) {
+				console.error("Error in observer:", error)
+			}
+		}
+	}
+
+	/**
+	 * Get the current value without subscribing.
+	 */
+	getValue(): T | undefined {
+		return this.currentValue
+	}
+
+	/**
+	 * Check if there are any subscribers.
+	 */
+	hasSubscribers(): boolean {
+		return this.observers.size > 0
+	}
+
+	/**
+	 * Get the number of subscribers.
+	 */
+	getSubscriberCount(): number {
+		return this.observers.size
+	}
+
+	/**
+	 * Remove all subscribers.
+	 */
+	clear(): void {
+		this.observers.clear()
+	}
+}

+ 580 - 0
apps/cli/src/agent/extension-client.ts

@@ -0,0 +1,580 @@
+/**
+ * Roo Code Client
+ *
+ * This is the main entry point for the client library. It provides a high-level
+ * API for:
+ * - Processing messages from the extension host
+ * - Querying the current agent state
+ * - Subscribing to state change events
+ * - Sending responses back to the extension
+ *
+ * The client is designed to be transport-agnostic. You provide a way to send
+ * messages to the extension, and you feed incoming messages to the client.
+ *
+ * Architecture:
+ * ```
+ *                     ┌───────────────────────────────────────────────┐
+ *                     │               ExtensionClient                 │
+ *                     │                                               │
+ *   Extension ──────▶ │  MessageProcessor ──▶ StateStore              │
+ *   Messages          │         │                  │                  │
+ *                     │         ▼                  ▼                  │
+ *                     │    TypedEventEmitter ◀── State/Events         │
+ *                     │         │                                     │
+ *                     │         ▼                                     │
+ *                     │    Your Event Handlers                        │
+ *                     └───────────────────────────────────────────────┘
+ * ```
+ */
+
+import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "@roo-code/types"
+
+import { StateStore } from "./state-store.js"
+import { MessageProcessor, parseExtensionMessage } from "./message-processor.js"
+import {
+	TypedEventEmitter,
+	type ClientEventMap,
+	type AgentStateChangeEvent,
+	type WaitingForInputEvent,
+	type ModeChangedEvent,
+} from "./events.js"
+import { AgentLoopState, type AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Extension Client Configuration
+// =============================================================================
+
+/**
+ * Configuration options for the ExtensionClient.
+ */
+export interface ExtensionClientConfig {
+	/**
+	 * Function to send messages to the extension host.
+	 * This is how the client communicates back to the extension.
+	 *
+	 * Example implementations:
+	 * - VSCode webview: (msg) => vscode.postMessage(msg)
+	 * - WebSocket: (msg) => socket.send(JSON.stringify(msg))
+	 * - IPC: (msg) => process.send(msg)
+	 */
+	sendMessage: (message: WebviewMessage) => void
+
+	/**
+	 * Whether to emit events for all state changes or only significant ones.
+	 * Default: true
+	 */
+	emitAllStateChanges?: boolean
+
+	/**
+	 * Enable debug logging.
+	 * Default: false
+	 */
+	debug?: boolean
+
+	/**
+	 * Maximum state history size (for debugging).
+	 * Set to 0 to disable history tracking.
+	 * Default: 0
+	 */
+	maxHistorySize?: number
+}
+
+// =============================================================================
+// Main Client Class
+// =============================================================================
+
+/**
+ * ExtensionClient is the main interface for interacting with the Roo Code extension.
+ *
+ * Basic usage:
+ * ```typescript
+ * // Create client with message sender
+ * const client = new ExtensionClient({
+ *   sendMessage: (msg) => vscode.postMessage(msg)
+ * })
+ *
+ * // Subscribe to state changes
+ * client.on('stateChange', (event) => {
+ *   console.log('State:', event.currentState.state)
+ * })
+ *
+ * // Subscribe to specific events
+ * client.on('waitingForInput', (event) => {
+ *   console.log('Waiting for:', event.ask)
+ * })
+ *
+ * // Feed messages from extension
+ * window.addEventListener('message', (e) => {
+ *   client.handleMessage(e.data)
+ * })
+ *
+ * // Query state at any time
+ * const state = client.getAgentState()
+ * if (state.isWaitingForInput) {
+ *   // Show approval UI
+ * }
+ *
+ * // Send responses
+ * client.approve() // or client.reject() or client.respond('answer')
+ * ```
+ */
+export class ExtensionClient {
+	private store: StateStore
+	private processor: MessageProcessor
+	private emitter: TypedEventEmitter
+	private sendMessage: (message: WebviewMessage) => void
+	private debug: boolean
+
+	constructor(config: ExtensionClientConfig) {
+		this.sendMessage = config.sendMessage
+		this.debug = config.debug ?? false
+		this.store = new StateStore({ maxHistorySize: config.maxHistorySize ?? 0 })
+		this.emitter = new TypedEventEmitter()
+
+		this.processor = new MessageProcessor(this.store, this.emitter, {
+			emitAllStateChanges: config.emitAllStateChanges ?? true,
+			debug: config.debug ?? false,
+		})
+	}
+
+	// ===========================================================================
+	// Message Handling
+	// ===========================================================================
+
+	/**
+	 * Handle an incoming message from the extension host.
+	 *
+	 * Call this method whenever you receive a message from the extension.
+	 * The client will parse, validate, and process the message, updating
+	 * internal state and emitting appropriate events.
+	 *
+	 * @param message - The raw message (can be ExtensionMessage or JSON string)
+	 */
+	handleMessage(message: ExtensionMessage | string): void {
+		let parsed: ExtensionMessage | undefined
+
+		if (typeof message === "string") {
+			parsed = parseExtensionMessage(message)
+
+			if (!parsed) {
+				if (this.debug) {
+					console.log("[ExtensionClient] Failed to parse message:", message)
+				}
+
+				return
+			}
+		} else {
+			parsed = message
+		}
+
+		this.processor.processMessage(parsed)
+	}
+
+	/**
+	 * Handle multiple messages at once.
+	 */
+	handleMessages(messages: (ExtensionMessage | string)[]): void {
+		for (const message of messages) {
+			this.handleMessage(message)
+		}
+	}
+
+	// ===========================================================================
+	// State Queries - Always know the current state
+	// ===========================================================================
+
+	/**
+	 * Get the complete agent state information.
+	 *
+	 * This returns everything you need to know about the current state:
+	 * - The high-level state (running, streaming, waiting, idle, etc.)
+	 * - Whether input is needed
+	 * - The specific ask type if waiting
+	 * - What action is required
+	 * - Human-readable description
+	 */
+	getAgentState(): AgentStateInfo {
+		return this.store.getAgentState()
+	}
+
+	/**
+	 * Get just the current state enum value.
+	 */
+	getCurrentState(): AgentLoopState {
+		return this.store.getCurrentState()
+	}
+
+	/**
+	 * Check if the agent is waiting for user input.
+	 */
+	isWaitingForInput(): boolean {
+		return this.store.isWaitingForInput()
+	}
+
+	/**
+	 * Check if the agent is actively running.
+	 */
+	isRunning(): boolean {
+		return this.store.isRunning()
+	}
+
+	/**
+	 * Check if content is currently streaming.
+	 */
+	isStreaming(): boolean {
+		return this.store.isStreaming()
+	}
+
+	/**
+	 * Check if there is an active task.
+	 */
+	hasActiveTask(): boolean {
+		return this.store.getCurrentState() !== AgentLoopState.NO_TASK
+	}
+
+	/**
+	 * Get all messages in the current task.
+	 */
+	getMessages(): ClineMessage[] {
+		return this.store.getMessages()
+	}
+
+	/**
+	 * Get the last message.
+	 */
+	getLastMessage(): ClineMessage | undefined {
+		return this.store.getLastMessage()
+	}
+
+	/**
+	 * Get the current ask type if the agent is waiting for input.
+	 */
+	getCurrentAsk(): ClineAsk | undefined {
+		return this.store.getAgentState().currentAsk
+	}
+
+	/**
+	 * Check if the client has received any state from the extension.
+	 */
+	isInitialized(): boolean {
+		return this.store.isInitialized()
+	}
+
+	/**
+	 * Get the current mode (e.g., "code", "architect", "ask").
+	 * Returns undefined if no mode has been received yet.
+	 */
+	getCurrentMode(): string | undefined {
+		return this.store.getCurrentMode()
+	}
+
+	// ===========================================================================
+	// Event Subscriptions - Realtime notifications
+	// ===========================================================================
+
+	/**
+	 * Subscribe to an event.
+	 *
+	 * Returns an unsubscribe function for easy cleanup.
+	 *
+	 * @param event - The event to subscribe to
+	 * @param listener - The callback function
+	 * @returns Unsubscribe function
+	 *
+	 * @example
+	 * ```typescript
+	 * const unsubscribe = client.on('stateChange', (event) => {
+	 *   console.log(event.currentState)
+	 * })
+	 *
+	 * // Later, to unsubscribe:
+	 * unsubscribe()
+	 * ```
+	 */
+	on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
+		return this.emitter.on(event, listener)
+	}
+
+	/**
+	 * Subscribe to an event, triggered only once.
+	 */
+	once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.once(event, listener)
+	}
+
+	/**
+	 * Unsubscribe from an event.
+	 */
+	off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Remove all listeners for an event, or all events.
+	 */
+	removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
+		this.emitter.removeAllListeners(event)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to state changes.
+	 */
+	onStateChange(listener: (event: AgentStateChangeEvent) => void): () => void {
+		return this.on("stateChange", listener)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to waiting events.
+	 */
+	onWaitingForInput(listener: (event: WaitingForInputEvent) => void): () => void {
+		return this.on("waitingForInput", listener)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to mode changes.
+	 */
+	onModeChanged(listener: (event: ModeChangedEvent) => void): () => void {
+		return this.on("modeChanged", listener)
+	}
+
+	// ===========================================================================
+	// Response Methods - Send actions to the extension
+	// ===========================================================================
+
+	/**
+	 * Approve the current action (tool, command, browser, MCP).
+	 *
+	 * Use when the agent is waiting for approval (interactive asks).
+	 */
+	approve(): void {
+		this.sendResponse("yesButtonClicked")
+	}
+
+	/**
+	 * Reject the current action.
+	 *
+	 * Use when you want to deny a tool, command, or other action.
+	 */
+	reject(): void {
+		this.sendResponse("noButtonClicked")
+	}
+
+	/**
+	 * Send a text response.
+	 *
+	 * Use for:
+	 * - Answering follow-up questions
+	 * - Providing additional context
+	 * - Giving feedback on completion
+	 *
+	 * @param text - The response text
+	 * @param images - Optional base64-encoded images
+	 */
+	respond(text: string, images?: string[]): void {
+		this.sendResponse("messageResponse", text, images)
+	}
+
+	/**
+	 * Generic method to send any ask response.
+	 *
+	 * @param response - The response type
+	 * @param text - Optional text content
+	 * @param images - Optional images
+	 */
+	sendResponse(response: ClineAskResponse, text?: string, images?: string[]): void {
+		const message: WebviewMessage = {
+			type: "askResponse",
+			askResponse: response,
+			text,
+			images,
+		}
+		this.sendMessage(message)
+	}
+
+	// ===========================================================================
+	// Task Control Methods
+	// ===========================================================================
+
+	/**
+	 * Start a new task with the given prompt.
+	 *
+	 * @param text - The task description/prompt
+	 * @param images - Optional base64-encoded images
+	 */
+	newTask(text: string, images?: string[]): void {
+		const message: WebviewMessage = {
+			type: "newTask",
+			text,
+			images,
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Clear the current task.
+	 *
+	 * This ends the current task and resets to a fresh state.
+	 */
+	clearTask(): void {
+		const message: WebviewMessage = {
+			type: "clearTask",
+		}
+		this.sendMessage(message)
+		this.processor.notifyTaskCleared()
+	}
+
+	/**
+	 * Cancel a running task.
+	 *
+	 * Use this to interrupt a task that is currently processing.
+	 */
+	cancelTask(): void {
+		const message: WebviewMessage = {
+			type: "cancelTask",
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Resume a paused task.
+	 *
+	 * Use when the agent state is RESUMABLE (resume_task ask).
+	 */
+	resumeTask(): void {
+		this.approve() // Resume uses the same response as approve
+	}
+
+	/**
+	 * Retry a failed API request.
+	 *
+	 * Use when the agent state shows api_req_failed.
+	 */
+	retryApiRequest(): void {
+		this.approve() // Retry uses the same response as approve
+	}
+
+	// ===========================================================================
+	// Terminal Operation Methods
+	// ===========================================================================
+
+	/**
+	 * Continue terminal output (don't wait for more output).
+	 *
+	 * Use when the agent is showing command_output and you want to proceed.
+	 */
+	continueTerminal(): void {
+		const message: WebviewMessage = {
+			type: "terminalOperation",
+			terminalOperation: "continue",
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Abort terminal command.
+	 *
+	 * Use when you want to kill a running terminal command.
+	 */
+	abortTerminal(): void {
+		const message: WebviewMessage = {
+			type: "terminalOperation",
+			terminalOperation: "abort",
+		}
+		this.sendMessage(message)
+	}
+
+	// ===========================================================================
+	// Utility Methods
+	// ===========================================================================
+
+	/**
+	 * Reset the client state.
+	 *
+	 * This clears all internal state and history.
+	 * Useful when disconnecting or starting fresh.
+	 */
+	reset(): void {
+		this.store.reset()
+		this.emitter.removeAllListeners()
+	}
+
+	/**
+	 * Get the state history (if history tracking is enabled).
+	 */
+	getStateHistory() {
+		return this.store.getHistory()
+	}
+
+	/**
+	 * Enable or disable debug mode.
+	 */
+	setDebug(enabled: boolean): void {
+		this.debug = enabled
+		this.processor.setDebug(enabled)
+	}
+
+	// ===========================================================================
+	// Advanced: Direct Store Access
+	// ===========================================================================
+
+	/**
+	 * Get direct access to the state store.
+	 *
+	 * This is for advanced use cases where you need more control.
+	 * Most users should use the methods above instead.
+	 */
+	getStore(): StateStore {
+		return this.store
+	}
+
+	/**
+	 * Get direct access to the event emitter.
+	 */
+	getEmitter(): TypedEventEmitter {
+		return this.emitter
+	}
+}
+
+// =============================================================================
+// Factory Functions
+// =============================================================================
+
+/**
+ * Create a new ExtensionClient instance.
+ *
+ * This is a convenience function that creates a client with default settings.
+ *
+ * @param sendMessage - Function to send messages to the extension
+ * @returns A new ExtensionClient instance
+ */
+export function createClient(sendMessage: (message: WebviewMessage) => void): ExtensionClient {
+	return new ExtensionClient({ sendMessage })
+}
+
+/**
+ * Create a mock client for testing.
+ *
+ * The mock client captures all sent messages for verification.
+ *
+ * @returns An object with the client and captured messages
+ */
+export function createMockClient(): {
+	client: ExtensionClient
+	sentMessages: WebviewMessage[]
+	clearMessages: () => void
+} {
+	const sentMessages: WebviewMessage[] = []
+
+	const client = new ExtensionClient({
+		sendMessage: (message) => sentMessages.push(message),
+		debug: false,
+	})
+
+	return {
+		client,
+		sentMessages,
+		clearMessages: () => {
+			sentMessages.length = 0
+		},
+	}
+}

+ 542 - 0
apps/cli/src/agent/extension-host.ts

@@ -0,0 +1,542 @@
+/**
+ * ExtensionHost - Loads and runs the Roo Code extension in CLI mode
+ *
+ * This class is a thin coordination layer responsible for:
+ * 1. Creating the vscode-shim mock
+ * 2. Loading the extension bundle via require()
+ * 3. Activating the extension
+ * 4. Wiring up managers for output, prompting, and ask handling
+ */
+
+import { createRequire } from "module"
+import path from "path"
+import { fileURLToPath } from "url"
+import fs from "fs"
+import { EventEmitter } from "events"
+
+import pWaitFor from "p-wait-for"
+
+import type {
+	ClineMessage,
+	ExtensionMessage,
+	ReasoningEffortExtended,
+	RooCodeSettings,
+	WebviewMessage,
+} from "@roo-code/types"
+import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
+import { DebugLogger } from "@roo-code/core/cli"
+
+import type { SupportedProvider } from "@/types/index.js"
+import type { User } from "@/lib/sdk/index.js"
+import { getProviderSettings } from "@/lib/utils/provider.js"
+import { createEphemeralStorageDir } from "@/lib/storage/index.js"
+
+import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
+import type { AgentStateInfo } from "./agent-state.js"
+import { ExtensionClient } from "./extension-client.js"
+import { OutputManager } from "./output-manager.js"
+import { PromptManager } from "./prompt-manager.js"
+import { AskDispatcher } from "./ask-dispatcher.js"
+
+// Pre-configured logger for CLI message activity debugging.
+const cliLogger = new DebugLogger("CLI")
+
+// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
+// When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
+// In development, we fall back to calculating from __dirname.
+// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level.
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..")
+
+export interface ExtensionHostOptions {
+	mode: string
+	reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
+	user: User | null
+	provider: SupportedProvider
+	apiKey?: string
+	model: string
+	workspacePath: string
+	extensionPath: string
+	nonInteractive?: boolean
+	debug?: boolean
+	/**
+	 * When true, completely disables all direct stdout/stderr output.
+	 * Use this when running in TUI mode where Ink controls the terminal.
+	 */
+	disableOutput?: boolean
+	/**
+	 * When true, uses a temporary storage directory that is cleaned up on exit.
+	 */
+	ephemeral?: boolean
+	/**
+	 * When true, don't suppress node warnings and console output since we're
+	 * running in an integration test and we want to see the output.
+	 */
+	integrationTest?: boolean
+}
+
+interface ExtensionModule {
+	activate: (context: unknown) => Promise<unknown>
+	deactivate?: () => Promise<void>
+}
+
+interface WebviewViewProvider {
+	resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
+}
+
+export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
+	client: ExtensionClient
+	activate(): Promise<void>
+	runTask(prompt: string): Promise<void>
+	sendToExtension(message: WebviewMessage): void
+	dispose(): Promise<void>
+}
+
+export class ExtensionHost extends EventEmitter implements ExtensionHostInterface {
+	// Extension lifecycle.
+	private vscode: ReturnType<typeof createVSCodeAPI> | null = null
+	private extensionModule: ExtensionModule | null = null
+	private extensionAPI: unknown = null
+	private options: ExtensionHostOptions
+	private isReady = false
+	private messageListener: ((message: ExtensionMessage) => void) | null = null
+	private initialSettings: RooCodeSettings
+
+	// Console suppression.
+	private originalConsole: {
+		log: typeof console.log
+		warn: typeof console.warn
+		error: typeof console.error
+		debug: typeof console.debug
+		info: typeof console.info
+	} | null = null
+
+	private originalProcessEmitWarning: typeof process.emitWarning | null = null
+
+	// Ephemeral storage.
+	private ephemeralStorageDir: string | null = null
+
+	// ==========================================================================
+	// Managers - These do all the heavy lifting
+	// ==========================================================================
+
+	/**
+	 * ExtensionClient: Single source of truth for agent loop state.
+	 * Handles message processing and state detection.
+	 */
+	public readonly client: ExtensionClient
+
+	/**
+	 * OutputManager: Handles all CLI output and streaming.
+	 * Uses Observable pattern internally for stream tracking.
+	 */
+	private outputManager: OutputManager
+
+	/**
+	 * PromptManager: Handles all user input collection.
+	 * Provides readline, yes/no, and timed prompts.
+	 */
+	private promptManager: PromptManager
+
+	/**
+	 * AskDispatcher: Routes asks to appropriate handlers.
+	 * Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
+	 */
+	private askDispatcher: AskDispatcher
+
+	// ==========================================================================
+	// Constructor
+	// ==========================================================================
+
+	constructor(options: ExtensionHostOptions) {
+		super()
+
+		this.options = options
+		this.options.integrationTest = true
+
+		// Initialize client - single source of truth for agent state (including mode).
+		this.client = new ExtensionClient({
+			sendMessage: (msg) => this.sendToExtension(msg),
+			debug: options.debug, // Enable debug logging in the client.
+		})
+
+		// Initialize output manager.
+		this.outputManager = new OutputManager({
+			disabled: options.disableOutput,
+		})
+
+		// Initialize prompt manager with console mode callbacks.
+		this.promptManager = new PromptManager({
+			onBeforePrompt: () => this.restoreConsole(),
+			onAfterPrompt: () => this.setupQuietMode(),
+		})
+
+		// Initialize ask dispatcher.
+		this.askDispatcher = new AskDispatcher({
+			outputManager: this.outputManager,
+			promptManager: this.promptManager,
+			sendMessage: (msg) => this.sendToExtension(msg),
+			nonInteractive: options.nonInteractive,
+			disabled: options.disableOutput, // TUI mode handles asks directly.
+		})
+
+		// Wire up client events.
+		this.setupClientEventHandlers()
+
+		// Populate initial settings.
+		const baseSettings: RooCodeSettings = {
+			mode: this.options.mode,
+			commandExecutionTimeout: 30,
+			browserToolEnabled: false,
+			enableCheckpoints: false,
+			...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
+		}
+
+		this.initialSettings = this.options.nonInteractive
+			? {
+					autoApprovalEnabled: true,
+					alwaysAllowReadOnly: true,
+					alwaysAllowReadOnlyOutsideWorkspace: true,
+					alwaysAllowWrite: true,
+					alwaysAllowWriteOutsideWorkspace: true,
+					alwaysAllowWriteProtected: true,
+					alwaysAllowBrowser: true,
+					alwaysAllowMcp: true,
+					alwaysAllowModeSwitch: true,
+					alwaysAllowSubtasks: true,
+					alwaysAllowExecute: true,
+					allowedCommands: ["*"],
+					...baseSettings,
+				}
+			: {
+					autoApprovalEnabled: false,
+					...baseSettings,
+				}
+
+		if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
+			if (this.options.reasoningEffort === "disabled") {
+				this.initialSettings.enableReasoningEffort = false
+			} else {
+				this.initialSettings.enableReasoningEffort = true
+				this.initialSettings.reasoningEffort = this.options.reasoningEffort
+			}
+		}
+
+		this.setupQuietMode()
+	}
+
+	// ==========================================================================
+	// Client Event Handlers
+	// ==========================================================================
+
+	/**
+	 * Wire up client events to managers.
+	 * The client emits events, managers handle them.
+	 */
+	private setupClientEventHandlers(): void {
+		// Handle new messages - delegate to OutputManager.
+		this.client.on("message", (msg: ClineMessage) => {
+			this.logMessageDebug(msg, "new")
+			this.outputManager.outputMessage(msg)
+		})
+
+		// Handle message updates - delegate to OutputManager.
+		this.client.on("messageUpdated", (msg: ClineMessage) => {
+			this.logMessageDebug(msg, "updated")
+			this.outputManager.outputMessage(msg)
+		})
+
+		// Handle waiting for input - delegate to AskDispatcher.
+		this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
+			this.askDispatcher.handleAsk(event.message)
+		})
+
+		// Handle task completion.
+		this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
+			// Output completion message via OutputManager.
+			// Note: completion_result is an "ask" type, not a "say" type.
+			if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
+				this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
+			}
+		})
+	}
+
+	// ==========================================================================
+	// Logging + Console Suppression
+	// ==========================================================================
+
+	private setupQuietMode(): void {
+		if (this.options.integrationTest) {
+			return
+		}
+
+		// Suppress node warnings.
+		this.originalProcessEmitWarning = process.emitWarning
+		process.emitWarning = () => {}
+		process.on("warning", () => {})
+
+		// Suppress console output.
+		this.originalConsole = {
+			log: console.log,
+			warn: console.warn,
+			error: console.error,
+			debug: console.debug,
+			info: console.info,
+		}
+
+		console.log = () => {}
+		console.warn = () => {}
+		console.debug = () => {}
+		console.info = () => {}
+	}
+
+	private restoreConsole(): void {
+		if (this.options.integrationTest) {
+			return
+		}
+
+		if (this.originalConsole) {
+			console.log = this.originalConsole.log
+			console.warn = this.originalConsole.warn
+			console.error = this.originalConsole.error
+			console.debug = this.originalConsole.debug
+			console.info = this.originalConsole.info
+			this.originalConsole = null
+		}
+
+		if (this.originalProcessEmitWarning) {
+			process.emitWarning = this.originalProcessEmitWarning
+			this.originalProcessEmitWarning = null
+		}
+	}
+
+	private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
+		if (msg.partial) {
+			if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
+				this.outputManager.setLoggedFirstPartial(msg.ts)
+				cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
+			}
+		} else {
+			cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
+			this.outputManager.clearLoggedFirstPartial(msg.ts)
+		}
+	}
+
+	// ==========================================================================
+	// Extension Lifecycle
+	// ==========================================================================
+
+	public async activate(): Promise<void> {
+		const bundlePath = path.join(this.options.extensionPath, "extension.js")
+
+		if (!fs.existsSync(bundlePath)) {
+			this.restoreConsole()
+			throw new Error(`Extension bundle not found at: ${bundlePath}`)
+		}
+
+		let storageDir: string | undefined
+
+		if (this.options.ephemeral) {
+			this.ephemeralStorageDir = await createEphemeralStorageDir()
+			storageDir = this.ephemeralStorageDir
+		}
+
+		// Create VSCode API mock.
+		this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
+			appRoot: CLI_PACKAGE_ROOT,
+			storageDir,
+		})
+		;(global as Record<string, unknown>).vscode = this.vscode
+		;(global as Record<string, unknown>).__extensionHost = this
+
+		// Set up module resolution.
+		const require = createRequire(import.meta.url)
+		const Module = require("module")
+		const originalResolve = Module._resolveFilename
+
+		Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) {
+			if (request === "vscode") return "vscode-mock"
+			return originalResolve.call(this, request, parent, isMain, options)
+		}
+
+		require.cache["vscode-mock"] = {
+			id: "vscode-mock",
+			filename: "vscode-mock",
+			loaded: true,
+			exports: this.vscode,
+			children: [],
+			paths: [],
+			path: "",
+			isPreloading: false,
+			parent: null,
+			require: require,
+		} as unknown as NodeJS.Module
+
+		try {
+			this.extensionModule = require(bundlePath) as ExtensionModule
+		} catch (error) {
+			Module._resolveFilename = originalResolve
+
+			throw new Error(
+				`Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
+			)
+		}
+
+		Module._resolveFilename = originalResolve
+
+		try {
+			this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
+		} catch (error) {
+			throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
+		}
+
+		// Set up message listener - forward all messages to client.
+		this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message)
+		this.on("extensionWebviewMessage", this.messageListener)
+
+		await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 })
+	}
+
+	public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {}
+
+	public unregisterWebviewProvider(_viewId: string): void {}
+
+	public markWebviewReady(): void {
+		this.isReady = true
+
+		// Send initial webview messages to trigger proper extension initialization.
+		// This is critical for the extension to start sending state updates properly.
+		this.sendToExtension({ type: "webviewDidLaunch" })
+
+		setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
+		this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
+	}
+
+	public isInInitialSetup(): boolean {
+		return !this.isReady
+	}
+
+	// ==========================================================================
+	// Message Handling
+	// ==========================================================================
+
+	public sendToExtension(message: WebviewMessage): void {
+		if (!this.isReady) {
+			throw new Error("You cannot send messages to the extension before it is ready")
+		}
+
+		this.emit("webviewMessage", message)
+	}
+
+	// ==========================================================================
+	// Task Management
+	// ==========================================================================
+
+	public async runTask(prompt: string): Promise<void> {
+		this.sendToExtension({ type: "newTask", text: prompt })
+
+		return new Promise((resolve, reject) => {
+			let timeoutId: NodeJS.Timeout | null = null
+			const timeoutMs: number = 110_000
+
+			const completeHandler = () => {
+				cleanup()
+				resolve()
+			}
+
+			const errorHandler = (error: Error) => {
+				cleanup()
+				reject(error)
+			}
+
+			const cleanup = () => {
+				if (timeoutId) {
+					clearTimeout(timeoutId)
+					timeoutId = null
+				}
+
+				this.client.off("taskCompleted", completeHandler)
+				this.client.off("error", errorHandler)
+			}
+
+			// Set timeout to prevent indefinite hanging.
+			timeoutId = setTimeout(() => {
+				cleanup()
+				reject(
+					new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
+				)
+			}, timeoutMs)
+
+			this.client.once("taskCompleted", completeHandler)
+			this.client.once("error", errorHandler)
+		})
+	}
+
+	// ==========================================================================
+	// Public Agent State API
+	// ==========================================================================
+
+	/**
+	 * Get the current agent loop state.
+	 */
+	public getAgentState(): AgentStateInfo {
+		return this.client.getAgentState()
+	}
+
+	/**
+	 * Check if the agent is currently waiting for user input.
+	 */
+	public isWaitingForInput(): boolean {
+		return this.client.getAgentState().isWaitingForInput
+	}
+
+	// ==========================================================================
+	// Cleanup
+	// ==========================================================================
+
+	async dispose(): Promise<void> {
+		// Clear managers.
+		this.outputManager.clear()
+		this.askDispatcher.clear()
+
+		// Remove message listener.
+		if (this.messageListener) {
+			this.off("extensionWebviewMessage", this.messageListener)
+			this.messageListener = null
+		}
+
+		// Reset client.
+		this.client.reset()
+
+		// Deactivate extension.
+		if (this.extensionModule?.deactivate) {
+			try {
+				await this.extensionModule.deactivate()
+			} catch {
+				// NO-OP
+			}
+		}
+
+		// Clear references.
+		this.vscode = null
+		this.extensionModule = null
+		this.extensionAPI = null
+
+		// Clear globals.
+		delete (global as Record<string, unknown>).vscode
+		delete (global as Record<string, unknown>).__extensionHost
+
+		// Restore console.
+		this.restoreConsole()
+
+		// Clean up ephemeral storage.
+		if (this.ephemeralStorageDir) {
+			try {
+				await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
+				this.ephemeralStorageDir = null
+			} catch {
+				// NO-OP
+			}
+		}
+	}
+}

+ 1 - 0
apps/cli/src/agent/index.ts

@@ -0,0 +1 @@
+export * from "./extension-host.js"

+ 479 - 0
apps/cli/src/agent/message-processor.ts

@@ -0,0 +1,479 @@
+/**
+ * Message Processor
+ *
+ * This module handles incoming messages from the extension host and dispatches
+ * appropriate state updates and events. It acts as the bridge between raw
+ * extension messages and the client's internal state management.
+ *
+ * Message Flow:
+ * ```
+ * Extension Host ──▶ MessageProcessor ──▶ StateStore ──▶ Events
+ * ```
+ *
+ * The processor handles different message types:
+ * - "state": Full state update from extension
+ * - "messageUpdated": Single message update
+ * - "action": UI action triggers
+ * - "invoke": Command invocations
+ */
+
+import { ExtensionMessage, ClineMessage } from "@roo-code/types"
+import { debugLog } from "@roo-code/core/cli"
+
+import type { StateStore } from "./state-store.js"
+import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
+import {
+	isSignificantStateChange,
+	transitionedToWaiting,
+	transitionedToRunning,
+	streamingStarted,
+	streamingEnded,
+	taskCompleted,
+} from "./events.js"
+import type { AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Message Processor Options
+// =============================================================================
+
+export interface MessageProcessorOptions {
+	/**
+	 * Whether to emit events for every state change, or only significant ones.
+	 * Default: true (emit all changes)
+	 */
+	emitAllStateChanges?: boolean
+
+	/**
+	 * Whether to log debug information.
+	 * Default: false
+	 */
+	debug?: boolean
+}
+
+// =============================================================================
+// Message Processor Class
+// =============================================================================
+
+/**
+ * MessageProcessor handles incoming extension messages and updates state accordingly.
+ *
+ * It is responsible for:
+ * 1. Parsing and validating incoming messages
+ * 2. Updating the state store
+ * 3. Emitting appropriate events
+ *
+ * Usage:
+ * ```typescript
+ * const store = new StateStore()
+ * const emitter = new TypedEventEmitter()
+ * const processor = new MessageProcessor(store, emitter)
+ *
+ * // Process a message from the extension
+ * processor.processMessage(extensionMessage)
+ * ```
+ */
+export class MessageProcessor {
+	private store: StateStore
+	private emitter: TypedEventEmitter
+	private options: Required<MessageProcessorOptions>
+
+	constructor(store: StateStore, emitter: TypedEventEmitter, options: MessageProcessorOptions = {}) {
+		this.store = store
+		this.emitter = emitter
+		this.options = {
+			emitAllStateChanges: options.emitAllStateChanges ?? true,
+			debug: options.debug ?? false,
+		}
+	}
+
+	// ===========================================================================
+	// Main Processing Methods
+	// ===========================================================================
+
+	/**
+	 * Process an incoming message from the extension host.
+	 *
+	 * This is the main entry point for all extension messages.
+	 * It routes messages to the appropriate handler based on type.
+	 *
+	 * @param message - The raw message from the extension
+	 */
+	processMessage(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Received message", { type: message.type })
+		}
+
+		try {
+			switch (message.type) {
+				case "state":
+					this.handleStateMessage(message)
+					break
+
+				case "messageUpdated":
+					this.handleMessageUpdated(message)
+					break
+
+				case "action":
+					this.handleAction(message)
+					break
+
+				case "invoke":
+					this.handleInvoke(message)
+					break
+
+				default:
+					// Other message types are not relevant to state detection
+					if (this.options.debug) {
+						debugLog("[MessageProcessor] Ignoring message", { type: message.type })
+					}
+			}
+		} catch (error) {
+			const err = error instanceof Error ? error : new Error(String(error))
+			debugLog("[MessageProcessor] Error processing message", { error: err.message })
+			this.emitter.emit("error", err)
+		}
+	}
+
+	/**
+	 * Process an array of messages (for batch updates).
+	 */
+	processMessages(messages: ExtensionMessage[]): void {
+		for (const message of messages) {
+			this.processMessage(message)
+		}
+	}
+
+	// ===========================================================================
+	// Message Type Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle a "state" message - full state update from extension.
+	 *
+	 * This is the most important message type for state detection.
+	 * It contains the complete clineMessages array which is the source of truth.
+	 */
+	private handleStateMessage(message: ExtensionMessage): void {
+		if (!message.state) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] State message missing state payload")
+			}
+			return
+		}
+
+		const { clineMessages, mode } = message.state
+
+		// Track mode changes.
+		if (mode && typeof mode === "string") {
+			const previousMode = this.store.getCurrentMode()
+
+			if (previousMode !== mode) {
+				if (this.options.debug) {
+					debugLog("[MessageProcessor] Mode changed", { from: previousMode, to: mode })
+				}
+
+				this.store.setCurrentMode(mode)
+				this.emitter.emit("modeChanged", { previousMode, currentMode: mode })
+			}
+		}
+
+		if (!clineMessages) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] State message missing clineMessages")
+			}
+			return
+		}
+
+		// Get previous state for comparison.
+		const previousState = this.store.getAgentState()
+
+		// Update the store with new messages
+		// Note: We only call setMessages, NOT setExtensionState, to avoid
+		// double processing (setExtensionState would call setMessages again)
+		this.store.setMessages(clineMessages)
+
+		// Get new state after update
+		const currentState = this.store.getAgentState()
+
+		// Debug logging for state message
+		if (this.options.debug) {
+			const lastMsg = clineMessages[clineMessages.length - 1]
+			const lastMsgInfo = lastMsg
+				? {
+						msgType: lastMsg.type === "ask" ? `ask:${lastMsg.ask}` : `say:${lastMsg.say}`,
+						partial: lastMsg.partial,
+						textPreview: lastMsg.text?.substring(0, 50),
+					}
+				: null
+			debugLog("[MessageProcessor] State update", {
+				messageCount: clineMessages.length,
+				lastMessage: lastMsgInfo,
+				stateTransition: `${previousState.state} → ${currentState.state}`,
+				currentAsk: currentState.currentAsk,
+				isWaitingForInput: currentState.isWaitingForInput,
+				isStreaming: currentState.isStreaming,
+				isRunning: currentState.isRunning,
+			})
+		}
+
+		// Emit events based on state changes
+		this.emitStateChangeEvents(previousState, currentState)
+
+		// Emit new message events for any messages we haven't seen
+		this.emitNewMessageEvents(previousState, currentState, clineMessages)
+	}
+
+	/**
+	 * Handle a "messageUpdated" message - single message update.
+	 *
+	 * This is sent when a message is modified (e.g., partial -> complete).
+	 */
+	private handleMessageUpdated(message: ExtensionMessage): void {
+		if (!message.clineMessage) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] messageUpdated missing clineMessage")
+			}
+			return
+		}
+
+		const clineMessage = message.clineMessage
+		const previousState = this.store.getAgentState()
+
+		// Update the message in the store
+		this.store.updateMessage(clineMessage)
+
+		const currentState = this.store.getAgentState()
+
+		// Emit message updated event
+		this.emitter.emit("messageUpdated", clineMessage)
+
+		// Emit state change events
+		this.emitStateChangeEvents(previousState, currentState)
+	}
+
+	/**
+	 * Handle an "action" message - UI action trigger.
+	 *
+	 * These are typically used to trigger UI behaviors and don't
+	 * directly affect agent state, but we can track them if needed.
+	 */
+	private handleAction(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Action", { action: message.action })
+		}
+		// Actions don't affect agent state, but subclasses could override this
+	}
+
+	/**
+	 * Handle an "invoke" message - command invocation.
+	 *
+	 * These are commands that should trigger specific behaviors.
+	 */
+	private handleInvoke(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Invoke", { invoke: message.invoke })
+		}
+		// Invokes don't directly affect state detection
+		// But they might trigger state changes through subsequent messages
+	}
+
+	// ===========================================================================
+	// Event Emission Helpers
+	// ===========================================================================
+
+	/**
+	 * Emit events based on state changes.
+	 */
+	private emitStateChangeEvents(previousState: AgentStateInfo, currentState: AgentStateInfo): void {
+		const isSignificant = isSignificantStateChange(previousState, currentState)
+
+		// Emit stateChange event
+		if (this.options.emitAllStateChanges || isSignificant) {
+			const changeEvent: AgentStateChangeEvent = {
+				previousState,
+				currentState,
+				isSignificantChange: isSignificant,
+			}
+			this.emitter.emit("stateChange", changeEvent)
+		}
+
+		// Emit specific transition events
+
+		// Waiting for input
+		if (transitionedToWaiting(previousState, currentState)) {
+			if (currentState.currentAsk && currentState.lastMessage) {
+				if (this.options.debug) {
+					debugLog("[MessageProcessor] EMIT waitingForInput", {
+						ask: currentState.currentAsk,
+						action: currentState.requiredAction,
+					})
+				}
+				const waitingEvent: WaitingForInputEvent = {
+					ask: currentState.currentAsk,
+					stateInfo: currentState,
+					message: currentState.lastMessage,
+				}
+				this.emitter.emit("waitingForInput", waitingEvent)
+			}
+		}
+
+		// Resumed running
+		if (transitionedToRunning(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT resumedRunning")
+			}
+			this.emitter.emit("resumedRunning", undefined as void)
+		}
+
+		// Streaming started
+		if (streamingStarted(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT streamingStarted")
+			}
+			this.emitter.emit("streamingStarted", undefined as void)
+		}
+
+		// Streaming ended
+		if (streamingEnded(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT streamingEnded")
+			}
+			this.emitter.emit("streamingEnded", undefined as void)
+		}
+
+		// Task completed
+		if (taskCompleted(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT taskCompleted", {
+					success: currentState.currentAsk === "completion_result",
+				})
+			}
+			const completedEvent: TaskCompletedEvent = {
+				success: currentState.currentAsk === "completion_result",
+				stateInfo: currentState,
+				message: currentState.lastMessage,
+			}
+			this.emitter.emit("taskCompleted", completedEvent)
+		}
+	}
+
+	/**
+	 * Emit events for new messages.
+	 *
+	 * We compare the previous and current message counts to find new messages.
+	 * This is a simple heuristic - for more accuracy, we'd track by timestamp.
+	 */
+	private emitNewMessageEvents(
+		_previousState: AgentStateInfo,
+		_currentState: AgentStateInfo,
+		messages: ClineMessage[],
+	): void {
+		// For now, just emit the last message as new
+		// A more sophisticated implementation would track seen message timestamps
+		const lastMessage = messages[messages.length - 1]
+		if (lastMessage) {
+			this.emitter.emit("message", lastMessage)
+		}
+	}
+
+	// ===========================================================================
+	// Utility Methods
+	// ===========================================================================
+
+	/**
+	 * Manually trigger a task cleared event.
+	 * Call this when you send a clearTask message to the extension.
+	 */
+	notifyTaskCleared(): void {
+		this.store.clear()
+		this.emitter.emit("taskCleared", undefined as void)
+	}
+
+	/**
+	 * Enable or disable debug logging.
+	 */
+	setDebug(enabled: boolean): void {
+		this.options.debug = enabled
+	}
+}
+
+// =============================================================================
+// Message Validation Helpers
+// =============================================================================
+
+/**
+ * Check if a message is a valid ClineMessage.
+ * Useful for validating messages before processing.
+ */
+export function isValidClineMessage(message: unknown): message is ClineMessage {
+	if (!message || typeof message !== "object") {
+		return false
+	}
+
+	const msg = message as Record<string, unknown>
+
+	// Required fields
+	if (typeof msg.ts !== "number") {
+		return false
+	}
+
+	if (msg.type !== "ask" && msg.type !== "say") {
+		return false
+	}
+
+	return true
+}
+
+/**
+ * Check if a message is a valid ExtensionMessage.
+ */
+export function isValidExtensionMessage(message: unknown): message is ExtensionMessage {
+	if (!message || typeof message !== "object") {
+		return false
+	}
+
+	const msg = message as Record<string, unknown>
+
+	// Must have a type
+	if (typeof msg.type !== "string") {
+		return false
+	}
+
+	return true
+}
+
+// =============================================================================
+// Message Parsing Utilities
+// =============================================================================
+
+/**
+ * Parse a JSON string into an ExtensionMessage.
+ * Returns undefined if parsing fails.
+ */
+export function parseExtensionMessage(json: string): ExtensionMessage | undefined {
+	try {
+		const parsed = JSON.parse(json)
+		if (isValidExtensionMessage(parsed)) {
+			return parsed
+		}
+		return undefined
+	} catch {
+		return undefined
+	}
+}
+
+/**
+ * Parse the text field of an api_req_started message.
+ * Returns undefined if parsing fails or text is not present.
+ */
+export function parseApiReqStartedText(message: ClineMessage): { cost?: number } | undefined {
+	if (message.say !== "api_req_started" || !message.text) {
+		return undefined
+	}
+
+	try {
+		return JSON.parse(message.text)
+	} catch {
+		return undefined
+	}
+}

+ 414 - 0
apps/cli/src/agent/output-manager.ts

@@ -0,0 +1,414 @@
+/**
+ * OutputManager - Handles all CLI output and streaming
+ *
+ * This manager is responsible for:
+ * - Writing messages to stdout/stderr
+ * - Tracking what's been displayed (to avoid duplicates)
+ * - Managing streaming content with delta computation
+ * - Formatting different message types appropriately
+ *
+ * Design notes:
+ * - Uses the Observable pattern from client/events.ts for internal state
+ * - Single responsibility: CLI output only (no prompting, no state detection)
+ * - Can be disabled for TUI mode where Ink controls the terminal
+ */
+
+import { ClineMessage, ClineSay } from "@roo-code/types"
+
+import { Observable } from "./events.js"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Tracks what we've displayed for a specific message ts.
+ */
+export interface DisplayedMessage {
+	ts: number
+	text: string
+	partial: boolean
+}
+
+/**
+ * Tracks streaming state for a message.
+ */
+export interface StreamState {
+	ts: number
+	text: string
+	headerShown: boolean
+}
+
+/**
+ * Configuration options for OutputManager.
+ */
+export interface OutputManagerOptions {
+	/**
+	 * When true, completely disables all output.
+	 * Use for TUI mode where another system controls the terminal.
+	 */
+	disabled?: boolean
+
+	/**
+	 * Stream for normal output (default: process.stdout).
+	 */
+	stdout?: NodeJS.WriteStream
+
+	/**
+	 * Stream for error output (default: process.stderr).
+	 */
+	stderr?: NodeJS.WriteStream
+}
+
+// =============================================================================
+// OutputManager Class
+// =============================================================================
+
+export class OutputManager {
+	private disabled: boolean
+	private stdout: NodeJS.WriteStream
+	private stderr: NodeJS.WriteStream
+
+	/**
+	 * Track displayed messages by ts to avoid duplicate output.
+	 * Observable pattern allows external systems to subscribe if needed.
+	 */
+	private displayedMessages = new Map<number, DisplayedMessage>()
+
+	/**
+	 * Track streamed content by ts for delta computation.
+	 */
+	private streamedContent = new Map<number, StreamState>()
+
+	/**
+	 * Track which ts is currently streaming (for newline management).
+	 */
+	private currentlyStreamingTs: number | null = null
+
+	/**
+	 * Track first partial logs (for debugging first/last pattern).
+	 */
+	private loggedFirstPartial = new Set<number>()
+
+	/**
+	 * Observable for streaming state changes.
+	 * External systems can subscribe to know when streaming starts/ends.
+	 */
+	public readonly streamingState = new Observable<{ ts: number | null; isStreaming: boolean }>({
+		ts: null,
+		isStreaming: false,
+	})
+
+	constructor(options: OutputManagerOptions = {}) {
+		this.disabled = options.disabled ?? false
+		this.stdout = options.stdout ?? process.stdout
+		this.stderr = options.stderr ?? process.stderr
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Output a ClineMessage based on its type.
+	 * This is the main entry point for message output.
+	 *
+	 * @param msg - The message to output
+	 * @param skipFirstUserMessage - If true, skip the first "text" message (user prompt echo)
+	 */
+	outputMessage(msg: ClineMessage, skipFirstUserMessage = true): void {
+		const ts = msg.ts
+		const text = msg.text || ""
+		const isPartial = msg.partial === true
+		const previousDisplay = this.displayedMessages.get(ts)
+		const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial
+
+		if (msg.type === "say" && msg.say) {
+			this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
+		} else if (msg.type === "ask" && msg.ask) {
+			// For ask messages, we only output command_output here
+			// Other asks are handled by AskDispatcher
+			if (msg.ask === "command_output") {
+				this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
+			}
+		}
+	}
+
+	/**
+	 * Output a simple text line with a label.
+	 */
+	output(label: string, text?: string): void {
+		if (this.disabled) return
+		const message = text ? `${label} ${text}\n` : `${label}\n`
+		this.stdout.write(message)
+	}
+
+	/**
+	 * Output an error message.
+	 */
+	outputError(label: string, text?: string): void {
+		if (this.disabled) return
+		const message = text ? `${label} ${text}\n` : `${label}\n`
+		this.stderr.write(message)
+	}
+
+	/**
+	 * Write raw text to stdout (for streaming).
+	 */
+	writeRaw(text: string): void {
+		if (this.disabled) return
+		this.stdout.write(text)
+	}
+
+	/**
+	 * Check if a message has already been fully displayed.
+	 */
+	isAlreadyDisplayed(ts: number): boolean {
+		const displayed = this.displayedMessages.get(ts)
+		return displayed !== undefined && !displayed.partial
+	}
+
+	/**
+	 * Check if we're currently streaming any message.
+	 */
+	isCurrentlyStreaming(): boolean {
+		return this.currentlyStreamingTs !== null
+	}
+
+	/**
+	 * Get the ts of the currently streaming message.
+	 */
+	getCurrentlyStreamingTs(): number | null {
+		return this.currentlyStreamingTs
+	}
+
+	/**
+	 * Mark a message as displayed (useful for external coordination).
+	 */
+	markDisplayed(ts: number, text: string, partial: boolean): void {
+		this.displayedMessages.set(ts, { ts, text, partial })
+	}
+
+	/**
+	 * Clear all tracking state.
+	 * Call this when starting a new task.
+	 */
+	clear(): void {
+		this.displayedMessages.clear()
+		this.streamedContent.clear()
+		this.currentlyStreamingTs = null
+		this.loggedFirstPartial.clear()
+		this.streamingState.next({ ts: null, isStreaming: false })
+	}
+
+	/**
+	 * Get debugging info about first partial logging.
+	 */
+	hasLoggedFirstPartial(ts: number): boolean {
+		return this.loggedFirstPartial.has(ts)
+	}
+
+	/**
+	 * Record that we've logged the first partial for a ts.
+	 */
+	setLoggedFirstPartial(ts: number): void {
+		this.loggedFirstPartial.add(ts)
+	}
+
+	/**
+	 * Clear the first partial record (when complete).
+	 */
+	clearLoggedFirstPartial(ts: number): void {
+		this.loggedFirstPartial.delete(ts)
+	}
+
+	// ===========================================================================
+	// Say Message Output
+	// ===========================================================================
+
+	private outputSayMessage(
+		ts: number,
+		say: ClineSay,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+		skipFirstUserMessage: boolean,
+	): void {
+		switch (say) {
+			case "text":
+				this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
+				break
+
+			// case "thinking": - not a valid ClineSay type
+			case "reasoning":
+				this.outputReasoningMessage(ts, text, isPartial, alreadyDisplayedComplete)
+				break
+
+			case "command_output":
+				this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
+				break
+
+			// Note: completion_result is an "ask" type, not a "say" type.
+			// It is handled via the TaskCompleted event in extension-host.ts
+
+			case "error":
+				if (!alreadyDisplayedComplete) {
+					this.outputError("\n[error]", text || "Unknown error")
+					this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
+				}
+				break
+
+			case "api_req_started":
+				// Silent - no output needed
+				break
+
+			default:
+				// NO-OP for unknown say types
+				break
+		}
+	}
+
+	private outputTextMessage(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+		skipFirstUserMessage: boolean,
+	): void {
+		// Skip the initial user prompt echo (first message with no prior messages)
+		if (skipFirstUserMessage && this.displayedMessages.size === 0 && !this.displayedMessages.has(ts)) {
+			this.displayedMessages.set(ts, { ts, text, partial: !!isPartial })
+			return
+		}
+
+		if (isPartial && text) {
+			// Stream partial content
+			this.streamContent(ts, text, "[assistant]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			// Message complete - ensure all content is output
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				// We were streaming - output any remaining delta and finish
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				// Not streamed yet - output complete message
+				this.output("\n[assistant]", text)
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	private outputReasoningMessage(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+	): void {
+		if (isPartial && text) {
+			this.streamContent(ts, text, "[reasoning]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			// Reasoning complete - finish the stream
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				this.output("\n[reasoning]", text)
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+		}
+	}
+
+	/**
+	 * Output command_output (shared between say and ask types).
+	 */
+	outputCommandOutput(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+	): void {
+		if (isPartial && text) {
+			this.streamContent(ts, text, "[command output]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				this.writeRaw("\n[command output] ")
+				this.writeRaw(text)
+				this.writeRaw("\n")
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	// ===========================================================================
+	// Streaming Helpers
+	// ===========================================================================
+
+	/**
+	 * Stream content with delta computation - only output new characters.
+	 */
+	streamContent(ts: number, text: string, header: string): void {
+		const previous = this.streamedContent.get(ts)
+
+		if (!previous) {
+			// First time seeing this message - output header and initial text
+			this.writeRaw(`\n${header} `)
+			this.writeRaw(text)
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+			this.currentlyStreamingTs = ts
+			this.streamingState.next({ ts, isStreaming: true })
+		} else if (text.length > previous.text.length && text.startsWith(previous.text)) {
+			// Text has grown - output delta
+			const delta = text.slice(previous.text.length)
+			this.writeRaw(delta)
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	/**
+	 * Finish streaming a message (add newline).
+	 */
+	finishStream(ts: number): void {
+		if (this.currentlyStreamingTs === ts) {
+			this.writeRaw("\n")
+			this.currentlyStreamingTs = null
+			this.streamingState.next({ ts: null, isStreaming: false })
+		}
+	}
+
+	/**
+	 * Output completion message (called from TaskCompleted handler).
+	 */
+	outputCompletionResult(ts: number, text: string): void {
+		const previousDisplay = this.displayedMessages.get(ts)
+		if (!previousDisplay || previousDisplay.partial) {
+			this.output("\n[task complete]", text || "")
+			this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
+		}
+	}
+}

+ 297 - 0
apps/cli/src/agent/prompt-manager.ts

@@ -0,0 +1,297 @@
+/**
+ * PromptManager - Handles all user input collection
+ *
+ * This manager is responsible for:
+ * - Collecting user input via readline
+ * - Yes/No prompts with proper defaults
+ * - Timed prompts that auto-select after timeout
+ * - Raw mode input for character-by-character handling
+ *
+ * Design notes:
+ * - Single responsibility: User input only (no output formatting)
+ * - Returns Promises for all input operations
+ * - Handles console mode switching (quiet mode restore)
+ * - Can be disabled for programmatic (non-interactive) use
+ */
+
+import readline from "readline"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Configuration options for PromptManager.
+ */
+export interface PromptManagerOptions {
+	/**
+	 * Called before prompting to restore console output.
+	 * Used to exit quiet mode temporarily.
+	 */
+	onBeforePrompt?: () => void
+
+	/**
+	 * Called after prompting to re-enable quiet mode.
+	 */
+	onAfterPrompt?: () => void
+
+	/**
+	 * Stream for input (default: process.stdin).
+	 */
+	stdin?: NodeJS.ReadStream
+
+	/**
+	 * Stream for prompt output (default: process.stdout).
+	 */
+	stdout?: NodeJS.WriteStream
+}
+
+/**
+ * Result of a timed prompt.
+ */
+export interface TimedPromptResult {
+	/** The user's input, or default if timed out */
+	value: string
+	/** Whether the result came from timeout */
+	timedOut: boolean
+	/** Whether the user cancelled (Ctrl+C) */
+	cancelled: boolean
+}
+
+// =============================================================================
+// PromptManager Class
+// =============================================================================
+
+export class PromptManager {
+	private onBeforePrompt?: () => void
+	private onAfterPrompt?: () => void
+	private stdin: NodeJS.ReadStream
+	private stdout: NodeJS.WriteStream
+
+	/**
+	 * Track if a prompt is currently active.
+	 */
+	private isPrompting = false
+
+	constructor(options: PromptManagerOptions = {}) {
+		this.onBeforePrompt = options.onBeforePrompt
+		this.onAfterPrompt = options.onAfterPrompt
+		this.stdin = options.stdin ?? (process.stdin as NodeJS.ReadStream)
+		this.stdout = options.stdout ?? process.stdout
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Check if a prompt is currently active.
+	 */
+	isActive(): boolean {
+		return this.isPrompting
+	}
+
+	/**
+	 * Prompt for text input using readline.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @returns The user's input
+	 * @throws If input is cancelled or an error occurs
+	 */
+	async promptForInput(prompt: string): Promise<string> {
+		return new Promise((resolve, reject) => {
+			this.beforePrompt()
+			this.isPrompting = true
+
+			const rl = readline.createInterface({
+				input: this.stdin,
+				output: this.stdout,
+			})
+
+			rl.question(prompt, (answer) => {
+				rl.close()
+				this.isPrompting = false
+				this.afterPrompt()
+				resolve(answer)
+			})
+
+			rl.on("close", () => {
+				this.isPrompting = false
+				this.afterPrompt()
+			})
+
+			rl.on("error", (err) => {
+				rl.close()
+				this.isPrompting = false
+				this.afterPrompt()
+				reject(err)
+			})
+		})
+	}
+
+	/**
+	 * Prompt for yes/no input.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param defaultValue - Default value if empty input (default: false)
+	 * @returns true for yes, false for no
+	 */
+	async promptForYesNo(prompt: string, defaultValue = false): Promise<boolean> {
+		const answer = await this.promptForInput(prompt)
+		const normalized = answer.trim().toLowerCase()
+		if (normalized === "" && defaultValue !== undefined) {
+			return defaultValue
+		}
+		return normalized === "y" || normalized === "yes"
+	}
+
+	/**
+	 * Prompt for input with a timeout.
+	 * Uses raw mode for character-by-character input handling.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param timeoutMs - Timeout in milliseconds
+	 * @param defaultValue - Value to use if timed out
+	 * @returns TimedPromptResult with value, timedOut flag, and cancelled flag
+	 */
+	async promptWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise<TimedPromptResult> {
+		return new Promise((resolve) => {
+			this.beforePrompt()
+			this.isPrompting = true
+
+			// Track the original raw mode state to restore it later
+			const wasRaw = this.stdin.isRaw
+
+			// Enable raw mode for character-by-character input if TTY
+			if (this.stdin.isTTY) {
+				this.stdin.setRawMode(true)
+			}
+
+			this.stdin.resume()
+
+			let inputBuffer = ""
+			let timeoutCancelled = false
+			let resolved = false
+
+			// Set up timeout
+			const timeout = setTimeout(() => {
+				if (!resolved) {
+					resolved = true
+					cleanup()
+					this.stdout.write(`\n[Timeout - using default: ${defaultValue || "(empty)"}]\n`)
+					resolve({ value: defaultValue, timedOut: true, cancelled: false })
+				}
+			}, timeoutMs)
+
+			// Display prompt
+			this.stdout.write(prompt)
+
+			// Cleanup function to restore state
+			const cleanup = () => {
+				clearTimeout(timeout)
+				this.stdin.removeListener("data", onData)
+
+				if (this.stdin.isTTY && wasRaw !== undefined) {
+					this.stdin.setRawMode(wasRaw)
+				}
+
+				this.stdin.pause()
+				this.isPrompting = false
+				this.afterPrompt()
+			}
+
+			// Handle incoming data
+			const onData = (data: Buffer) => {
+				const char = data.toString()
+
+				// Handle Ctrl+C
+				if (char === "\x03") {
+					cleanup()
+					resolved = true
+					this.stdout.write("\n[cancelled]\n")
+					resolve({ value: defaultValue, timedOut: false, cancelled: true })
+					return
+				}
+
+				// Cancel timeout on first input
+				if (!timeoutCancelled) {
+					timeoutCancelled = true
+					clearTimeout(timeout)
+				}
+
+				// Handle Enter
+				if (char === "\r" || char === "\n") {
+					if (!resolved) {
+						resolved = true
+						cleanup()
+						this.stdout.write("\n")
+						resolve({ value: inputBuffer, timedOut: false, cancelled: false })
+					}
+					return
+				}
+
+				// Handle Backspace
+				if (char === "\x7f" || char === "\b") {
+					if (inputBuffer.length > 0) {
+						inputBuffer = inputBuffer.slice(0, -1)
+						this.stdout.write("\b \b")
+					}
+					return
+				}
+
+				// Normal character - add to buffer and echo
+				inputBuffer += char
+				this.stdout.write(char)
+			}
+
+			this.stdin.on("data", onData)
+		})
+	}
+
+	/**
+	 * Prompt for yes/no with timeout.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param timeoutMs - Timeout in milliseconds
+	 * @param defaultValue - Default boolean value if timed out
+	 * @returns true for yes, false for no
+	 */
+	async promptForYesNoWithTimeout(prompt: string, timeoutMs: number, defaultValue: boolean): Promise<boolean> {
+		const result = await this.promptWithTimeout(prompt, timeoutMs, defaultValue ? "y" : "n")
+		const normalized = result.value.trim().toLowerCase()
+		if (result.timedOut || result.cancelled || normalized === "") {
+			return defaultValue
+		}
+		return normalized === "y" || normalized === "yes"
+	}
+
+	/**
+	 * Display a message on stdout (utility for prompting context).
+	 */
+	write(text: string): void {
+		this.stdout.write(text)
+	}
+
+	/**
+	 * Display a message with newline.
+	 */
+	writeLine(text: string): void {
+		this.stdout.write(text + "\n")
+	}
+
+	// ===========================================================================
+	// Private Helpers
+	// ===========================================================================
+
+	private beforePrompt(): void {
+		if (this.onBeforePrompt) {
+			this.onBeforePrompt()
+		}
+	}
+
+	private afterPrompt(): void {
+		if (this.onAfterPrompt) {
+			this.onAfterPrompt()
+		}
+	}
+}

+ 415 - 0
apps/cli/src/agent/state-store.ts

@@ -0,0 +1,415 @@
+/**
+ * State Store
+ *
+ * This module manages the client's internal state, including:
+ * - The clineMessages array (source of truth for agent state)
+ * - The computed agent state info
+ * - Any extension state we want to cache
+ *
+ * The store is designed to be:
+ * - Immutable: State updates create new objects, not mutations
+ * - Observable: Changes trigger notifications
+ * - Queryable: Current state is always accessible
+ */
+
+import { ClineMessage, ExtensionState } from "@roo-code/types"
+
+import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js"
+import { Observable } from "./events.js"
+
+// =============================================================================
+// Store State Interface
+// =============================================================================
+
+/**
+ * The complete state managed by the store.
+ */
+export interface StoreState {
+	/**
+	 * The array of messages from the extension.
+	 * This is the primary data used to compute agent state.
+	 */
+	messages: ClineMessage[]
+
+	/**
+	 * The computed agent state info.
+	 * Updated automatically when messages change.
+	 */
+	agentState: AgentStateInfo
+
+	/**
+	 * Whether we have received any state from the extension.
+	 * Useful to distinguish "no task" from "not yet connected".
+	 */
+	isInitialized: boolean
+
+	/**
+	 * The last time state was updated.
+	 */
+	lastUpdatedAt: number
+
+	/**
+	 * The current mode (e.g., "code", "architect", "ask").
+	 * Tracked from state messages received from the extension.
+	 */
+	currentMode: string | undefined
+
+	/**
+	 * Optional: Cache of extension state fields we might need.
+	 * This is a subset of the full ExtensionState.
+	 */
+	extensionState?: Partial<ExtensionState>
+}
+
+/**
+ * Create the initial store state.
+ */
+function createInitialState(): StoreState {
+	return {
+		messages: [],
+		agentState: detectAgentState([]),
+		isInitialized: false,
+		lastUpdatedAt: Date.now(),
+		currentMode: undefined,
+	}
+}
+
+// =============================================================================
+// State Store Class
+// =============================================================================
+
+/**
+ * StateStore manages all client state and provides reactive updates.
+ *
+ * Key features:
+ * - Stores the clineMessages array
+ * - Automatically computes agent state when messages change
+ * - Provides observable pattern for state changes
+ * - Tracks state history for debugging (optional)
+ *
+ * Usage:
+ * ```typescript
+ * const store = new StateStore()
+ *
+ * // Subscribe to state changes
+ * store.subscribe((state) => {
+ *   console.log('New state:', state.agentState.state)
+ * })
+ *
+ * // Update messages
+ * store.setMessages(newMessages)
+ *
+ * // Query current state
+ * const currentState = store.getState()
+ * ```
+ */
+export class StateStore {
+	private state: StoreState
+	private stateObservable: Observable<StoreState>
+	private agentStateObservable: Observable<AgentStateInfo>
+
+	/**
+	 * Optional: Track state history for debugging.
+	 * Set maxHistorySize to enable.
+	 */
+	private stateHistory: StoreState[] = []
+	private maxHistorySize: number
+
+	constructor(options: { maxHistorySize?: number } = {}) {
+		this.state = createInitialState()
+		this.stateObservable = new Observable<StoreState>(this.state)
+		this.agentStateObservable = new Observable<AgentStateInfo>(this.state.agentState)
+		this.maxHistorySize = options.maxHistorySize ?? 0
+	}
+
+	// ===========================================================================
+	// State Queries
+	// ===========================================================================
+
+	/**
+	 * Get the current complete state.
+	 */
+	getState(): StoreState {
+		return this.state
+	}
+
+	/**
+	 * Get just the agent state info.
+	 * This is a convenience method for the most common query.
+	 */
+	getAgentState(): AgentStateInfo {
+		return this.state.agentState
+	}
+
+	/**
+	 * Get the current messages array.
+	 */
+	getMessages(): ClineMessage[] {
+		return this.state.messages
+	}
+
+	/**
+	 * Get the last message, if any.
+	 */
+	getLastMessage(): ClineMessage | undefined {
+		return this.state.messages[this.state.messages.length - 1]
+	}
+
+	/**
+	 * Check if the store has been initialized with extension state.
+	 */
+	isInitialized(): boolean {
+		return this.state.isInitialized
+	}
+
+	/**
+	 * Quick check: Is the agent currently waiting for input?
+	 */
+	isWaitingForInput(): boolean {
+		return this.state.agentState.isWaitingForInput
+	}
+
+	/**
+	 * Quick check: Is the agent currently running?
+	 */
+	isRunning(): boolean {
+		return this.state.agentState.isRunning
+	}
+
+	/**
+	 * Quick check: Is content currently streaming?
+	 */
+	isStreaming(): boolean {
+		return this.state.agentState.isStreaming
+	}
+
+	/**
+	 * Get the current agent loop state enum value.
+	 */
+	getCurrentState(): AgentLoopState {
+		return this.state.agentState.state
+	}
+
+	/**
+	 * Get the current mode (e.g., "code", "architect", "ask").
+	 */
+	getCurrentMode(): string | undefined {
+		return this.state.currentMode
+	}
+
+	// ===========================================================================
+	// State Updates
+	// ===========================================================================
+
+	/**
+	 * Set the complete messages array.
+	 * This is typically called when receiving a full state update from the extension.
+	 *
+	 * @param messages - The new messages array
+	 * @returns The previous agent state (for comparison)
+	 */
+	setMessages(messages: ClineMessage[]): AgentStateInfo {
+		const previousAgentState = this.state.agentState
+		const newAgentState = detectAgentState(messages)
+
+		this.updateState({
+			messages,
+			agentState: newAgentState,
+			isInitialized: true,
+			lastUpdatedAt: Date.now(),
+			currentMode: this.state.currentMode, // Preserve mode across message updates
+		})
+
+		return previousAgentState
+	}
+
+	/**
+	 * Add a single message to the end of the messages array.
+	 * Useful when receiving incremental updates.
+	 *
+	 * @param message - The message to add
+	 * @returns The previous agent state
+	 */
+	addMessage(message: ClineMessage): AgentStateInfo {
+		const newMessages = [...this.state.messages, message]
+		return this.setMessages(newMessages)
+	}
+
+	/**
+	 * Update a message in place (e.g., when partial becomes complete).
+	 * Finds the message by timestamp and replaces it.
+	 *
+	 * @param message - The updated message
+	 * @returns The previous agent state, or undefined if message not found
+	 */
+	updateMessage(message: ClineMessage): AgentStateInfo | undefined {
+		const index = this.state.messages.findIndex((m) => m.ts === message.ts)
+		if (index === -1) {
+			// Message not found, add it instead
+			return this.addMessage(message)
+		}
+
+		const newMessages = [...this.state.messages]
+		newMessages[index] = message
+		return this.setMessages(newMessages)
+	}
+
+	/**
+	 * Clear all messages and reset to initial state.
+	 * Called when a task is cleared/cancelled.
+	 */
+	clear(): void {
+		this.updateState({
+			messages: [],
+			agentState: detectAgentState([]),
+			isInitialized: true, // Still initialized, just empty
+			lastUpdatedAt: Date.now(),
+			currentMode: this.state.currentMode, // Preserve mode when clearing task
+			extensionState: undefined,
+		})
+	}
+
+	/**
+	 * Set the current mode.
+	 * Called when mode changes are detected from extension state messages.
+	 *
+	 * @param mode - The new mode value
+	 */
+	setCurrentMode(mode: string | undefined): void {
+		if (this.state.currentMode !== mode) {
+			this.updateState({
+				...this.state,
+				currentMode: mode,
+				lastUpdatedAt: Date.now(),
+			})
+		}
+	}
+
+	/**
+	 * Reset to completely uninitialized state.
+	 * Called on disconnect or reset.
+	 */
+	reset(): void {
+		this.state = createInitialState()
+		this.stateHistory = []
+		// Don't notify on reset - we're starting fresh
+	}
+
+	/**
+	 * Update cached extension state.
+	 * This stores any additional extension state fields we might need.
+	 *
+	 * @param extensionState - The extension state to cache
+	 */
+	setExtensionState(extensionState: Partial<ExtensionState>): void {
+		// Extract and store messages if present
+		if (extensionState.clineMessages) {
+			this.setMessages(extensionState.clineMessages)
+		}
+
+		// Store the rest of the extension state
+		this.updateState({
+			...this.state,
+			extensionState: {
+				...this.state.extensionState,
+				...extensionState,
+			},
+		})
+	}
+
+	// ===========================================================================
+	// Subscriptions
+	// ===========================================================================
+
+	/**
+	 * Subscribe to all state changes.
+	 *
+	 * @param observer - Callback function receiving the new state
+	 * @returns Unsubscribe function
+	 */
+	subscribe(observer: (state: StoreState) => void): () => void {
+		return this.stateObservable.subscribe(observer)
+	}
+
+	/**
+	 * Subscribe to agent state changes only.
+	 * This is more efficient if you only care about agent state.
+	 *
+	 * @param observer - Callback function receiving the new agent state
+	 * @returns Unsubscribe function
+	 */
+	subscribeToAgentState(observer: (state: AgentStateInfo) => void): () => void {
+		return this.agentStateObservable.subscribe(observer)
+	}
+
+	// ===========================================================================
+	// History (for debugging)
+	// ===========================================================================
+
+	/**
+	 * Get the state history (if enabled).
+	 */
+	getHistory(): StoreState[] {
+		return [...this.stateHistory]
+	}
+
+	/**
+	 * Clear the state history.
+	 */
+	clearHistory(): void {
+		this.stateHistory = []
+	}
+
+	// ===========================================================================
+	// Private Methods
+	// ===========================================================================
+
+	/**
+	 * Internal method to update state and notify observers.
+	 */
+	private updateState(newState: StoreState): void {
+		// Track history if enabled
+		if (this.maxHistorySize > 0) {
+			this.stateHistory.push(this.state)
+			if (this.stateHistory.length > this.maxHistorySize) {
+				this.stateHistory.shift()
+			}
+		}
+
+		this.state = newState
+
+		// Notify observers
+		this.stateObservable.next(this.state)
+		this.agentStateObservable.next(this.state.agentState)
+	}
+}
+
+// =============================================================================
+// Singleton Store (optional convenience)
+// =============================================================================
+
+let defaultStore: StateStore | null = null
+
+/**
+ * Get the default singleton store instance.
+ * Useful for simple applications that don't need multiple stores.
+ */
+export function getDefaultStore(): StateStore {
+	if (!defaultStore) {
+		defaultStore = new StateStore()
+	}
+
+	return defaultStore
+}
+
+/**
+ * Reset the default store instance.
+ * Useful for testing or when you need a fresh start.
+ */
+export function resetDefaultStore(): void {
+	if (defaultStore) {
+		defaultStore.reset()
+	}
+
+	defaultStore = null
+}

+ 3 - 0
apps/cli/src/commands/auth/index.ts

@@ -0,0 +1,3 @@
+export * from "./login.js"
+export * from "./logout.js"
+export * from "./status.js"

+ 186 - 0
apps/cli/src/commands/auth/login.ts

@@ -0,0 +1,186 @@
+import http from "http"
+import { randomBytes } from "crypto"
+import net from "net"
+import { exec } from "child_process"
+
+import { AUTH_BASE_URL } from "@/types/index.js"
+import { saveToken } from "@/lib/storage/index.js"
+
+export interface LoginOptions {
+	timeout?: number
+	verbose?: boolean
+}
+
+export interface LoginResult {
+	success: boolean
+	error?: string
+	userId?: string
+	orgId?: string | null
+}
+
+const LOCALHOST = "127.0.0.1"
+
+export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise<LoginResult> {
+	const state = randomBytes(16).toString("hex")
+	const port = await getAvailablePort()
+	const host = `http://${LOCALHOST}:${port}`
+
+	if (verbose) {
+		console.log(`[Auth] Starting local callback server on port ${port}`)
+	}
+
+	// Create promise that will be resolved when we receive the callback.
+	const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
+		const server = http.createServer((req, res) => {
+			const url = new URL(req.url!, host)
+
+			if (url.pathname === "/callback") {
+				const receivedState = url.searchParams.get("state")
+				const token = url.searchParams.get("token")
+				const error = url.searchParams.get("error")
+
+				if (error) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
+					errorUrl.searchParams.set("message", error)
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					// Wait for response to be fully sent before closing server and rejecting.
+					// The 'close' event fires when the underlying connection is terminated,
+					// ensuring the browser has received the redirect before we shut down.
+					res.on("close", () => {
+						server.close()
+						reject(new Error(error))
+					})
+				} else if (!token) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
+					errorUrl.searchParams.set("message", "Missing token in callback")
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						reject(new Error("Missing token in callback"))
+					})
+				} else if (receivedState !== state) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
+					errorUrl.searchParams.set("message", "Invalid state parameter (possible CSRF attack)")
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						reject(new Error("Invalid state parameter"))
+					})
+				} else {
+					res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						resolve({ token, state: receivedState })
+					})
+				}
+			} else {
+				res.writeHead(404, { "Content-Type": "text/plain" })
+				res.end("Not found")
+			}
+		})
+
+		server.listen(port, LOCALHOST)
+
+		const timeoutId = setTimeout(() => {
+			server.close()
+			reject(new Error("Authentication timed out"))
+		}, timeout)
+
+		server.on("listening", () => {
+			console.log(`[Auth] Callback server listening on port ${port}`)
+		})
+
+		server.on("close", () => {
+			console.log("[Auth] Callback server closed")
+			clearTimeout(timeoutId)
+		})
+	})
+
+	const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`)
+	authUrl.searchParams.set("state", state)
+	authUrl.searchParams.set("callback", `${host}/callback`)
+
+	console.log("Opening browser for authentication...")
+	console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`)
+
+	try {
+		await openBrowser(authUrl.toString())
+	} catch (error) {
+		if (verbose) {
+			console.warn("[Auth] Failed to open browser automatically:", error)
+		}
+
+		console.log("Please open the URL above in your browser manually.")
+	}
+
+	try {
+		const { token } = await tokenPromise
+		await saveToken(token)
+		console.log("✓ Successfully authenticated!")
+		return { success: true }
+	} catch (error) {
+		const message = error instanceof Error ? error.message : String(error)
+		console.error(`✗ Authentication failed: ${message}`)
+		return { success: false, error: message }
+	}
+}
+
+async function getAvailablePort(startPort = 49152, endPort = 65535): Promise<number> {
+	return new Promise((resolve, reject) => {
+		const server = net.createServer()
+		let port = startPort
+
+		const tryPort = () => {
+			server.once("error", (err: NodeJS.ErrnoException) => {
+				if (err.code === "EADDRINUSE" && port < endPort) {
+					port++
+					tryPort()
+				} else {
+					reject(err)
+				}
+			})
+
+			server.once("listening", () => {
+				server.close(() => {
+					resolve(port)
+				})
+			})
+
+			server.listen(port, LOCALHOST)
+		}
+
+		tryPort()
+	})
+}
+
+function openBrowser(url: string): Promise<void> {
+	return new Promise((resolve, reject) => {
+		const platform = process.platform
+		let command: string
+
+		switch (platform) {
+			case "darwin":
+				command = `open "${url}"`
+				break
+			case "win32":
+				command = `start "" "${url}"`
+				break
+			default:
+				// Linux and other Unix-like systems.
+				command = `xdg-open "${url}"`
+				break
+		}
+
+		exec(command, (error) => {
+			if (error) {
+				reject(error)
+			} else {
+				resolve()
+			}
+		})
+	})
+}

+ 27 - 0
apps/cli/src/commands/auth/logout.ts

@@ -0,0 +1,27 @@
+import { clearToken, hasToken, getCredentialsPath } from "@/lib/storage/index.js"
+
+export interface LogoutOptions {
+	verbose?: boolean
+}
+
+export interface LogoutResult {
+	success: boolean
+	wasLoggedIn: boolean
+}
+
+export async function logout({ verbose = false }: LogoutOptions = {}): Promise<LogoutResult> {
+	const wasLoggedIn = await hasToken()
+
+	if (!wasLoggedIn) {
+		console.log("You are not currently logged in.")
+		return { success: true, wasLoggedIn: false }
+	}
+
+	if (verbose) {
+		console.log(`[Auth] Removing credentials from ${getCredentialsPath()}`)
+	}
+
+	await clearToken()
+	console.log("✓ Successfully logged out")
+	return { success: true, wasLoggedIn: true }
+}

+ 97 - 0
apps/cli/src/commands/auth/status.ts

@@ -0,0 +1,97 @@
+import { loadToken, loadCredentials, getCredentialsPath } from "@/lib/storage/index.js"
+import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "@/lib/auth/index.js"
+
+export interface StatusOptions {
+	verbose?: boolean
+}
+
+export interface StatusResult {
+	authenticated: boolean
+	expired?: boolean
+	expiringSoon?: boolean
+	userId?: string
+	orgId?: string | null
+	expiresAt?: Date
+	createdAt?: Date
+}
+
+export async function status(options: StatusOptions = {}): Promise<StatusResult> {
+	const { verbose = false } = options
+
+	const token = await loadToken()
+
+	if (!token) {
+		console.log("✗ Not authenticated")
+		console.log("")
+		console.log("Run: roo auth login")
+		return { authenticated: false }
+	}
+
+	const expiresAt = getTokenExpirationDate(token)
+	const expired = !isTokenValid(token)
+	const expiringSoon = isTokenExpired(token, 24 * 60 * 60) && !expired
+
+	const credentials = await loadCredentials()
+	const createdAt = credentials?.createdAt ? new Date(credentials.createdAt) : undefined
+
+	if (expired) {
+		console.log("✗ Authentication token expired")
+		console.log("")
+		console.log("Run: roo auth login")
+
+		return {
+			authenticated: false,
+			expired: true,
+			expiresAt: expiresAt ?? undefined,
+		}
+	}
+
+	if (expiringSoon) {
+		console.log("⚠ Expires soon; refresh with `roo auth login`")
+	} else {
+		console.log("✓ Authenticated")
+	}
+
+	if (expiresAt) {
+		const remaining = getTimeRemaining(expiresAt)
+		console.log(`  Expires:      ${formatDate(expiresAt)} (${remaining})`)
+	}
+
+	if (createdAt && verbose) {
+		console.log(`  Created:      ${formatDate(createdAt)}`)
+	}
+
+	if (verbose) {
+		console.log(`  Credentials:  ${getCredentialsPath()}`)
+	}
+
+	return {
+		authenticated: true,
+		expired: false,
+		expiringSoon,
+		expiresAt: expiresAt ?? undefined,
+		createdAt,
+	}
+}
+
+function formatDate(date: Date): string {
+	return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
+}
+
+function getTimeRemaining(date: Date): string {
+	const now = new Date()
+	const diff = date.getTime() - now.getTime()
+
+	if (diff <= 0) {
+		return "expired"
+	}
+
+	const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+	const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
+
+	if (days > 0) {
+		return `${days} day${days === 1 ? "" : "s"}`
+	}
+
+	return `${hours} hour${hours === 1 ? "" : "s"}`
+}

+ 1 - 0
apps/cli/src/commands/cli/index.ts

@@ -0,0 +1 @@
+export * from "./run.js"

+ 219 - 0
apps/cli/src/commands/cli/run.ts

@@ -0,0 +1,219 @@
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { createElement } from "react"
+
+import { isProviderName } from "@roo-code/types"
+import { setLogger } from "@roo-code/vscode-shim"
+
+import {
+	FlagOptions,
+	isSupportedProvider,
+	OnboardingProviderChoice,
+	supportedProviders,
+	ASCII_ROO,
+	DEFAULT_FLAGS,
+	REASONING_EFFORTS,
+	SDK_BASE_URL,
+} from "@/types/index.js"
+
+import { type User, createClient } from "@/lib/sdk/index.js"
+import { loadToken, hasToken, loadSettings } from "@/lib/storage/index.js"
+import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
+import { runOnboarding } from "@/lib/utils/onboarding.js"
+import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
+import { VERSION } from "@/lib/utils/version.js"
+
+import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+export async function run(workspaceArg: string, options: FlagOptions) {
+	setLogger({
+		info: () => {},
+		warn: () => {},
+		error: () => {},
+		debug: () => {},
+	})
+
+	const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
+	const isTuiEnabled = options.tui && isTuiSupported
+	const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
+	const workspacePath = path.resolve(workspaceArg)
+
+	if (!isSupportedProvider(options.provider)) {
+		console.error(
+			`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
+		)
+
+		process.exit(1)
+	}
+
+	let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
+	let provider = options.provider
+	let user: User | null = null
+	let useCloudProvider = false
+
+	if (isTuiEnabled) {
+		let { onboardingProviderChoice } = await loadSettings()
+
+		if (!onboardingProviderChoice) {
+			const result = await runOnboarding()
+			onboardingProviderChoice = result.choice
+		}
+
+		if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
+			useCloudProvider = true
+			const authenticated = await hasToken()
+
+			if (authenticated) {
+				const token = await loadToken()
+
+				if (token) {
+					try {
+						const client = createClient({ url: SDK_BASE_URL, authToken: token })
+						const me = await client.auth.me.query()
+						provider = "roo"
+						apiKey = token
+						user = me?.type === "user" ? me.user : null
+					} catch {
+						// Token may be expired or invalid - user will need to re-authenticate.
+					}
+				}
+			}
+		}
+	}
+
+	if (!apiKey) {
+		if (useCloudProvider) {
+			console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
+			console.error("[CLI] Please run: roo auth login")
+			console.error("[CLI] Or use --api-key to provide your own API key.")
+		} else {
+			console.error(
+				`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
+			)
+			console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
+		}
+
+		process.exit(1)
+	}
+
+	if (!fs.existsSync(workspacePath)) {
+		console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
+		process.exit(1)
+	}
+
+	if (!isProviderName(options.provider)) {
+		console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
+		process.exit(1)
+	}
+
+	if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
+		console.error(
+			`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
+		)
+		process.exit(1)
+	}
+
+	if (options.tui && !isTuiSupported) {
+		console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
+	}
+
+	if (!isTuiEnabled && !options.prompt) {
+		console.error("[CLI] Error: prompt is required in plain text mode")
+		console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
+		console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
+		process.exit(1)
+	}
+
+	if (isTuiEnabled) {
+		try {
+			const { render } = await import("ink")
+			const { App } = await import("../../ui/App.js")
+
+			render(
+				createElement(App, {
+					initialPrompt: options.prompt || "",
+					workspacePath: workspacePath,
+					extensionPath: path.resolve(extensionPath),
+					user,
+					provider,
+					apiKey,
+					model: options.model || DEFAULT_FLAGS.model,
+					mode: options.mode || DEFAULT_FLAGS.mode,
+					nonInteractive: options.yes,
+					debug: options.debug,
+					exitOnComplete: options.exitOnComplete,
+					reasoningEffort: options.reasoningEffort,
+					ephemeral: options.ephemeral,
+					version: VERSION,
+					// Create extension host factory for dependency injection.
+					createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
+				}),
+				// Handle Ctrl+C in App component for double-press exit.
+				{ exitOnCtrlC: false },
+			)
+		} catch (error) {
+			console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
+
+			if (error instanceof Error) {
+				console.error(error.stack)
+			}
+
+			process.exit(1)
+		}
+	} else {
+		console.log(ASCII_ROO)
+		console.log()
+		console.log(
+			`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
+		)
+
+		const host = new ExtensionHost({
+			mode: options.mode || DEFAULT_FLAGS.mode,
+			reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
+			user,
+			provider,
+			apiKey,
+			model: options.model || DEFAULT_FLAGS.model,
+			workspacePath,
+			extensionPath: path.resolve(extensionPath),
+			nonInteractive: options.yes,
+			ephemeral: options.ephemeral,
+			debug: options.debug,
+		})
+
+		process.on("SIGINT", async () => {
+			console.log("\n[CLI] Received SIGINT, shutting down...")
+			await host.dispose()
+			process.exit(130)
+		})
+
+		process.on("SIGTERM", async () => {
+			console.log("\n[CLI] Received SIGTERM, shutting down...")
+			await host.dispose()
+			process.exit(143)
+		})
+
+		try {
+			await host.activate()
+			await host.runTask(options.prompt!)
+			await host.dispose()
+
+			if (!options.waitOnComplete) {
+				process.exit(0)
+			}
+		} catch (error) {
+			console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
+
+			if (error instanceof Error) {
+				console.error(error.stack)
+			}
+
+			await host.dispose()
+			process.exit(1)
+		}
+	}
+}

+ 2 - 0
apps/cli/src/commands/index.ts

@@ -0,0 +1,2 @@
+export * from "./auth/index.js"
+export * from "./cli/index.js"

+ 65 - 0
apps/cli/src/index.ts

@@ -0,0 +1,65 @@
+import { Command } from "commander"
+
+import { DEFAULT_FLAGS } from "@/types/constants.js"
+import { VERSION } from "@/lib/utils/version.js"
+import { run, login, logout, status } from "@/commands/index.js"
+
+const program = new Command()
+
+program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version(VERSION)
+
+program
+	.argument("[workspace]", "Workspace path to operate in", process.cwd())
+	.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
+	.option("-e, --extension <path>", "Path to the extension bundle directory")
+	.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
+	.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
+	.option("-k, --api-key <key>", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)")
+	.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
+	.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
+	.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
+	.option(
+		"-r, --reasoning-effort <effort>",
+		"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
+		DEFAULT_FLAGS.reasoningEffort,
+	)
+	.option("-x, --exit-on-complete", "Exit the process when the task completes (applies to TUI mode only)", false)
+	.option(
+		"-w, --wait-on-complete",
+		"Keep the process running when the task completes (applies to plain text mode only)",
+		false,
+	)
+	.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
+	.option("--no-tui", "Disable TUI, use plain text output")
+	.action(run)
+
+const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
+
+authCommand
+	.command("login")
+	.description("Authenticate with Roo Code Cloud")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await login({ verbose: options.verbose })
+		process.exit(result.success ? 0 : 1)
+	})
+
+authCommand
+	.command("logout")
+	.description("Log out from Roo Code Cloud")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await logout({ verbose: options.verbose })
+		process.exit(result.success ? 0 : 1)
+	})
+
+authCommand
+	.command("status")
+	.description("Show authentication status")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await status({ verbose: options.verbose })
+		process.exit(result.authenticated ? 0 : 1)
+	})
+
+program.parse()

+ 1 - 0
apps/cli/src/lib/auth/index.ts

@@ -0,0 +1 @@
+export * from "./token.js"

+ 61 - 0
apps/cli/src/lib/auth/token.ts

@@ -0,0 +1,61 @@
+export interface DecodedToken {
+	iss: string
+	sub: string
+	exp: number
+	iat: number
+	nbf: number
+	v: number
+	r?: {
+		u?: string
+		o?: string
+		t: string
+	}
+}
+
+function decodeToken(token: string): DecodedToken | null {
+	try {
+		const parts = token.split(".")
+
+		if (parts.length !== 3) {
+			return null
+		}
+
+		const payload = parts[1]
+
+		if (!payload) {
+			return null
+		}
+
+		const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
+		const decoded = Buffer.from(padded, "base64url").toString("utf-8")
+		return JSON.parse(decoded) as DecodedToken
+	} catch {
+		return null
+	}
+}
+
+export function isTokenExpired(token: string, bufferSeconds = 24 * 60 * 60): boolean {
+	const decoded = decodeToken(token)
+
+	if (!decoded?.exp) {
+		return true
+	}
+
+	const expiresAt = decoded.exp
+	const bufferTime = Math.floor(Date.now() / 1000) + bufferSeconds
+	return expiresAt < bufferTime
+}
+
+export function isTokenValid(token: string): boolean {
+	return !isTokenExpired(token, 0)
+}
+
+export function getTokenExpirationDate(token: string): Date | null {
+	const decoded = decodeToken(token)
+
+	if (!decoded?.exp) {
+		return null
+	}
+
+	return new Date(decoded.exp * 1000)
+}

+ 30 - 0
apps/cli/src/lib/sdk/client.ts

@@ -0,0 +1,30 @@
+import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"
+import superjson from "superjson"
+
+import type { User, Org } from "./types.js"
+
+export interface ClientConfig {
+	url: string
+	authToken: string
+}
+
+export interface RooClient {
+	auth: {
+		me: {
+			query: () => Promise<{ type: "user"; user: User } | { type: "org"; org: Org } | null>
+		}
+	}
+}
+
+export const createClient = ({ url, authToken }: ClientConfig): RooClient => {
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	return createTRPCProxyClient<any>({
+		links: [
+			httpBatchLink({
+				url: `${url}/trpc`,
+				transformer: superjson,
+				headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}),
+			}),
+		],
+	}) as unknown as RooClient
+}

+ 2 - 0
apps/cli/src/lib/sdk/index.ts

@@ -0,0 +1,2 @@
+export * from "./types.js"
+export * from "./client.js"

+ 31 - 0
apps/cli/src/lib/sdk/types.ts

@@ -0,0 +1,31 @@
+export interface User {
+	id: string
+	name: string
+	email: string
+	imageUrl: string | null
+	entity: {
+		id: string
+		username: string | null
+		image_url: string
+		last_name: string
+		first_name: string
+		email_addresses: { email_address: string }[]
+		public_metadata: Record<string, unknown>
+	}
+	publicMetadata: Record<string, unknown>
+	stripeCustomerId: string | null
+	lastSyncAt: string
+	deletedAt: string | null
+	createdAt: string
+	updatedAt: string
+}
+
+export interface Org {
+	id: string
+	name: string
+	slug: string
+	imageUrl: string | null
+	createdAt: string
+	updatedAt: string
+	deletedAt: string | null
+}

+ 152 - 0
apps/cli/src/lib/storage/__tests__/credentials.test.ts

@@ -0,0 +1,152 @@
+import fs from "fs/promises"
+import path from "path"
+
+// Use vi.hoisted to make the test directory available to the mock
+// This must return the path synchronously since CREDENTIALS_FILE is computed at import time
+const { getTestConfigDir } = vi.hoisted(() => {
+	// eslint-disable-next-line @typescript-eslint/no-require-imports
+	const os = require("os")
+	// eslint-disable-next-line @typescript-eslint/no-require-imports
+	const path = require("path")
+	const testRunId = Date.now().toString()
+	const testConfigDir = path.join(os.tmpdir(), `roo-cli-test-${testRunId}`)
+	return { getTestConfigDir: () => testConfigDir }
+})
+
+vi.mock("../config-dir.js", () => ({
+	getConfigDir: getTestConfigDir,
+}))
+
+// Import after mocking
+import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js"
+
+// Re-derive the test config dir for use in tests (must match the hoisted one)
+const actualTestConfigDir = getTestConfigDir()
+
+describe("Token Storage", () => {
+	const expectedCredentialsFile = path.join(actualTestConfigDir, "cli-credentials.json")
+
+	beforeEach(async () => {
+		// Clear test directory before each test
+		await fs.rm(actualTestConfigDir, { recursive: true, force: true })
+	})
+
+	afterAll(async () => {
+		// Clean up test directory
+		await fs.rm(actualTestConfigDir, { recursive: true, force: true })
+	})
+
+	describe("getCredentialsPath", () => {
+		it("should return the correct credentials file path", () => {
+			expect(getCredentialsPath()).toBe(expectedCredentialsFile)
+		})
+	})
+
+	describe("saveToken", () => {
+		it("should save token to disk", async () => {
+			const token = "test-token-123"
+			await saveToken(token)
+
+			const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
+			const credentials = JSON.parse(savedData)
+
+			expect(credentials.token).toBe(token)
+			expect(credentials.createdAt).toBeDefined()
+		})
+
+		it("should save token with user info", async () => {
+			const token = "test-token-456"
+			await saveToken(token, { userId: "user_123", orgId: "org_456" })
+
+			const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
+			const credentials = JSON.parse(savedData)
+
+			expect(credentials.token).toBe(token)
+			expect(credentials.userId).toBe("user_123")
+			expect(credentials.orgId).toBe("org_456")
+		})
+
+		it("should create config directory if it doesn't exist", async () => {
+			const token = "test-token-789"
+			await saveToken(token)
+
+			const dirStats = await fs.stat(actualTestConfigDir)
+			expect(dirStats.isDirectory()).toBe(true)
+		})
+
+		// Unix file permissions don't apply on Windows - skip this test
+		it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => {
+			const token = "test-token-perms"
+			await saveToken(token)
+
+			const stats = await fs.stat(expectedCredentialsFile)
+			// Check that only owner has read/write (mode 0o600)
+			const mode = stats.mode & 0o777
+			expect(mode).toBe(0o600)
+		})
+	})
+
+	describe("loadToken", () => {
+		it("should load saved token", async () => {
+			const token = "test-token-abc"
+			await saveToken(token)
+
+			const loaded = await loadToken()
+			expect(loaded).toBe(token)
+		})
+
+		it("should return null if no token exists", async () => {
+			const loaded = await loadToken()
+			expect(loaded).toBeNull()
+		})
+	})
+
+	describe("loadCredentials", () => {
+		it("should load full credentials", async () => {
+			const token = "test-token-def"
+			await saveToken(token, { userId: "user_789" })
+
+			const credentials = await loadCredentials()
+
+			expect(credentials).not.toBeNull()
+			expect(credentials?.token).toBe(token)
+			expect(credentials?.userId).toBe("user_789")
+			expect(credentials?.createdAt).toBeDefined()
+		})
+
+		it("should return null if no credentials exist", async () => {
+			const credentials = await loadCredentials()
+			expect(credentials).toBeNull()
+		})
+	})
+
+	describe("clearToken", () => {
+		it("should remove saved token", async () => {
+			const token = "test-token-ghi"
+			await saveToken(token)
+
+			await clearToken()
+
+			const loaded = await loadToken()
+			expect(loaded).toBeNull()
+		})
+
+		it("should not throw if no token exists", async () => {
+			await expect(clearToken()).resolves.not.toThrow()
+		})
+	})
+
+	describe("hasToken", () => {
+		it("should return true if token exists", async () => {
+			await saveToken("test-token-jkl")
+
+			const exists = await hasToken()
+			expect(exists).toBe(true)
+		})
+
+		it("should return false if no token exists", async () => {
+			const exists = await hasToken()
+			expect(exists).toBe(false)
+		})
+	})
+})

+ 240 - 0
apps/cli/src/lib/storage/__tests__/history.test.ts

@@ -0,0 +1,240 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+
+import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../history.js"
+
+vi.mock("fs/promises")
+
+vi.mock("os", async (importOriginal) => {
+	const actual = await importOriginal<typeof import("os")>()
+	return {
+		...actual,
+		default: {
+			...actual,
+			homedir: vi.fn(() => "/home/testuser"),
+		},
+		homedir: vi.fn(() => "/home/testuser"),
+	}
+})
+
+describe("historyStorage", () => {
+	beforeEach(() => {
+		vi.resetAllMocks()
+	})
+
+	describe("getHistoryFilePath", () => {
+		it("should return the correct path to cli-history.json", () => {
+			const result = getHistoryFilePath()
+			expect(result).toBe(path.join("/home/testuser", ".roo", "cli-history.json"))
+		})
+	})
+
+	describe("loadHistory", () => {
+		it("should return empty array when file does not exist", async () => {
+			const error = new Error("ENOENT") as NodeJS.ErrnoException
+			error.code = "ENOENT"
+			vi.mocked(fs.readFile).mockRejectedValue(error)
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+		})
+
+		it("should return entries from valid JSON file", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first command", "second command", "third command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual(["first command", "second command", "third command"])
+		})
+
+		it("should return empty array for invalid JSON", async () => {
+			vi.mocked(fs.readFile).mockResolvedValue("not valid json")
+
+			// Suppress console.error for this test
+			const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+			consoleSpy.mockRestore()
+		})
+
+		it("should filter out non-string entries", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["valid", 123, "also valid", null, ""],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual(["valid", "also valid"])
+		})
+
+		it("should return empty array when entries is not an array", async () => {
+			const mockData = {
+				version: 1,
+				entries: "not an array",
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+		})
+	})
+
+	describe("saveHistory", () => {
+		it("should create directory and save history", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			await saveHistory(["command1", "command2"])
+
+			expect(fs.mkdir).toHaveBeenCalledWith(path.join("/home/testuser", ".roo"), { recursive: true })
+			expect(fs.writeFile).toHaveBeenCalled()
+
+			// Verify the content written
+			const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
+			const writtenContent = JSON.parse(writeCall?.[1] as string)
+			expect(writtenContent.version).toBe(1)
+			expect(writtenContent.entries).toEqual(["command1", "command2"])
+		})
+
+		it("should trim entries to MAX_HISTORY_ENTRIES", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			// Create array larger than MAX_HISTORY_ENTRIES
+			const manyEntries = Array.from({ length: MAX_HISTORY_ENTRIES + 100 }, (_, i) => `command${i}`)
+
+			await saveHistory(manyEntries)
+
+			const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
+			const writtenContent = JSON.parse(writeCall?.[1] as string)
+			expect(writtenContent.entries.length).toBe(MAX_HISTORY_ENTRIES)
+			// Should keep the most recent entries (last 500)
+			expect(writtenContent.entries[0]).toBe(`command100`)
+			expect(writtenContent.entries[MAX_HISTORY_ENTRIES - 1]).toBe(`command${MAX_HISTORY_ENTRIES + 99}`)
+		})
+
+		it("should handle directory already exists error", async () => {
+			const error = new Error("EEXIST") as NodeJS.ErrnoException
+			error.code = "EEXIST"
+			vi.mocked(fs.mkdir).mockRejectedValue(error)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			// Should not throw
+			await expect(saveHistory(["command"])).resolves.not.toThrow()
+		})
+
+		it("should log warning on write error but not throw", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied"))
+
+			const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+			await expect(saveHistory(["command"])).resolves.not.toThrow()
+			expect(consoleSpy).toHaveBeenCalledWith(
+				expect.stringContaining("Could not save CLI history"),
+				expect.any(String),
+			)
+
+			consoleSpy.mockRestore()
+		})
+	})
+
+	describe("addToHistory", () => {
+		it("should add new entry to history", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("new command")
+
+			expect(result).toEqual(["existing command", "new command"])
+		})
+
+		it("should not add empty strings", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("")
+
+			expect(result).toEqual(["existing command"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should not add whitespace-only strings", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("   ")
+
+			expect(result).toEqual(["existing command"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should not add consecutive duplicates", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first", "second"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("second")
+
+			expect(result).toEqual(["first", "second"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should add non-consecutive duplicates", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first", "second"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("first")
+
+			expect(result).toEqual(["first", "second", "first"])
+		})
+
+		it("should trim whitespace from entry before adding", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("  new command  ")
+
+			expect(result).toEqual(["existing", "new command"])
+		})
+	})
+
+	describe("MAX_HISTORY_ENTRIES", () => {
+		it("should be 500", () => {
+			expect(MAX_HISTORY_ENTRIES).toBe(500)
+		})
+	})
+})

+ 22 - 0
apps/cli/src/lib/storage/config-dir.ts

@@ -0,0 +1,22 @@
+import fs from "fs/promises"
+import os from "os"
+import path from "path"
+
+const CONFIG_DIR = path.join(os.homedir(), ".roo")
+
+export function getConfigDir(): string {
+	return CONFIG_DIR
+}
+
+export async function ensureConfigDir(): Promise<void> {
+	try {
+		await fs.mkdir(CONFIG_DIR, { recursive: true })
+	} catch (err) {
+		// Directory may already exist, that's fine.
+		const error = err as NodeJS.ErrnoException
+
+		if (error.code !== "EEXIST") {
+			throw err
+		}
+	}
+}

+ 72 - 0
apps/cli/src/lib/storage/credentials.ts

@@ -0,0 +1,72 @@
+import fs from "fs/promises"
+import path from "path"
+
+import { getConfigDir } from "./index.js"
+
+const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json")
+
+export interface Credentials {
+	token: string
+	createdAt: string
+	userId?: string
+	orgId?: string
+}
+
+export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise<void> {
+	await fs.mkdir(getConfigDir(), { recursive: true })
+
+	const credentials: Credentials = {
+		token,
+		createdAt: new Date().toISOString(),
+		userId: options?.userId,
+		orgId: options?.orgId,
+	}
+
+	await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
+		mode: 0o600, // Read/write for owner only
+	})
+}
+
+export async function loadToken(): Promise<string | null> {
+	try {
+		const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
+		const credentials: Credentials = JSON.parse(data)
+		return credentials.token
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return null
+		}
+		throw error
+	}
+}
+
+export async function loadCredentials(): Promise<Credentials | null> {
+	try {
+		const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
+		return JSON.parse(data) as Credentials
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return null
+		}
+		throw error
+	}
+}
+
+export async function clearToken(): Promise<void> {
+	try {
+		await fs.unlink(CREDENTIALS_FILE)
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
+			throw error
+		}
+	}
+}
+
+export async function hasToken(): Promise<boolean> {
+	const token = await loadToken()
+	return token !== null
+}
+
+export function getCredentialsPath(): string {
+	return CREDENTIALS_FILE
+}

+ 10 - 0
apps/cli/src/lib/storage/ephemeral.ts

@@ -0,0 +1,10 @@
+import path from "path"
+import os from "os"
+import fs from "fs"
+
+export async function createEphemeralStorageDir(): Promise<string> {
+	const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
+	const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
+	await fs.promises.mkdir(tmpDir, { recursive: true })
+	return tmpDir
+}

+ 109 - 0
apps/cli/src/lib/storage/history.ts

@@ -0,0 +1,109 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+
+import { ensureConfigDir, getConfigDir } from "./config-dir.js"
+
+/** Maximum number of history entries to keep */
+export const MAX_HISTORY_ENTRIES = 500
+
+/** History file format version for future migrations */
+const HISTORY_VERSION = 1
+
+interface HistoryData {
+	version: number
+	entries: string[]
+}
+
+/**
+ * Get the path to the history file
+ */
+export function getHistoryFilePath(): string {
+	return path.join(getConfigDir(), "cli-history.json")
+}
+
+/**
+ * Load history entries from file
+ * Returns empty array if file doesn't exist or is invalid
+ */
+export async function loadHistory(): Promise<string[]> {
+	const filePath = getHistoryFilePath()
+
+	try {
+		const content = await fs.readFile(filePath, "utf-8")
+		const data: HistoryData = JSON.parse(content)
+
+		// Validate structure
+		if (!data || typeof data !== "object") {
+			return []
+		}
+
+		if (!Array.isArray(data.entries)) {
+			return []
+		}
+
+		// Filter to only valid strings
+		return data.entries.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
+	} catch (err) {
+		const error = err as NodeJS.ErrnoException
+		// File doesn't exist - that's expected on first run
+		if (error.code === "ENOENT") {
+			return []
+		}
+
+		// JSON parse error or other issue - log and return empty
+		console.error("Warning: Could not load CLI history:", error.message)
+		return []
+	}
+}
+
+/**
+ * Save history entries to file
+ * Creates the .roo directory if needed
+ * Trims to MAX_HISTORY_ENTRIES
+ */
+export async function saveHistory(entries: string[]): Promise<void> {
+	const filePath = getHistoryFilePath()
+
+	// Trim to max entries (keep most recent)
+	const trimmedEntries = entries.slice(-MAX_HISTORY_ENTRIES)
+
+	const data: HistoryData = {
+		version: HISTORY_VERSION,
+		entries: trimmedEntries,
+	}
+
+	try {
+		await ensureConfigDir()
+		await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8")
+	} catch (err) {
+		const error = err as NodeJS.ErrnoException
+		// Log but don't throw - history persistence is not critical
+		console.error("Warning: Could not save CLI history:", error.message)
+	}
+}
+
+/**
+ * Add a new entry to history and save
+ * Avoids adding consecutive duplicates or empty entries
+ * Returns the updated history array
+ */
+export async function addToHistory(entry: string): Promise<string[]> {
+	const trimmed = entry.trim()
+
+	// Don't add empty entries
+	if (!trimmed) {
+		return await loadHistory()
+	}
+
+	const history = await loadHistory()
+
+	// Don't add consecutive duplicates
+	if (history.length > 0 && history[history.length - 1] === trimmed) {
+		return history
+	}
+
+	const updated = [...history, trimmed]
+	await saveHistory(updated)
+
+	return updated.slice(-MAX_HISTORY_ENTRIES)
+}

+ 4 - 0
apps/cli/src/lib/storage/index.ts

@@ -0,0 +1,4 @@
+export * from "./config-dir.js"
+export * from "./settings.js"
+export * from "./credentials.js"
+export * from "./ephemeral.js"

+ 40 - 0
apps/cli/src/lib/storage/settings.ts

@@ -0,0 +1,40 @@
+import fs from "fs/promises"
+import path from "path"
+
+import type { CliSettings } from "@/types/index.js"
+
+import { getConfigDir } from "./index.js"
+
+export function getSettingsPath(): string {
+	return path.join(getConfigDir(), "cli-settings.json")
+}
+
+export async function loadSettings(): Promise<CliSettings> {
+	try {
+		const settingsPath = getSettingsPath()
+		const data = await fs.readFile(settingsPath, "utf-8")
+		return JSON.parse(data) as CliSettings
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return {}
+		}
+
+		throw error
+	}
+}
+
+export async function saveSettings(settings: Partial<CliSettings>): Promise<void> {
+	const configDir = getConfigDir()
+	await fs.mkdir(configDir, { recursive: true })
+
+	const existing = await loadSettings()
+	const merged = { ...existing, ...settings }
+
+	await fs.writeFile(getSettingsPath(), JSON.stringify(merged, null, 2), {
+		mode: 0o600,
+	})
+}
+
+export async function resetOnboarding(): Promise<void> {
+	await saveSettings({ onboardingProviderChoice: undefined })
+}

+ 102 - 0
apps/cli/src/lib/utils/__tests__/commands.test.ts

@@ -0,0 +1,102 @@
+import {
+	type GlobalCommand,
+	type GlobalCommandAction,
+	GLOBAL_COMMANDS,
+	getGlobalCommand,
+	getGlobalCommandsForAutocomplete,
+} from "../commands.js"
+
+describe("globalCommands", () => {
+	describe("GLOBAL_COMMANDS", () => {
+		it("should contain the /new command", () => {
+			const newCommand = GLOBAL_COMMANDS.find((cmd) => cmd.name === "new")
+			expect(newCommand).toBeDefined()
+			expect(newCommand?.action).toBe("clearTask")
+			expect(newCommand?.description).toBe("Start a new task")
+		})
+
+		it("should have valid structure for all commands", () => {
+			for (const cmd of GLOBAL_COMMANDS) {
+				expect(cmd.name).toBeTruthy()
+				expect(typeof cmd.name).toBe("string")
+				expect(cmd.description).toBeTruthy()
+				expect(typeof cmd.description).toBe("string")
+				expect(cmd.action).toBeTruthy()
+				expect(typeof cmd.action).toBe("string")
+			}
+		})
+	})
+
+	describe("getGlobalCommand", () => {
+		it("should return the command when found", () => {
+			const cmd = getGlobalCommand("new")
+			expect(cmd).toBeDefined()
+			expect(cmd?.name).toBe("new")
+			expect(cmd?.action).toBe("clearTask")
+		})
+
+		it("should return undefined for unknown commands", () => {
+			const cmd = getGlobalCommand("unknown-command")
+			expect(cmd).toBeUndefined()
+		})
+
+		it("should be case-sensitive", () => {
+			const cmd = getGlobalCommand("NEW")
+			expect(cmd).toBeUndefined()
+		})
+	})
+
+	describe("getGlobalCommandsForAutocomplete", () => {
+		it("should return commands in autocomplete format", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			expect(commands.length).toBe(GLOBAL_COMMANDS.length)
+
+			for (const cmd of commands) {
+				expect(cmd.name).toBeTruthy()
+				expect(cmd.source).toBe("global")
+				expect(cmd.action).toBeTruthy()
+			}
+		})
+
+		it("should include the /new command with correct format", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			const newCommand = commands.find((cmd) => cmd.name === "new")
+
+			expect(newCommand).toBeDefined()
+			expect(newCommand?.description).toBe("Start a new task")
+			expect(newCommand?.source).toBe("global")
+			expect(newCommand?.action).toBe("clearTask")
+		})
+
+		it("should not include argumentHint for action commands", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			// Action commands don't have argument hints
+			for (const cmd of commands) {
+				expect(cmd).not.toHaveProperty("argumentHint")
+			}
+		})
+	})
+
+	describe("type safety", () => {
+		it("should have valid GlobalCommandAction types", () => {
+			// This test ensures the type is properly constrained
+			const validActions: GlobalCommandAction[] = ["clearTask"]
+
+			for (const cmd of GLOBAL_COMMANDS) {
+				expect(validActions).toContain(cmd.action)
+			}
+		})
+
+		it("should match GlobalCommand interface", () => {
+			const testCommand: GlobalCommand = {
+				name: "test",
+				description: "Test command",
+				action: "clearTask",
+			}
+
+			expect(testCommand.name).toBe("test")
+			expect(testCommand.description).toBe("Test command")
+			expect(testCommand.action).toBe("clearTask")
+		})
+	})
+})

+ 54 - 0
apps/cli/src/lib/utils/__tests__/extension.test.ts

@@ -0,0 +1,54 @@
+import fs from "fs"
+import path from "path"
+
+import { getDefaultExtensionPath } from "../extension.js"
+
+vi.mock("fs")
+
+describe("getDefaultExtensionPath", () => {
+	const originalEnv = process.env
+
+	beforeEach(() => {
+		vi.resetAllMocks()
+		// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests.
+		process.env = { ...originalEnv }
+		delete process.env.ROO_EXTENSION_PATH
+	})
+
+	afterEach(() => {
+		process.env = originalEnv
+	})
+
+	it("should return monorepo path when extension.js exists there", () => {
+		const mockDirname = "/test/apps/cli/dist"
+		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+
+		vi.mocked(fs.existsSync).mockReturnValue(true)
+
+		const result = getDefaultExtensionPath(mockDirname)
+
+		expect(result).toBe(expectedMonorepoPath)
+		expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
+	})
+
+	it("should return package path when extension.js does not exist in monorepo path", () => {
+		const mockDirname = "/test/apps/cli/dist"
+		const expectedPackagePath = path.resolve(mockDirname, "../extension")
+
+		vi.mocked(fs.existsSync).mockReturnValue(false)
+
+		const result = getDefaultExtensionPath(mockDirname)
+
+		expect(result).toBe(expectedPackagePath)
+	})
+
+	it("should check monorepo path first", () => {
+		const mockDirname = "/some/path"
+		vi.mocked(fs.existsSync).mockReturnValue(false)
+
+		getDefaultExtensionPath(mockDirname)
+
+		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+		expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
+	})
+})

+ 128 - 0
apps/cli/src/lib/utils/__tests__/input.test.ts

@@ -0,0 +1,128 @@
+import type { Key } from "ink"
+
+import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js"
+
+function createKey(overrides: Partial<Key> = {}): Key {
+	return {
+		upArrow: false,
+		downArrow: false,
+		leftArrow: false,
+		rightArrow: false,
+		pageDown: false,
+		pageUp: false,
+		home: false,
+		end: false,
+		return: false,
+		escape: false,
+		ctrl: false,
+		shift: false,
+		tab: false,
+		backspace: false,
+		delete: false,
+		meta: false,
+		...overrides,
+	}
+}
+
+describe("globalInputSequences", () => {
+	describe("GLOBAL_INPUT_SEQUENCES registry", () => {
+		it("should have ctrl-c registered", () => {
+			const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c")
+			expect(seq).toBeDefined()
+			expect(seq?.description).toContain("Exit")
+		})
+
+		it("should have ctrl-m registered", () => {
+			const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m")
+			expect(seq).toBeDefined()
+			expect(seq?.description).toContain("mode")
+		})
+	})
+
+	describe("isGlobalInputSequence", () => {
+		describe("Ctrl+C detection", () => {
+			it("should match standard Ctrl+C", () => {
+				const result = isGlobalInputSequence("c", createKey({ ctrl: true }))
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-c")
+			})
+
+			it("should not match plain 'c' key", () => {
+				const result = isGlobalInputSequence("c", createKey())
+				expect(result).toBeUndefined()
+			})
+		})
+
+		describe("Ctrl+M detection", () => {
+			it("should match standard Ctrl+M", () => {
+				const result = isGlobalInputSequence("m", createKey({ ctrl: true }))
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should match CSI u encoding for Ctrl+M", () => {
+				const result = isGlobalInputSequence("\x1b[109;5u", createKey())
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should match input ending with CSI u sequence", () => {
+				const result = isGlobalInputSequence("[109;5u", createKey())
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should not match plain 'm' key", () => {
+				const result = isGlobalInputSequence("m", createKey())
+				expect(result).toBeUndefined()
+			})
+		})
+
+		it("should return undefined for non-global sequences", () => {
+			const result = isGlobalInputSequence("a", createKey())
+			expect(result).toBeUndefined()
+		})
+
+		it("should return undefined for regular text input", () => {
+			const result = isGlobalInputSequence("hello", createKey())
+			expect(result).toBeUndefined()
+		})
+	})
+
+	describe("matchesGlobalSequence", () => {
+		it("should return true for matching sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c")
+			expect(result).toBe(true)
+		})
+
+		it("should return false for non-matching sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for non-existent sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent")
+			expect(result).toBe(false)
+		})
+
+		it("should match ctrl-m with CSI u encoding", () => {
+			const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m")
+			expect(result).toBe(true)
+		})
+	})
+
+	describe("extensibility", () => {
+		it("should have unique IDs for all sequences", () => {
+			const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id)
+			const uniqueIds = new Set(ids)
+			expect(uniqueIds.size).toBe(ids.length)
+		})
+
+		it("should have descriptions for all sequences", () => {
+			for (const seq of GLOBAL_INPUT_SEQUENCES) {
+				expect(seq.description).toBeTruthy()
+				expect(seq.description.length).toBeGreaterThan(0)
+			}
+		})
+	})
+})

+ 68 - 0
apps/cli/src/lib/utils/__tests__/path.test.ts

@@ -0,0 +1,68 @@
+import { normalizePath, arePathsEqual } from "../path.js"
+
+// Helper to create platform-specific expected paths
+const expectedPath = (...segments: string[]) => {
+	// On Windows, path.normalize converts forward slashes to backslashes
+	// and paths like /Users become \Users (without a drive letter)
+	if (process.platform === "win32") {
+		return "\\" + segments.join("\\")
+	}
+
+	return "/" + segments.join("/")
+}
+
+describe("normalizePath", () => {
+	it("should remove trailing slashes", () => {
+		expect(normalizePath("/Users/test/project/")).toBe(expectedPath("Users", "test", "project"))
+		expect(normalizePath("/Users/test/project//")).toBe(expectedPath("Users", "test", "project"))
+	})
+
+	it("should handle paths without trailing slashes", () => {
+		expect(normalizePath("/Users/test/project")).toBe(expectedPath("Users", "test", "project"))
+	})
+
+	it("should normalize path separators", () => {
+		// path.normalize handles this
+		expect(normalizePath("/Users//test/project")).toBe(expectedPath("Users", "test", "project"))
+	})
+})
+
+describe("arePathsEqual", () => {
+	it("should return true for identical paths", () => {
+		expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true)
+	})
+
+	it("should return true for paths differing only by trailing slash", () => {
+		expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true)
+		expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true)
+	})
+
+	it("should return false for undefined or empty paths", () => {
+		expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", undefined)).toBe(false)
+		expect(arePathsEqual(undefined, undefined)).toBe(false)
+		expect(arePathsEqual("", "/Users/test/project")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", "")).toBe(false)
+	})
+
+	it("should return false for different paths", () => {
+		expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false)
+	})
+
+	// Case sensitivity behavior depends on platform
+	if (process.platform === "darwin" || process.platform === "win32") {
+		it("should be case-insensitive on macOS/Windows", () => {
+			expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true)
+			expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true)
+		})
+	} else {
+		it("should be case-sensitive on Linux", () => {
+			expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false)
+		})
+	}
+
+	it("should handle paths with multiple trailing slashes", () => {
+		expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true)
+	})
+})

+ 34 - 0
apps/cli/src/lib/utils/__tests__/provider.test.ts

@@ -0,0 +1,34 @@
+import { getApiKeyFromEnv } from "../provider.js"
+
+describe("getApiKeyFromEnv", () => {
+	const originalEnv = process.env
+
+	beforeEach(() => {
+		// Reset process.env before each test.
+		process.env = { ...originalEnv }
+	})
+
+	afterEach(() => {
+		process.env = originalEnv
+	})
+
+	it("should return API key from environment variable for anthropic", () => {
+		process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
+		expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
+	})
+
+	it("should return API key from environment variable for openrouter", () => {
+		process.env.OPENROUTER_API_KEY = "test-openrouter-key"
+		expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
+	})
+
+	it("should return API key from environment variable for openai", () => {
+		process.env.OPENAI_API_KEY = "test-openai-key"
+		expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
+	})
+
+	it("should return undefined when API key is not set", () => {
+		delete process.env.ANTHROPIC_API_KEY
+		expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
+	})
+})

+ 62 - 0
apps/cli/src/lib/utils/commands.ts

@@ -0,0 +1,62 @@
+/**
+ * CLI-specific global slash commands
+ *
+ * These commands are handled entirely within the CLI and trigger actions
+ * by sending messages to the extension host. They are separate from the
+ * extension's built-in commands which expand into prompt content.
+ */
+
+/**
+ * Action types that can be triggered by global commands.
+ * Each action corresponds to a message type sent to the extension host.
+ */
+export type GlobalCommandAction = "clearTask"
+
+/**
+ * Definition of a CLI global command
+ */
+export interface GlobalCommand {
+	/** Command name (without the leading /) */
+	name: string
+	/** Description shown in the autocomplete picker */
+	description: string
+	/** Action to trigger when the command is executed */
+	action: GlobalCommandAction
+}
+
+/**
+ * CLI-specific global slash commands
+ * These commands trigger actions rather than expanding into prompt content.
+ */
+export const GLOBAL_COMMANDS: GlobalCommand[] = [
+	{
+		name: "new",
+		description: "Start a new task",
+		action: "clearTask",
+	},
+]
+
+/**
+ * Get a global command by name
+ */
+export function getGlobalCommand(name: string): GlobalCommand | undefined {
+	return GLOBAL_COMMANDS.find((cmd) => cmd.name === name)
+}
+
+/**
+ * Get global commands formatted for autocomplete
+ * Returns commands in the SlashCommandResult format expected by the autocomplete trigger
+ */
+export function getGlobalCommandsForAutocomplete(): Array<{
+	name: string
+	description?: string
+	source: "global" | "project" | "built-in"
+	action?: string
+}> {
+	return GLOBAL_COMMANDS.map((cmd) => ({
+		name: cmd.name,
+		description: cmd.description,
+		source: "global" as const,
+		action: cmd.action,
+	}))
+}

+ 67 - 0
apps/cli/src/lib/utils/context-window.ts

@@ -0,0 +1,67 @@
+import type { ProviderSettings } from "@roo-code/types"
+
+import type { RouterModels } from "@/ui/store.js"
+
+const DEFAULT_CONTEXT_WINDOW = 200_000
+
+/**
+ * Looks up the context window size for the current model from routerModels.
+ *
+ * @param routerModels - The router models data containing model info per provider
+ * @param apiConfiguration - The current API configuration with provider and model ID
+ * @returns The context window size, or DEFAULT_CONTEXT_WINDOW (200K) if not found
+ */
+export function getContextWindow(routerModels: RouterModels | null, apiConfiguration: ProviderSettings | null): number {
+	if (!routerModels || !apiConfiguration) {
+		return DEFAULT_CONTEXT_WINDOW
+	}
+
+	const provider = apiConfiguration.apiProvider
+	const modelId = getModelIdForProvider(apiConfiguration)
+
+	if (!provider || !modelId) {
+		return DEFAULT_CONTEXT_WINDOW
+	}
+
+	const providerModels = routerModels[provider]
+	const modelInfo = providerModels?.[modelId]
+
+	return modelInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
+}
+
+/**
+ * Gets the model ID from the API configuration based on the provider type.
+ *
+ * Different providers store their model ID in different fields of ProviderSettings.
+ */
+function getModelIdForProvider(config: ProviderSettings): string | undefined {
+	switch (config.apiProvider) {
+		case "openrouter":
+			return config.openRouterModelId
+		case "ollama":
+			return config.ollamaModelId
+		case "lmstudio":
+			return config.lmStudioModelId
+		case "openai":
+			return config.openAiModelId
+		case "requesty":
+			return config.requestyModelId
+		case "litellm":
+			return config.litellmModelId
+		case "deepinfra":
+			return config.deepInfraModelId
+		case "huggingface":
+			return config.huggingFaceModelId
+		case "unbound":
+			return config.unboundModelId
+		case "vercel-ai-gateway":
+			return config.vercelAiGatewayModelId
+		case "io-intelligence":
+			return config.ioIntelligenceModelId
+		default:
+			// For anthropic, bedrock, vertex, gemini, xai, groq, etc.
+			return config.apiModelId
+	}
+}
+
+export { DEFAULT_CONTEXT_WINDOW }

+ 33 - 0
apps/cli/src/lib/utils/extension.ts

@@ -0,0 +1,33 @@
+import path from "path"
+import fs from "fs"
+
+/**
+ * Get the default path to the extension bundle.
+ * This assumes the CLI is installed alongside the built extension.
+ *
+ * @param dirname - The __dirname equivalent for the calling module
+ */
+export function getDefaultExtensionPath(dirname: string): string {
+	// Check for environment variable first (set by install script)
+	if (process.env.ROO_EXTENSION_PATH) {
+		const envPath = process.env.ROO_EXTENSION_PATH
+
+		if (fs.existsSync(path.join(envPath, "extension.js"))) {
+			return envPath
+		}
+	}
+
+	// __dirname is apps/cli/dist when bundled
+	// The extension is at src/dist (relative to monorepo root)
+	// So from apps/cli/dist, we need to go ../../../src/dist
+	const monorepoPath = path.resolve(dirname, "../../../src/dist")
+
+	// Try monorepo path first (for development)
+	if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
+		return monorepoPath
+	}
+
+	// Fallback: when installed via curl script, extension is at ../extension
+	const packagePath = path.resolve(dirname, "../extension")
+	return packagePath
+}

+ 122 - 0
apps/cli/src/lib/utils/input.ts

@@ -0,0 +1,122 @@
+/**
+ * Global Input Sequences Registry
+ *
+ * This module centralizes the definition of input sequences that should be
+ * handled at the App level (or other top-level components) and ignored by
+ * child components like MultilineTextInput.
+ *
+ * When adding new global shortcuts:
+ * 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES
+ * 2. The App.tsx useInput handler should check for and handle the sequence
+ * 3. Child components automatically ignore these via isGlobalInputSequence()
+ */
+
+import type { Key } from "ink"
+
+/**
+ * Definition of a global input sequence
+ */
+export interface GlobalInputSequence {
+	/** Unique identifier for the sequence */
+	id: string
+	/** Human-readable description */
+	description: string
+	/**
+	 * Matcher function - returns true if the input matches this sequence.
+	 * @param input - The raw input string from useInput
+	 * @param key - The parsed key object from useInput
+	 */
+	matches: (input: string, key: Key) => boolean
+}
+
+/**
+ * Registry of all global input sequences that should be handled at the App level
+ * and ignored by child components (like MultilineTextInput).
+ *
+ * Add new global shortcuts here to ensure they're properly handled throughout
+ * the application.
+ */
+export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [
+	{
+		id: "ctrl-c",
+		description: "Exit application (with confirmation)",
+		matches: (input, key) => key.ctrl && input === "c",
+	},
+	{
+		id: "ctrl-m",
+		description: "Cycle through modes",
+		matches: (input, key) => {
+			// Standard Ctrl+M detection
+			if (key.ctrl && input === "m") return true
+			// CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol)
+			// 109 = 'm' ASCII code, 5 = Ctrl modifier
+			if (input === "\x1b[109;5u") return true
+			if (input.endsWith("[109;5u")) return true
+			return false
+		},
+	},
+	{
+		id: "ctrl-t",
+		description: "Toggle TODO list viewer",
+		matches: (input, key) => {
+			// Standard Ctrl+T detection
+			if (key.ctrl && input === "t") return true
+			// CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol)
+			// 116 = 't' ASCII code, 5 = Ctrl modifier
+			if (input === "\x1b[116;5u") return true
+			if (input.endsWith("[116;5u")) return true
+			return false
+		},
+	},
+	// Add more global sequences here as needed:
+	// {
+	//   id: "ctrl-n",
+	//   description: "New task",
+	//   matches: (input, key) => key.ctrl && input === "n",
+	// },
+]
+
+/**
+ * Check if an input matches any global input sequence.
+ *
+ * Use this in child components (like MultilineTextInput) to determine
+ * if input should be ignored because it will be handled by a parent component.
+ *
+ * @param input - The raw input string from useInput
+ * @param key - The parsed key object from useInput
+ * @returns The matching GlobalInputSequence, or undefined if no match
+ *
+ * @example
+ * ```tsx
+ * useInput((input, key) => {
+ *   // Ignore inputs handled at App level
+ *   if (isGlobalInputSequence(input, key)) {
+ *     return
+ *   }
+ *   // Handle component-specific input...
+ * })
+ * ```
+ */
+export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined {
+	return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key))
+}
+
+/**
+ * Check if an input matches a specific global input sequence by ID.
+ *
+ * @param input - The raw input string from useInput
+ * @param key - The parsed key object from useInput
+ * @param id - The sequence ID to check for
+ * @returns true if the input matches the specified sequence
+ *
+ * @example
+ * ```tsx
+ * if (matchesGlobalSequence(input, key, "ctrl-m")) {
+ *   // Handle mode cycling
+ * }
+ * ```
+ */
+export function matchesGlobalSequence(input: string, key: Key, id: string): boolean {
+	const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id)
+	return seq ? seq.matches(input, key) : false
+}

+ 33 - 0
apps/cli/src/lib/utils/onboarding.ts

@@ -0,0 +1,33 @@
+import { createElement } from "react"
+
+import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js"
+import { login } from "@/commands/index.js"
+import { saveSettings } from "@/lib/storage/index.js"
+
+export async function runOnboarding(): Promise<OnboardingResult> {
+	const { render } = await import("ink")
+	const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
+
+	return new Promise<OnboardingResult>((resolve) => {
+		const onSelect = async (choice: OnboardingProviderChoice) => {
+			await saveSettings({ onboardingProviderChoice: choice })
+
+			app.unmount()
+
+			console.log("")
+
+			if (choice === OnboardingProviderChoice.Roo) {
+				const { success: authenticated } = await login()
+				await saveSettings({ onboardingProviderChoice: choice })
+				resolve({ choice: OnboardingProviderChoice.Roo, authenticated, skipped: false })
+			} else {
+				console.log("Using your own API key.")
+				console.log("Set your API key via --api-key or environment variable.")
+				console.log("")
+				resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
+			}
+		}
+
+		const app = render(createElement(OnboardingScreen, { onSelect }))
+	})
+}

+ 35 - 0
apps/cli/src/lib/utils/path.ts

@@ -0,0 +1,35 @@
+import * as path from "path"
+
+/**
+ * Normalize a path by removing trailing slashes and converting separators.
+ * This handles cross-platform path comparison issues.
+ */
+export function normalizePath(p: string): string {
+	// Remove trailing slashes
+	let normalized = p.replace(/[/\\]+$/, "")
+	// Convert to consistent separators using path.normalize
+	normalized = path.normalize(normalized)
+	return normalized
+}
+
+/**
+ * Compare two paths for equality, handling:
+ * - Trailing slashes
+ * - Path separator differences
+ * - Case sensitivity (case-insensitive on Windows/macOS)
+ */
+export function arePathsEqual(path1?: string, path2?: string): boolean {
+	if (!path1 || !path2) {
+		return false
+	}
+
+	const normalizedPath1 = normalizePath(path1)
+	const normalizedPath2 = normalizePath(path2)
+
+	// On Windows and macOS, file paths are case-insensitive
+	if (process.platform === "win32" || process.platform === "darwin") {
+		return normalizedPath1.toLowerCase() === normalizedPath2.toLowerCase()
+	}
+
+	return normalizedPath1 === normalizedPath2
+}

+ 61 - 0
apps/cli/src/lib/utils/provider.ts

@@ -0,0 +1,61 @@
+import { RooCodeSettings } from "@roo-code/types"
+
+import type { SupportedProvider } from "@/types/index.js"
+
+const envVarMap: Record<SupportedProvider, string> = {
+	anthropic: "ANTHROPIC_API_KEY",
+	"openai-native": "OPENAI_API_KEY",
+	gemini: "GOOGLE_API_KEY",
+	openrouter: "OPENROUTER_API_KEY",
+	"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
+	roo: "ROO_API_KEY",
+}
+
+export function getEnvVarName(provider: SupportedProvider): string {
+	return envVarMap[provider]
+}
+
+export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
+	const envVar = getEnvVarName(provider)
+	return process.env[envVar]
+}
+
+export function getProviderSettings(
+	provider: SupportedProvider,
+	apiKey: string | undefined,
+	model: string | undefined,
+): RooCodeSettings {
+	const config: RooCodeSettings = { apiProvider: provider }
+
+	switch (provider) {
+		case "anthropic":
+			if (apiKey) config.apiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "openai-native":
+			if (apiKey) config.openAiNativeApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "gemini":
+			if (apiKey) config.geminiApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "openrouter":
+			if (apiKey) config.openRouterApiKey = apiKey
+			if (model) config.openRouterModelId = model
+			break
+		case "vercel-ai-gateway":
+			if (apiKey) config.vercelAiGatewayApiKey = apiKey
+			if (model) config.vercelAiGatewayModelId = model
+			break
+		case "roo":
+			if (apiKey) config.rooApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		default:
+			if (apiKey) config.apiKey = apiKey
+			if (model) config.apiModelId = model
+	}
+
+	return config
+}

+ 6 - 0
apps/cli/src/lib/utils/version.ts

@@ -0,0 +1,6 @@
+import { createRequire } from "module"
+
+const require = createRequire(import.meta.url)
+const packageJson = require("../package.json")
+
+export const VERSION = packageJson.version

+ 26 - 0
apps/cli/src/types/constants.ts

@@ -0,0 +1,26 @@
+import { reasoningEffortsExtended } from "@roo-code/types"
+
+export const DEFAULT_FLAGS = {
+	mode: "code",
+	reasoningEffort: "medium" as const,
+	model: "anthropic/claude-opus-4.5",
+}
+
+export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
+
+/**
+ * Default timeout in seconds for auto-approving followup questions.
+ * Used in both the TUI (App.tsx) and the extension host (extension-host.ts).
+ */
+export const FOLLOWUP_TIMEOUT_SECONDS = 60
+
+export const ASCII_ROO = `  _,'   ___
+ <__\\__/   \\
+    \\_  /  _\\
+      \\,\\ / \\\\
+        //   \\\\
+      ,/'     \`\\_,`
+
+export const AUTH_BASE_URL = process.env.ROO_AUTH_BASE_URL ?? "https://app.roocode.com"
+
+export const SDK_BASE_URL = process.env.ROO_SDK_BASE_URL ?? "https://cloud-api.roocode.com"

+ 2 - 0
apps/cli/src/types/index.ts

@@ -0,0 +1,2 @@
+export * from "./types.js"
+export * from "./constants.js"

+ 49 - 0
apps/cli/src/types/types.ts

@@ -0,0 +1,49 @@
+import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types"
+
+export const supportedProviders = [
+	"anthropic",
+	"openai-native",
+	"gemini",
+	"openrouter",
+	"vercel-ai-gateway",
+	"roo",
+] as const satisfies ProviderName[]
+
+export type SupportedProvider = (typeof supportedProviders)[number]
+
+export function isSupportedProvider(provider: string): provider is SupportedProvider {
+	return supportedProviders.includes(provider as SupportedProvider)
+}
+
+export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" | "disabled"
+
+export type FlagOptions = {
+	prompt?: string
+	extension?: string
+	debug: boolean
+	yes: boolean
+	apiKey?: string
+	provider: SupportedProvider
+	model?: string
+	mode?: string
+	reasoningEffort?: ReasoningEffortFlagOptions
+	exitOnComplete: boolean
+	waitOnComplete: boolean
+	ephemeral: boolean
+	tui: boolean
+}
+
+export enum OnboardingProviderChoice {
+	Roo = "roo",
+	Byok = "byok",
+}
+
+export interface OnboardingResult {
+	choice: OnboardingProviderChoice
+	authenticated?: boolean
+	skipped: boolean
+}
+
+export interface CliSettings {
+	onboardingProviderChoice?: OnboardingProviderChoice
+}

+ 621 - 0
apps/cli/src/ui/App.tsx

@@ -0,0 +1,621 @@
+import { Box, Text, useApp, useInput } from "ink"
+import { Select } from "@inkjs/ui"
+import { useState, useEffect, useCallback, useRef, useMemo } from "react"
+
+import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
+
+import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js"
+import { arePathsEqual } from "@/lib/utils/path.js"
+import { getContextWindow } from "@/lib/utils/context-window.js"
+
+import * as theme from "./theme.js"
+import { useCLIStore } from "./store.js"
+import { useUIStateStore } from "./stores/uiStateStore.js"
+
+// Import extracted hooks.
+import {
+	TerminalSizeProvider,
+	useTerminalSize,
+	useToast,
+	useExtensionHost,
+	useMessageHandlers,
+	useTaskSubmit,
+	useGlobalInput,
+	useFollowupCountdown,
+	useFocusManagement,
+	usePickerHandlers,
+} from "./hooks/index.js"
+
+// Import extracted utilities.
+import { getView } from "./utils/index.js"
+
+// Import components.
+import Header from "./components/Header.js"
+import ChatHistoryItem from "./components/ChatHistoryItem.js"
+import LoadingText from "./components/LoadingText.js"
+import ToastDisplay from "./components/ToastDisplay.js"
+import TodoDisplay from "./components/TodoDisplay.js"
+import { HorizontalLine } from "./components/HorizontalLine.js"
+import {
+	type AutocompleteInputHandle,
+	type AutocompleteTrigger,
+	type FileResult,
+	type SlashCommandResult,
+	AutocompleteInput,
+	PickerSelect,
+	createFileTrigger,
+	createSlashCommandTrigger,
+	createModeTrigger,
+	createHelpTrigger,
+	createHistoryTrigger,
+	toFileResult,
+	toSlashCommandResult,
+	toModeResult,
+	toHistoryResult,
+} from "./components/autocomplete/index.js"
+import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
+import ScrollIndicator from "./components/ScrollIndicator.js"
+
+const PICKER_HEIGHT = 10
+
+export interface TUIAppProps extends ExtensionHostOptions {
+	initialPrompt: string
+	debug: boolean
+	exitOnComplete: boolean
+	version: string
+	createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
+}
+
+/**
+ * Inner App component that uses the terminal size context
+ */
+function AppInner({
+	initialPrompt,
+	workspacePath,
+	extensionPath,
+	user,
+	provider,
+	apiKey,
+	model,
+	mode,
+	nonInteractive = false,
+	debug,
+	exitOnComplete,
+	reasoningEffort,
+	ephemeral,
+	version,
+	createExtensionHost,
+}: TUIAppProps) {
+	const { exit } = useApp()
+
+	const {
+		messages,
+		pendingAsk,
+		isLoading,
+		isComplete,
+		hasStartedTask: _hasStartedTask,
+		error,
+		fileSearchResults,
+		allSlashCommands,
+		availableModes,
+		taskHistory,
+		currentMode,
+		tokenUsage,
+		routerModels,
+		apiConfiguration,
+		currentTodos,
+	} = useCLIStore()
+
+	// Access UI state from the UI store
+	const {
+		showExitHint,
+		countdownSeconds,
+		showCustomInput,
+		isTransitioningToCustomInput,
+		showTodoViewer,
+		pickerState,
+		setIsTransitioningToCustomInput,
+	} = useUIStateStore()
+
+	// Compute context window from router models and API configuration
+	const contextWindow = useMemo(() => {
+		return getContextWindow(routerModels, apiConfiguration)
+	}, [routerModels, apiConfiguration])
+
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
+
+	// Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change
+	const fileSearchResultsRef = useRef(fileSearchResults)
+	const allSlashCommandsRef = useRef(allSlashCommands)
+	const availableModesRef = useRef(availableModes)
+	const taskHistoryRef = useRef(taskHistory)
+
+	// Keep refs in sync with current state
+	useEffect(() => {
+		fileSearchResultsRef.current = fileSearchResults
+	}, [fileSearchResults])
+	useEffect(() => {
+		allSlashCommandsRef.current = allSlashCommands
+	}, [allSlashCommands])
+	useEffect(() => {
+		availableModesRef.current = availableModes
+	}, [availableModes])
+	useEffect(() => {
+		taskHistoryRef.current = taskHistory
+	}, [taskHistory])
+
+	// Scroll area state
+	const { rows } = useTerminalSize()
+	const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
+	const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
+
+	// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick).
+	const rafIdRef = useRef<NodeJS.Immediate | null>(null)
+	const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null)
+
+	// Toast notifications for ephemeral messages (e.g., mode changes).
+	const { currentToast, showInfo } = useToast()
+
+	const {
+		handleExtensionMessage,
+		seenMessageIds,
+		pendingCommandRef: _pendingCommandRef,
+		firstTextMessageSkipped,
+	} = useMessageHandlers({
+		nonInteractive,
+	})
+
+	const { sendToExtension, runTask, cleanup } = useExtensionHost({
+		initialPrompt,
+		mode,
+		reasoningEffort,
+		user,
+		provider,
+		apiKey,
+		model,
+		workspacePath,
+		extensionPath,
+		debug,
+		nonInteractive,
+		ephemeral,
+		exitOnComplete,
+		onExtensionMessage: handleExtensionMessage,
+		createExtensionHost,
+	})
+
+	// Initialize task submit hook
+	const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
+		sendToExtension,
+		runTask,
+		seenMessageIds,
+		firstTextMessageSkipped,
+	})
+
+	// Initialize focus management hook
+	const { canToggleFocus, isScrollAreaActive, isInputAreaActive, toggleFocus } = useFocusManagement({
+		showApprovalPrompt: Boolean(pendingAsk && pendingAsk.type !== "followup"),
+		pendingAsk,
+	})
+
+	// Initialize countdown hook for followup auto-accept
+	const { cancelCountdown } = useFollowupCountdown({
+		pendingAsk,
+		onAutoSubmit: handleSubmit,
+	})
+
+	// Initialize picker handlers hook
+	const { handlePickerStateChange, handlePickerSelect, handlePickerClose, handlePickerIndexChange } =
+		usePickerHandlers({
+			autocompleteRef,
+			followupAutocompleteRef,
+			sendToExtension,
+			showInfo,
+			seenMessageIds,
+			firstTextMessageSkipped,
+		})
+
+	// Initialize global input hook
+	useGlobalInput({
+		canToggleFocus,
+		isScrollAreaActive,
+		pickerIsOpen: pickerState.isOpen,
+		availableModes,
+		currentMode,
+		mode,
+		sendToExtension,
+		showInfo,
+		exit,
+		cleanup,
+		toggleFocus,
+		closePicker: handlePickerClose,
+	})
+
+	// Determine current view
+	const view = getView(messages, pendingAsk, isLoading)
+
+	// Determine if we should show the approval prompt (Y/N) instead of text input
+	const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup"
+
+	// Display all messages including partial (streaming) ones
+	const displayMessages = useMemo(() => {
+		return messages
+	}, [messages])
+
+	// Scroll to bottom when new messages arrive (if auto-scroll is enabled)
+	const prevMessageCount = useRef(messages.length)
+	useEffect(() => {
+		if (messages.length > prevMessageCount.current && scrollState.isAtBottom) {
+			scrollToBottom()
+		}
+		prevMessageCount.current = messages.length
+	}, [messages.length, scrollState.isAtBottom, scrollToBottom])
+
+	// Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates)
+	const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
+		// Store the latest scroll values in ref
+		pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom }
+
+		// Only schedule one update per event loop tick
+		if (rafIdRef.current === null) {
+			rafIdRef.current = setImmediate(() => {
+				rafIdRef.current = null
+				const pending = pendingScrollRef.current
+				if (pending) {
+					setScrollState(pending)
+					pendingScrollRef.current = null
+				}
+			})
+		}
+	}, [])
+
+	// Cleanup RAF-style timer on unmount
+	useEffect(() => {
+		return () => {
+			if (rafIdRef.current !== null) {
+				clearImmediate(rafIdRef.current)
+			}
+		}
+	}, [])
+
+	// File search handler for the file trigger
+	const handleFileSearch = useCallback(
+		(query: string) => {
+			if (!sendToExtension) {
+				return
+			}
+			sendToExtension({ type: "searchFiles", query })
+		},
+		[sendToExtension],
+	)
+
+	// Create autocomplete triggers
+	// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult)
+	// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
+	// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
+	// The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data.
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
+		const fileTrigger = createFileTrigger({
+			onSearch: handleFileSearch,
+			getResults: () => {
+				const results = fileSearchResultsRef.current
+				return results.map(toFileResult)
+			},
+		})
+
+		const slashCommandTrigger = createSlashCommandTrigger({
+			getCommands: () => {
+				// Merge CLI global commands with extension commands
+				const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult)
+				const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult)
+				// Global commands appear first, then extension commands
+				return [...globalCommands, ...extensionCommands]
+			},
+		})
+
+		const modeTrigger = createModeTrigger({
+			getModes: () => availableModesRef.current.map(toModeResult),
+		})
+
+		const helpTrigger = createHelpTrigger()
+
+		// History trigger - type # to search and resume previous tasks
+		const historyTrigger = createHistoryTrigger({
+			getHistory: () => {
+				// Filter to only show tasks for the current workspace
+				// Use arePathsEqual for proper cross-platform path comparison
+				// (handles trailing slashes, separators, and case sensitivity)
+				const history = taskHistoryRef.current
+				const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath))
+				return filtered.map(toHistoryResult)
+			},
+		})
+
+		return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger]
+	}, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs
+
+	// Refresh search results when fileSearchResults changes while file picker is open
+	// This handles the async timing where API results arrive after initial search
+	// IMPORTANT: Only run when fileSearchResults array identity changes (new API response)
+	// We use a ref to track this and avoid depending on pickerState in the effect
+	const prevFileSearchResultsRef = useRef(fileSearchResults)
+	const pickerStateRef = useRef(pickerState)
+	pickerStateRef.current = pickerState
+
+	useEffect(() => {
+		// Only run if fileSearchResults actually changed (different array reference)
+		if (fileSearchResults === prevFileSearchResultsRef.current) {
+			return
+		}
+
+		const currentPickerState = pickerStateRef.current
+		const willRefresh =
+			currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0
+
+		prevFileSearchResultsRef.current = fileSearchResults
+
+		// Only refresh when file picker is open and we have new results
+		if (willRefresh) {
+			autocompleteRef.current?.refreshSearch()
+			followupAutocompleteRef.current?.refreshSearch()
+		}
+	}, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref
+
+	// Handle Y/N input for approval prompts
+	useInput((input) => {
+		if (pendingAsk && pendingAsk.type !== "followup") {
+			const lower = input.toLowerCase()
+
+			if (lower === "y") {
+				handleApprove()
+			} else if (lower === "n") {
+				handleReject()
+			}
+		}
+	})
+
+	// Cancel countdown timer when user navigates in the followup suggestion menu
+	// This provides better UX - any user interaction cancels the auto-accept timer
+	const showFollowupSuggestions =
+		pendingAsk?.type === "followup" &&
+		pendingAsk.suggestions &&
+		pendingAsk.suggestions.length > 0 &&
+		!showCustomInput
+
+	useInput((_input, key) => {
+		// Only handle when followup suggestions are shown and countdown is active
+		if (showFollowupSuggestions && countdownSeconds !== null) {
+			// Cancel countdown on any arrow key navigation
+			if (key.upArrow || key.downArrow) {
+				cancelCountdown()
+			}
+		}
+	})
+
+	// Error display
+	if (error) {
+		return (
+			<Box flexDirection="column" padding={1}>
+				<Text color="red" bold>
+					Error: {error}
+				</Text>
+				<Text color="gray" dimColor>
+					Press Ctrl+C to exit
+				</Text>
+			</Box>
+		)
+	}
+
+	// Status bar content
+	// Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint
+	// Don't show spinner when waiting for user input (pendingAsk is set)
+	const statusBarMessage = currentToast ? (
+		<ToastDisplay toast={currentToast} />
+	) : showExitHint ? (
+		<Text color="yellow">Press Ctrl+C again to exit</Text>
+	) : isLoading && !pendingAsk ? (
+		<Box>
+			<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
+			<Text color={theme.dimText}> • </Text>
+			<Text color={theme.dimText}>Esc to cancel</Text>
+			{isScrollAreaActive && (
+				<>
+					<Text color={theme.dimText}> • </Text>
+					<ScrollIndicator
+						scrollTop={scrollState.scrollTop}
+						maxScroll={scrollState.maxScroll}
+						isScrollFocused={true}
+					/>
+				</>
+			)}
+		</Box>
+	) : isScrollAreaActive ? (
+		<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
+	) : isInputAreaActive ? (
+		<Text color={theme.dimText}>? for shortcuts</Text>
+	) : null
+
+	const getPickerRenderItem = () => {
+		if (pickerState.activeTrigger) {
+			return pickerState.activeTrigger.renderItem
+		}
+
+		return (item: FileResult | SlashCommandResult, isSelected: boolean) => (
+			<Box paddingLeft={2}>
+				<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
+			</Box>
+		)
+	}
+
+	return (
+		<Box flexDirection="column" height={rows - 1}>
+			{/* Header - fixed size */}
+			<Box flexShrink={0}>
+				<Header
+					cwd={workspacePath}
+					user={user}
+					provider={provider}
+					model={model}
+					mode={currentMode || mode}
+					reasoningEffort={reasoningEffort}
+					version={version}
+					tokenUsage={tokenUsage}
+					contextWindow={contextWindow}
+				/>
+			</Box>
+
+			{/* Scrollable message history area - fills remaining space via flexGrow */}
+			<ScrollArea
+				isActive={isScrollAreaActive}
+				onScroll={handleScroll}
+				scrollToBottomTrigger={scrollToBottomTrigger}>
+				{displayMessages.map((message) => (
+					<ChatHistoryItem key={message.id} message={message} />
+				))}
+			</ScrollArea>
+
+			{/* Input area - with borders like Claude Code - fixed size */}
+			<Box flexDirection="column" flexShrink={0}>
+				{pendingAsk?.type === "followup" ? (
+					<Box flexDirection="column">
+						<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
+						{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
+							<Box flexDirection="column" marginTop={1}>
+								<HorizontalLine active={true} />
+								<Select
+									options={[
+										...pendingAsk.suggestions.map((s) => ({
+											label: s.answer,
+											value: s.answer,
+										})),
+										{ label: "Type something...", value: "__CUSTOM__" },
+									]}
+									onChange={(value) => {
+										if (!value || typeof value !== "string") return
+										if (showCustomInput || isTransitioningToCustomInput) return
+
+										if (value === "__CUSTOM__") {
+											// Clear countdown timer and switch to custom input
+											cancelCountdown()
+											setIsTransitioningToCustomInput(true)
+											useUIStateStore.getState().setShowCustomInput(true)
+										} else if (value.trim()) {
+											handleSubmit(value)
+										}
+									}}
+								/>
+								<HorizontalLine active={true} />
+								<Text color={theme.dimText}>
+									↑↓ navigate • Enter select
+									{countdownSeconds !== null && (
+										<Text color="yellow"> • Auto-select in {countdownSeconds}s</Text>
+									)}
+								</Text>
+							</Box>
+						) : (
+							<Box flexDirection="column" marginTop={1}>
+								<HorizontalLine active={isInputAreaActive} />
+								<AutocompleteInput
+									ref={followupAutocompleteRef}
+									placeholder="Type your response..."
+									onSubmit={(text: string) => {
+										if (text && text.trim()) {
+											handleSubmit(text)
+											useUIStateStore.getState().setShowCustomInput(false)
+											setIsTransitioningToCustomInput(false)
+										}
+									}}
+									isActive={true}
+									triggers={autocompleteTriggers}
+									onPickerStateChange={handlePickerStateChange}
+									prompt="> "
+								/>
+								<HorizontalLine active={isInputAreaActive} />
+								{pickerState.isOpen ? (
+									<Box flexDirection="column" height={PICKER_HEIGHT}>
+										<PickerSelect
+											results={pickerState.results}
+											selectedIndex={pickerState.selectedIndex}
+											maxVisible={PICKER_HEIGHT - 1}
+											onSelect={handlePickerSelect}
+											onEscape={handlePickerClose}
+											onIndexChange={handlePickerIndexChange}
+											renderItem={getPickerRenderItem()}
+											emptyMessage={pickerState.activeTrigger?.emptyMessage}
+											isActive={isInputAreaActive && pickerState.isOpen}
+											isLoading={pickerState.isLoading}
+										/>
+									</Box>
+								) : (
+									<Box height={1}>{statusBarMessage}</Box>
+								)}
+							</Box>
+						)}
+					</Box>
+				) : showApprovalPrompt ? (
+					<Box flexDirection="column">
+						<Text color={theme.rooHeader}>{pendingAsk?.content}</Text>
+						<Text color={theme.dimText}>
+							Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
+							<Text color={theme.errorColor}>N</Text> to reject
+						</Text>
+						<Box height={1}>{statusBarMessage}</Box>
+					</Box>
+				) : (
+					<Box flexDirection="column">
+						<HorizontalLine active={isInputAreaActive} />
+						<AutocompleteInput
+							ref={autocompleteRef}
+							placeholder={isComplete ? "Type to continue..." : ""}
+							onSubmit={handleSubmit}
+							isActive={isInputAreaActive}
+							triggers={autocompleteTriggers}
+							onPickerStateChange={handlePickerStateChange}
+							prompt="› "
+						/>
+						<HorizontalLine active={isInputAreaActive} />
+						{showTodoViewer ? (
+							<Box flexDirection="column" height={PICKER_HEIGHT}>
+								<TodoDisplay todos={currentTodos} showProgress={true} title="TODO List" />
+								<Box height={1}>
+									<Text color={theme.dimText}>Ctrl+T to close</Text>
+								</Box>
+							</Box>
+						) : pickerState.isOpen ? (
+							<Box flexDirection="column" height={PICKER_HEIGHT}>
+								<PickerSelect
+									results={pickerState.results}
+									selectedIndex={pickerState.selectedIndex}
+									maxVisible={PICKER_HEIGHT - 1}
+									onSelect={handlePickerSelect}
+									onEscape={handlePickerClose}
+									onIndexChange={handlePickerIndexChange}
+									renderItem={getPickerRenderItem()}
+									emptyMessage={pickerState.activeTrigger?.emptyMessage}
+									isActive={isInputAreaActive && pickerState.isOpen}
+									isLoading={pickerState.isLoading}
+								/>
+							</Box>
+						) : (
+							<Box height={1}>{statusBarMessage}</Box>
+						)}
+					</Box>
+				)}
+			</Box>
+		</Box>
+	)
+}
+
+/**
+ * Main TUI Application Component - wraps with TerminalSizeProvider
+ */
+export function App(props: TUIAppProps) {
+	return (
+		<TerminalSizeProvider>
+			<AppInner {...props} />
+		</TerminalSizeProvider>
+	)
+}

+ 279 - 0
apps/cli/src/ui/__tests__/store.test.ts

@@ -0,0 +1,279 @@
+import { RooCodeSettings } from "@roo-code/types"
+
+import { useCLIStore } from "../store.js"
+
+describe("useCLIStore", () => {
+	beforeEach(() => {
+		// Reset store to initial state before each test
+		useCLIStore.getState().reset()
+	})
+
+	describe("initialState", () => {
+		it("should have isResumingTask set to false initially", () => {
+			const state = useCLIStore.getState()
+			expect(state.isResumingTask).toBe(false)
+		})
+
+		it("should have empty messages array initially", () => {
+			const state = useCLIStore.getState()
+			expect(state.messages).toEqual([])
+		})
+
+		it("should have empty taskHistory initially", () => {
+			const state = useCLIStore.getState()
+			expect(state.taskHistory).toEqual([])
+		})
+	})
+
+	describe("setIsResumingTask", () => {
+		it("should set isResumingTask to true", () => {
+			useCLIStore.getState().setIsResumingTask(true)
+			expect(useCLIStore.getState().isResumingTask).toBe(true)
+		})
+
+		it("should set isResumingTask to false", () => {
+			useCLIStore.getState().setIsResumingTask(true)
+			useCLIStore.getState().setIsResumingTask(false)
+			expect(useCLIStore.getState().isResumingTask).toBe(false)
+		})
+	})
+
+	describe("reset", () => {
+		it("should reset all state to initial values", () => {
+			// Set some state first
+			const store = useCLIStore.getState()
+			store.addMessage({ id: "1", role: "user", content: "test" })
+			store.setTaskHistory([{ id: "task1", task: "test", workspace: "/test", ts: Date.now() }])
+			store.setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
+			store.setAllSlashCommands([{ key: "test", name: "test", source: "global" as const }])
+			store.setIsResumingTask(true)
+			store.setLoading(true)
+			store.setHasStartedTask(true)
+
+			// Reset
+			useCLIStore.getState().reset()
+
+			// Verify all state is reset
+			const resetState = useCLIStore.getState()
+			expect(resetState.messages).toEqual([])
+			expect(resetState.taskHistory).toEqual([])
+			expect(resetState.availableModes).toEqual([])
+			expect(resetState.allSlashCommands).toEqual([])
+			expect(resetState.isResumingTask).toBe(false)
+			expect(resetState.isLoading).toBe(false)
+			expect(resetState.hasStartedTask).toBe(false)
+		})
+	})
+
+	describe("resetForTaskSwitch", () => {
+		it("should clear task-specific state", () => {
+			// Set up task-specific state
+			const store = useCLIStore.getState()
+			store.addMessage({ id: "1", role: "user", content: "test" })
+			store.setLoading(true)
+			store.setComplete(true)
+			store.setHasStartedTask(true)
+			store.setError("some error")
+			store.setIsResumingTask(true)
+			store.setTokenUsage({
+				totalTokensIn: 100,
+				totalTokensOut: 50,
+				totalCost: 0.01,
+				contextTokens: 0,
+				totalCacheReads: 0,
+				totalCacheWrites: 0,
+			})
+			store.setTodos([{ id: "1", content: "test todo", status: "pending" }])
+
+			// Reset for task switch
+			useCLIStore.getState().resetForTaskSwitch()
+
+			// Verify task-specific state is cleared
+			const resetState = useCLIStore.getState()
+			expect(resetState.messages).toEqual([])
+			expect(resetState.pendingAsk).toBeNull()
+			expect(resetState.isLoading).toBe(false)
+			expect(resetState.isComplete).toBe(false)
+			expect(resetState.hasStartedTask).toBe(false)
+			expect(resetState.error).toBeNull()
+			expect(resetState.isResumingTask).toBe(false)
+			expect(resetState.tokenUsage).toBeNull()
+			expect(resetState.currentTodos).toEqual([])
+			expect(resetState.previousTodos).toEqual([])
+		})
+
+		it("should PRESERVE taskHistory", () => {
+			const taskHistory = [
+				{ id: "task1", task: "test task 1", workspace: "/test", ts: Date.now() },
+				{ id: "task2", task: "test task 2", workspace: "/test", ts: Date.now() },
+			]
+			useCLIStore.getState().setTaskHistory(taskHistory)
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().taskHistory).toEqual(taskHistory)
+		})
+
+		it("should PRESERVE availableModes", () => {
+			const modes = [
+				{ key: "code", slug: "code", name: "Code", description: "Code mode" },
+				{ key: "architect", slug: "architect", name: "Architect", description: "Architect mode" },
+			]
+			useCLIStore.getState().setAvailableModes(modes)
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().availableModes).toEqual(modes)
+		})
+
+		it("should PRESERVE allSlashCommands", () => {
+			const commands = [
+				{ key: "new", name: "new", description: "New task", source: "global" as const },
+				{ key: "help", name: "help", description: "Get help", source: "built-in" as const },
+			]
+			useCLIStore.getState().setAllSlashCommands(commands)
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().allSlashCommands).toEqual(commands)
+		})
+
+		it("should PRESERVE fileSearchResults", () => {
+			const results = [
+				{ key: "file1", path: "file1.ts", type: "file" as const },
+				{ key: "file2", path: "file2.ts", type: "file" as const },
+			]
+			useCLIStore.getState().setFileSearchResults(results)
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().fileSearchResults).toEqual(results)
+		})
+
+		it("should PRESERVE currentMode", () => {
+			useCLIStore.getState().setCurrentMode("architect")
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().currentMode).toBe("architect")
+		})
+
+		it("should PRESERVE routerModels", () => {
+			const models = { openai: { "gpt-4": { contextWindow: 128000 } } }
+			useCLIStore.getState().setRouterModels(models)
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().routerModels).toEqual(models)
+		})
+
+		it("should PRESERVE apiConfiguration", () => {
+			const config: RooCodeSettings = { apiProvider: "openai", apiModelId: "gpt-4" }
+
+			useCLIStore
+				.getState()
+				.setApiConfiguration(config as ReturnType<typeof useCLIStore.getState>["apiConfiguration"])
+
+			useCLIStore.getState().resetForTaskSwitch()
+
+			expect(useCLIStore.getState().apiConfiguration).toEqual(config)
+		})
+	})
+
+	describe("task resumption flow", () => {
+		it("should support the full task resumption workflow", () => {
+			const store = useCLIStore.getState
+
+			// Step 1: Initial state with task history and modes from webviewDidLaunch.
+			store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }])
+			store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
+			store().setAllSlashCommands([{ key: "new", name: "new", source: "global" as const }])
+
+			// Step 2: User starts a new task.
+			store().setHasStartedTask(true)
+			store().addMessage({ id: "1", role: "user", content: "New task" })
+			store().addMessage({ id: "2", role: "assistant", content: "Working on it..." })
+			store().setLoading(true)
+
+			// Verify current state.
+			expect(store().messages.length).toBe(2)
+			expect(store().hasStartedTask).toBe(true)
+
+			// Step 3: User selects a task from history to resume.
+			// This triggers resetForTaskSwitch + setIsResumingTask(true).
+			store().resetForTaskSwitch()
+			store().setIsResumingTask(true)
+
+			// Verify task-specific state is cleared but global state preserved.
+			expect(store().messages).toEqual([])
+			expect(store().isLoading).toBe(false)
+			expect(store().hasStartedTask).toBe(false)
+			expect(store().isResumingTask).toBe(true) // Flag is set.
+			expect(store().taskHistory.length).toBe(1) // Preserved.
+			expect(store().availableModes.length).toBe(1) // Preserved.
+			expect(store().allSlashCommands.length).toBe(1) // Preserved.
+
+			// Step 4: Extension sends state message with clineMessages
+			// (simulated by adding messages).
+			store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" })
+			store().addMessage({ id: "old2", role: "assistant", content: "Previous response" })
+
+			// Step 5: After processing state, isResumingTask should be cleared.
+			store().setIsResumingTask(false)
+
+			// Final verification.
+			expect(store().isResumingTask).toBe(false)
+			expect(store().messages.length).toBe(2)
+			expect(store().taskHistory.length).toBe(1) // Still preserved.
+		})
+
+		it("should allow reading isResumingTask synchronously during message processing", () => {
+			const store = useCLIStore.getState
+
+			// Set the flag
+			store().setIsResumingTask(true)
+
+			// Simulate synchronous read during message processing
+			const isResuming = store().isResumingTask
+			expect(isResuming).toBe(true)
+
+			// The handler can use this to decide whether to skip messages
+			if (!isResuming) {
+				// Would skip first text message for new tasks
+			} else {
+				// Would NOT skip first text message for resumed tasks
+			}
+
+			// After processing, clear the flag
+			store().setIsResumingTask(false)
+			expect(store().isResumingTask).toBe(false)
+		})
+	})
+
+	describe("difference between reset and resetForTaskSwitch", () => {
+		it("should show that reset clears everything while resetForTaskSwitch preserves global state", () => {
+			const store = useCLIStore.getState
+
+			// Set up both task-specific and global state
+			store().addMessage({ id: "1", role: "user", content: "test" })
+			store().setTaskHistory([{ id: "t1", task: "task", workspace: "/", ts: Date.now() }])
+			store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
+
+			// Use resetForTaskSwitch
+			store().resetForTaskSwitch()
+
+			// Task-specific cleared, global preserved
+			expect(store().messages).toEqual([])
+			expect(store().taskHistory.length).toBe(1)
+			expect(store().availableModes.length).toBe(1)
+
+			// Now use reset()
+			store().reset()
+
+			// Everything cleared
+			expect(store().messages).toEqual([])
+			expect(store().taskHistory).toEqual([])
+			expect(store().availableModes).toEqual([])
+		})
+	})
+})

+ 252 - 0
apps/cli/src/ui/components/ChatHistoryItem.tsx

@@ -0,0 +1,252 @@
+import { memo } from "react"
+import { Box, Newline, Text } from "ink"
+
+import type { TUIMessage } from "../types.js"
+import * as theme from "../theme.js"
+
+import TodoDisplay from "./TodoDisplay.js"
+import { getToolRenderer } from "./tools/index.js"
+
+/**
+ * Tool categories for styling
+ */
+type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other"
+
+function getToolCategory(toolName: string): ToolCategory {
+	const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"]
+	const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"]
+	const searchTools = ["searchFiles", "search_files"]
+	const commandTools = ["executeCommand", "execute_command"]
+	const browserTools = ["browserAction", "browser_action"]
+	const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"]
+	const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"]
+
+	if (fileTools.includes(toolName)) return "file"
+	if (dirTools.includes(toolName)) return "directory"
+	if (searchTools.includes(toolName)) return "search"
+	if (commandTools.includes(toolName)) return "command"
+	if (browserTools.includes(toolName)) return "browser"
+	if (modeTools.includes(toolName)) return "mode"
+	if (completionTools.includes(toolName)) return "completion"
+	return "other"
+}
+
+/**
+ * Category colors for tool types
+ */
+const CATEGORY_COLORS: Record<ToolCategory, string> = {
+	file: theme.toolHeader,
+	directory: theme.toolHeader,
+	search: theme.warningColor,
+	command: theme.successColor,
+	browser: theme.focusColor,
+	mode: theme.userHeader,
+	completion: theme.successColor,
+	other: theme.toolHeader,
+}
+
+/**
+ * Sanitize content for terminal display by:
+ * - Replacing tab characters with spaces (tabs expand to variable widths in terminals)
+ * - Stripping carriage returns that could cause display issues
+ */
+function sanitizeContent(text: string): string {
+	return text.replace(/\t/g, "    ").replace(/\r/g, "")
+}
+
+/**
+ * Truncate content for display, showing line count
+ */
+function truncateContent(
+	content: string,
+	maxLines: number = 10,
+): { text: string; truncated: boolean; totalLines: number } {
+	const lines = content.split("\n")
+	const totalLines = lines.length
+
+	if (lines.length <= maxLines) {
+		return { text: content, truncated: false, totalLines }
+	}
+
+	const truncatedText = lines.slice(0, maxLines).join("\n")
+	return { text: truncatedText, truncated: true, totalLines }
+}
+
+/**
+ * Parse tool info from raw JSON content
+ */
+function parseToolInfo(content: string): Record<string, unknown> | null {
+	try {
+		return JSON.parse(content)
+	} catch {
+		return null
+	}
+}
+
+/**
+ * Render tool display component
+ */
+function ToolDisplay({ message }: { message: TUIMessage }) {
+	const toolName = message.toolName || "unknown"
+	const category = getToolCategory(toolName)
+	const categoryColor = CATEGORY_COLORS[category]
+
+	// Try to parse the raw content for additional tool info
+	const toolInfo = parseToolInfo(message.content || "")
+
+	// Extract key fields from tool info
+	const path = toolInfo?.path as string | undefined
+	const isOutsideWorkspace = toolInfo?.isOutsideWorkspace as boolean | undefined
+	const reason = toolInfo?.reason as string | undefined
+	const rawContent = toolInfo?.content as string | undefined
+
+	// Get the display output (formatted by App.tsx) - already sanitized
+	const toolDisplayOutput = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : undefined
+
+	// Sanitize raw content if present
+	const sanitizedRawContent = rawContent ? sanitizeContent(rawContent) : undefined
+
+	// Format the header
+	const headerText = message.toolDisplayName || toolName
+
+	return (
+		<Box flexDirection="column" paddingX={1}>
+			{/* Tool Header */}
+			<Text bold color={categoryColor}>
+				{headerText}
+			</Text>
+
+			{/* Path indicator for file/directory operations */}
+			{path && (
+				<Box marginLeft={2}>
+					<Text color={theme.dimText}>
+						{category === "file" ? "file: " : category === "directory" ? "dir: " : "path: "}
+					</Text>
+					<Text color={theme.text} bold>
+						{path}
+					</Text>
+					{isOutsideWorkspace && (
+						<Text color={theme.warningColor} dimColor>
+							{" (outside workspace)"}
+						</Text>
+					)}
+				</Box>
+			)}
+
+			{/* Reason/explanation if present */}
+			{reason && (
+				<Box marginLeft={2}>
+					<Text color={theme.dimText} italic>
+						{reason}
+					</Text>
+				</Box>
+			)}
+
+			{/* Content display */}
+			{(toolDisplayOutput || sanitizedRawContent) && (
+				<Box flexDirection="column" marginLeft={2} marginTop={0}>
+					{(() => {
+						const contentToDisplay = toolDisplayOutput || sanitizedRawContent || ""
+						const { text, truncated, totalLines } = truncateContent(contentToDisplay, 15)
+
+						return (
+							<>
+								<Text color={theme.toolText}>{text}</Text>
+								{truncated && (
+									<Text color={theme.dimText} dimColor>
+										{`... (${totalLines - 15} more lines)`}
+									</Text>
+								)}
+							</>
+						)
+					})()}
+				</Box>
+			)}
+
+			<Text>
+				<Newline />
+			</Text>
+		</Box>
+	)
+}
+
+interface ChatHistoryItemProps {
+	message: TUIMessage
+}
+
+function ChatHistoryItem({ message }: ChatHistoryItemProps) {
+	const content = sanitizeContent(message.content || "...")
+
+	switch (message.role) {
+		case "user":
+			return (
+				<Box flexDirection="column" paddingX={1}>
+					<Text bold color="magenta">
+						You said:
+					</Text>
+					<Text color={theme.userText}>
+						{content}
+						<Newline />
+					</Text>
+				</Box>
+			)
+		case "assistant":
+			return (
+				<Box flexDirection="column" paddingX={1}>
+					<Text bold color="yellow">
+						Roo said:
+					</Text>
+					<Text color={theme.rooText}>
+						{content}
+						<Newline />
+					</Text>
+				</Box>
+			)
+		case "thinking":
+			return (
+				<Box flexDirection="column" paddingX={1}>
+					<Text bold color={theme.thinkingHeader} dimColor>
+						Roo is thinking:
+					</Text>
+					<Text color={theme.thinkingText} dimColor>
+						{content}
+						<Newline />
+					</Text>
+				</Box>
+			)
+		case "tool": {
+			// Special rendering for update_todo_list tool - show full TODO list
+			if (
+				(message.toolName === "update_todo_list" || message.toolName === "updateTodoList") &&
+				message.todos &&
+				message.todos.length > 0
+			) {
+				return <TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
+			}
+
+			// Use the new structured tool renderers when toolData is available
+			if (message.toolData) {
+				const ToolRenderer = getToolRenderer(message.toolData.tool)
+				return <ToolRenderer toolData={message.toolData} rawContent={message.content} />
+			}
+
+			// Fallback to generic ToolDisplay for messages without toolData
+			return <ToolDisplay message={message} />
+		}
+		case "system":
+			// System messages are typically rendered as Header, not here.
+			// But if they appear, show them subtly.
+			return (
+				<Box flexDirection="column" paddingX={1}>
+					<Text color="gray" dimColor>
+						{content}
+						<Newline />
+					</Text>
+				</Box>
+			)
+		default:
+			return null
+	}
+}
+
+export default memo(ChatHistoryItem)

+ 75 - 0
apps/cli/src/ui/components/Header.tsx

@@ -0,0 +1,75 @@
+import { memo } from "react"
+import { Text, Box } from "ink"
+
+import type { TokenUsage } from "@roo-code/types"
+
+import { ASCII_ROO } from "@/types/constants.js"
+import { User } from "@/lib/sdk/types.js"
+
+import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
+import * as theme from "../theme.js"
+
+import MetricsDisplay from "./MetricsDisplay.js"
+
+interface HeaderProps {
+	cwd: string
+	user: User | null
+	provider: string
+	model: string
+	mode: string
+	reasoningEffort?: string
+	version: string
+	tokenUsage?: TokenUsage | null
+	contextWindow?: number
+}
+
+function Header({
+	cwd,
+	user,
+	provider,
+	model,
+	mode,
+	reasoningEffort,
+	version,
+	tokenUsage,
+	contextWindow,
+}: HeaderProps) {
+	const { columns } = useTerminalSize()
+
+	const homeDir = process.env.HOME || process.env.USERPROFILE || ""
+	const title = `Roo Code CLI v${version}`
+	const remainingDashes = Math.max(0, columns - `── ${title} `.length)
+
+	return (
+		<Box flexDirection="column" width={columns}>
+			<Text color={theme.borderColor}>
+				── <Text color={theme.titleColor}>{title}</Text> {"─".repeat(remainingDashes)}
+			</Text>
+			<Box width={columns}>
+				<Box flexDirection="row">
+					<Box marginY={1}>
+						<Text color="magenta">{ASCII_ROO}</Text>
+					</Box>
+					<Box flexDirection="column" marginLeft={1} marginTop={1}>
+						{user && <Text color={theme.dimText}>Welcome back, {user.name}</Text>}
+						<Text color={theme.dimText}>
+							cwd: {cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd}
+						</Text>
+						<Text color={theme.dimText}>
+							{provider}: {model} [{reasoningEffort}]
+						</Text>
+						<Text color={theme.dimText}>mode: {mode}</Text>
+					</Box>
+				</Box>
+			</Box>
+			{tokenUsage && contextWindow && contextWindow > 0 && (
+				<Box alignSelf="flex-end" marginTop={-1}>
+					<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
+				</Box>
+			)}
+			<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
+		</Box>
+	)
+}
+
+export default memo(Header)

+ 14 - 0
apps/cli/src/ui/components/HorizontalLine.tsx

@@ -0,0 +1,14 @@
+import { Text } from "ink"
+
+import * as theme from "../theme.js"
+import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
+
+interface HorizontalLineProps {
+	active?: boolean
+}
+
+export function HorizontalLine({ active = false }: HorizontalLineProps) {
+	const { columns } = useTerminalSize()
+	const color = active ? theme.borderColorActive : theme.borderColor
+	return <Text color={color}>{"─".repeat(columns)}</Text>
+}

+ 174 - 0
apps/cli/src/ui/components/Icon.tsx

@@ -0,0 +1,174 @@
+import { Box, Text } from "ink"
+import type { TextProps } from "ink"
+
+/**
+ * Icon names supported by the Icon component.
+ * Each icon has a Nerd Font glyph and an ASCII fallback.
+ */
+export type IconName =
+	| "folder"
+	| "file"
+	| "file-edit"
+	| "check"
+	| "cross"
+	| "arrow-right"
+	| "bullet"
+	| "spinner"
+	// Tool-related icons
+	| "search"
+	| "terminal"
+	| "browser"
+	| "switch"
+	| "question"
+	| "gear"
+	| "diff"
+	// TODO-related icons
+	| "checkbox"
+	| "checkbox-checked"
+	| "checkbox-progress"
+	| "todo-list"
+
+/**
+ * Icon definitions with Nerd Font glyph and ASCII fallback.
+ * Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char).
+ */
+const ICONS: Record<IconName, { nerd: string; fallback: string }> = {
+	folder: { nerd: "\uf413", fallback: "▼" },
+	file: { nerd: "\uf4a5", fallback: "●" },
+	"file-edit": { nerd: "\uf4d2", fallback: "✎" },
+	check: { nerd: "\uf42e", fallback: "✓" },
+	cross: { nerd: "\uf517", fallback: "✗" },
+	"arrow-right": { nerd: "\uf432", fallback: "→" },
+	bullet: { nerd: "\uf444", fallback: "•" },
+	spinner: { nerd: "\uf4e3", fallback: "*" },
+	// Tool-related icons
+	search: { nerd: "\uf422", fallback: "🔍" },
+	terminal: { nerd: "\uf489", fallback: "$" },
+	browser: { nerd: "\uf488", fallback: "🌐" },
+	switch: { nerd: "\uf443", fallback: "⇄" },
+	question: { nerd: "\uf420", fallback: "?" },
+	gear: { nerd: "\uf423", fallback: "⚙" },
+	diff: { nerd: "\uf4d2", fallback: "±" },
+	// TODO-related icons
+	checkbox: { nerd: "\uf4aa", fallback: "○" }, // Empty checkbox
+	"checkbox-checked": { nerd: "\uf4a4", fallback: "✓" }, // Checked checkbox
+	"checkbox-progress": { nerd: "\uf4aa", fallback: "→" }, // In progress (dot circle)
+	"todo-list": { nerd: "\uf45e", fallback: "☑" }, // List icon for TODO header
+}
+
+/**
+ * Check if a string contains surrogate pairs (characters outside BMP).
+ * Surrogate pairs have .length of 2 but render as 1 visual character.
+ */
+function containsSurrogatePair(str: string): boolean {
+	// Surrogate pairs are in the range U+D800 to U+DFFF
+	return /[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(str)
+}
+
+/**
+ * Detect if Nerd Font icons are likely supported.
+ *
+ * Users can override this with the ROOCODE_NERD_FONT environment variable:
+ * - ROOCODE_NERD_FONT=0 to force ASCII fallbacks (if icons don't render correctly)
+ * - ROOCODE_NERD_FONT=1 to force Nerd Font icons
+ *
+ * Defaults to true because:
+ * 1. Nerd Fonts are common in developer terminal setups
+ * 2. Modern terminals handle missing glyphs gracefully
+ * 3. Users can easily disable if icons don't render correctly
+ */
+function detectNerdFontSupport(): boolean {
+	// Allow explicit override via environment variable
+	const envOverride = process.env.ROOCODE_NERD_FONT
+	if (envOverride === "0" || envOverride === "false") return false
+	if (envOverride === "1" || envOverride === "true") return true
+
+	// Default to Nerd Font icons - they're common in developer setups
+	// and users can set ROOCODE_NERD_FONT=0 if needed
+	return true
+}
+
+// Cache the detection result
+let nerdFontSupported: boolean | null = null
+
+/**
+ * Get whether Nerd Font icons are supported (cached).
+ */
+export function isNerdFontSupported(): boolean {
+	if (nerdFontSupported === null) {
+		nerdFontSupported = detectNerdFontSupport()
+	}
+	return nerdFontSupported
+}
+
+/**
+ * Reset the Nerd Font detection cache (useful for testing).
+ */
+export function resetNerdFontCache(): void {
+	nerdFontSupported = null
+}
+
+export interface IconProps extends Omit<TextProps, "children"> {
+	/** The icon to display */
+	name: IconName
+	/** Override the automatic Nerd Font detection */
+	useNerdFont?: boolean
+	/** Custom width for the icon container (default: 2) */
+	width?: number
+}
+
+/**
+ * Icon component that renders Nerd Font icons with ASCII fallbacks.
+ *
+ * Renders icons in a fixed-width Box to handle surrogate pair width
+ * calculation issues in Ink. Surrogate pairs (like Nerd Font glyphs)
+ * have .length of 2 in JavaScript but render as 1 visual character.
+ *
+ * @example
+ * ```tsx
+ * <Icon name="folder" color="blue" />
+ * <Icon name="file" />
+ * <Icon name="check" color="green" useNerdFont={false} />
+ * ```
+ */
+export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: IconProps) {
+	const iconDef = ICONS[name]
+	if (!iconDef) {
+		return null
+	}
+
+	const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
+	const icon = shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
+
+	// Use fixed-width Box to isolate surrogate pair width calculation
+	// from surrounding text. This prevents the off-by-one truncation bug.
+	const needsWidthFix = containsSurrogatePair(icon)
+
+	if (needsWidthFix) {
+		return (
+			<Box width={width}>
+				<Text color={color} {...textProps}>
+					{icon}
+				</Text>
+			</Box>
+		)
+	}
+
+	// For BMP characters (no surrogate pairs), render directly
+	return (
+		<Text color={color} {...textProps}>
+			{icon}
+		</Text>
+	)
+}
+
+/**
+ * Get the raw icon character (useful for string concatenation).
+ */
+export function getIconChar(name: IconName, useNerdFont?: boolean): string {
+	const iconDef = ICONS[name]
+	if (!iconDef) return ""
+
+	const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
+	return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
+}

+ 41 - 0
apps/cli/src/ui/components/LoadingText.tsx

@@ -0,0 +1,41 @@
+import { Spinner } from "@inkjs/ui"
+import { memo, useMemo } from "react"
+
+const THINKING_PHRASES = [
+	"Thinking",
+	"Pondering",
+	"Contemplating",
+	"Reticulating",
+	"Marinating",
+	"Actualizing",
+	"Crunching",
+	"Untangling",
+	"Summoning",
+	"Conjuring",
+	"Materializing",
+	"Synthesizing",
+	"Assembling",
+	"Percolating",
+	"Brewing",
+	"Manifesting",
+	"Cogitating",
+]
+
+interface LoadingTextProps {
+	children?: React.ReactNode
+}
+
+function LoadingText({ children }: LoadingTextProps) {
+	const randomPhrase = useMemo(() => {
+		const randomIndex = Math.floor(Math.random() * THINKING_PHRASES.length)
+		return THINKING_PHRASES[randomIndex]
+	}, [])
+
+	const childrenStr = children ? String(children) : ""
+	const useRandomPhrase = !children || childrenStr === "Thinking"
+	const label = useRandomPhrase ? `${randomPhrase}...` : `${childrenStr}...`
+
+	return <Spinner label={label} />
+}
+
+export default memo(LoadingText)

+ 68 - 0
apps/cli/src/ui/components/MetricsDisplay.tsx

@@ -0,0 +1,68 @@
+import { memo } from "react"
+import { Text, Box } from "ink"
+
+import type { TokenUsage } from "@roo-code/types"
+
+import * as theme from "../theme.js"
+import ProgressBar from "./ProgressBar.js"
+
+interface MetricsDisplayProps {
+	tokenUsage: TokenUsage
+	contextWindow: number
+}
+
+/**
+ * Formats a large number with K (thousands) or M (millions) suffix.
+ *
+ * Examples:
+ * - 1234 -> "1.2K"
+ * - 1234567 -> "1.2M"
+ * - 500 -> "500"
+ */
+function formatNumber(num: number): string {
+	if (num >= 1_000_000) {
+		return `${(num / 1_000_000).toFixed(1)}M`
+	}
+	if (num >= 1_000) {
+		return `${(num / 1_000).toFixed(1)}K`
+	}
+	return num.toString()
+}
+
+/**
+ * Formats cost as currency with $ prefix.
+ *
+ * Examples:
+ * - 0.12345 -> "$0.12"
+ * - 1.5 -> "$1.50"
+ */
+function formatCost(cost: number): string {
+	return `$${cost.toFixed(2)}`
+}
+
+/**
+ * Displays task metrics in a compact format:
+ * $0.12 │ ↓45.2K │ ↑8.7K │ [████████░░░░] 62%
+ */
+function MetricsDisplay({ tokenUsage, contextWindow }: MetricsDisplayProps) {
+	const { totalCost, totalTokensIn, totalTokensOut, contextTokens } = tokenUsage
+
+	return (
+		<Box>
+			<Text color={theme.text}>{formatCost(totalCost)}</Text>
+			<Text color={theme.dimText}> • </Text>
+			<Text color={theme.dimText}>
+				↓ <Text color={theme.text}>{formatNumber(totalTokensIn)}</Text>
+			</Text>
+			<Text color={theme.dimText}> • </Text>
+			<Text color={theme.dimText}>
+				↑ <Text color={theme.text}>{formatNumber(totalTokensOut)}</Text>
+			</Text>
+			<Text color={theme.dimText}> • </Text>
+			<ProgressBar value={contextTokens} max={contextWindow} width={12} />
+		</Box>
+	)
+}
+
+export default memo(MetricsDisplay)
+export { formatNumber, formatCost }

+ 493 - 0
apps/cli/src/ui/components/MultilineTextInput.tsx

@@ -0,0 +1,493 @@
+/**
+ * MultilineTextInput Component
+ *
+ * A multi-line text input for Ink CLI applications.
+ * Based on ink-multiline-input but simplified for our needs.
+ *
+ * Key behaviors:
+ * - Option+Enter (macOS) / Alt+Enter: Add new line (works reliably)
+ * - Shift+Enter: Add new line (requires terminal support for kitty keyboard protocol)
+ * - Enter: Submit
+ * - Backspace at start of line: Merge with previous line
+ * - Escape: Clear all lines
+ * - Arrow keys: Navigate within and between lines
+ */
+
+import { useState, useEffect, useMemo, useCallback, useRef } from "react"
+import { Box, Text, useInput, type Key } from "ink"
+
+import { isGlobalInputSequence } from "@/lib/utils/input.js"
+
+export interface MultilineTextInputProps {
+	/**
+	 * Current value (can contain newlines)
+	 */
+	value: string
+	/**
+	 * Called when the value changes
+	 */
+	onChange: (value: string) => void
+	/**
+	 * Called when user submits (Enter)
+	 */
+	onSubmit?: (value: string) => void
+	/**
+	 * Called when user presses Escape
+	 */
+	onEscape?: () => void
+	/**
+	 * Called when up arrow is pressed while cursor is on the first line
+	 * Use this to trigger history navigation
+	 */
+	onUpAtFirstLine?: () => void
+	/**
+	 * Called when down arrow is pressed while cursor is on the last line
+	 * Use this to trigger history navigation
+	 */
+	onDownAtLastLine?: () => void
+	/**
+	 * Placeholder text when empty
+	 */
+	placeholder?: string
+	/**
+	 * Whether the input is active/focused
+	 */
+	isActive?: boolean
+	/**
+	 * Whether to show the cursor
+	 */
+	showCursor?: boolean
+	/**
+	 * Prompt character for the first line
+	 */
+	prompt?: string
+	/**
+	 * Terminal width in columns - used for proper line wrapping
+	 * If not provided, lines won't be wrapped
+	 */
+	columns?: number
+}
+
+/**
+ * Normalize line endings to LF (\n)
+ */
+function normalizeLineEndings(text: string): string {
+	if (text == null) return ""
+	return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+}
+
+/**
+ * Calculate line and column position from cursor index
+ */
+function getCursorPosition(value: string, cursorIndex: number): { line: number; col: number } {
+	const lines = value.split("\n")
+	let pos = 0
+	for (let i = 0; i < lines.length; i++) {
+		const line = lines[i]!
+		const lineEnd = pos + line.length
+		if (cursorIndex <= lineEnd) {
+			return { line: i, col: cursorIndex - pos }
+		}
+		pos = lineEnd + 1 // +1 for newline
+	}
+	// Cursor at very end
+	return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
+}
+
+/**
+ * Calculate cursor index from line and column position
+ */
+function getIndexFromPosition(value: string, line: number, col: number): number {
+	const lines = value.split("\n")
+	let index = 0
+	for (let i = 0; i < line && i < lines.length; i++) {
+		index += lines[i]!.length + 1 // +1 for newline
+	}
+	const targetLine = lines[line] || ""
+	index += Math.min(col, targetLine.length)
+	return index
+}
+
+/**
+ * Represents a visual row after wrapping a logical line
+ */
+interface VisualRow {
+	text: string
+	logicalLineIndex: number
+	isFirstRowOfLine: boolean
+	startCol: number // column offset in the logical line
+}
+
+/**
+ * Wrap a logical line into visual rows based on available width.
+ * Uses word-boundary wrapping: prefers to break at spaces rather than
+ * in the middle of words.
+ */
+function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
+	if (availableWidth <= 0 || lineText.length < availableWidth) {
+		return [
+			{
+				text: lineText,
+				logicalLineIndex,
+				isFirstRowOfLine: true,
+				startCol: 0,
+			},
+		]
+	}
+
+	const rows: VisualRow[] = []
+	let remaining = lineText
+	let startCol = 0
+	let isFirst = true
+
+	while (remaining.length > 0) {
+		if (remaining.length < availableWidth) {
+			// Remaining text fits in one row
+			rows.push({
+				text: remaining,
+				logicalLineIndex,
+				isFirstRowOfLine: isFirst,
+				startCol,
+			})
+			break
+		}
+
+		// Find a good break point - prefer breaking at a space
+		let breakPoint = availableWidth
+
+		// Look backwards from availableWidth for a space
+		const searchStart = Math.min(availableWidth, remaining.length)
+		let spaceIndex = -1
+		for (let i = searchStart - 1; i >= 0; i--) {
+			if (remaining[i] === " ") {
+				spaceIndex = i
+				break
+			}
+		}
+
+		if (spaceIndex > 0) {
+			// Found a space - break after it (include the space in this row)
+			breakPoint = spaceIndex + 1
+		}
+		// else: no space found, break at availableWidth (mid-word break as fallback)
+
+		const chunk = remaining.slice(0, breakPoint)
+		rows.push({
+			text: chunk,
+			logicalLineIndex,
+			isFirstRowOfLine: isFirst,
+			startCol,
+		})
+
+		remaining = remaining.slice(breakPoint)
+		startCol += breakPoint
+		isFirst = false
+	}
+
+	return rows
+}
+
+export function MultilineTextInput({
+	value,
+	onChange,
+	onSubmit,
+	onEscape,
+	onUpAtFirstLine,
+	onDownAtLastLine,
+	placeholder = "",
+	isActive = true,
+	showCursor = true,
+	prompt = "> ",
+	columns,
+}: MultilineTextInputProps) {
+	const [cursorIndex, setCursorIndex] = useState(value.length)
+
+	// Use refs to track the latest values for use in the useInput callback.
+	// This prevents stale closure issues when multiple keystrokes arrive
+	// faster than React can re-render.
+	const valueRef = useRef(value)
+	const cursorIndexRef = useRef(cursorIndex)
+
+	// Track the previous value prop to detect actual changes from the parent
+	const prevValuePropRef = useRef(value)
+
+	// Only sync valueRef when the value prop actually changes from the parent.
+	// This prevents overwriting our optimistic updates during re-renders
+	// triggered by internal state changes (like setCursorIndex) before the
+	// parent has processed our onChange call.
+	if (value !== prevValuePropRef.current) {
+		valueRef.current = value
+		prevValuePropRef.current = value
+	}
+	// cursorIndex is internal state, safe to sync on every render
+	cursorIndexRef.current = cursorIndex
+
+	// Clamp cursor if value changes externally
+	useEffect(() => {
+		if (cursorIndex > value.length) {
+			setCursorIndex(value.length)
+		}
+	}, [value, cursorIndex])
+
+	// Handle keyboard input
+	useInput(
+		(input: string, key: Key) => {
+			// Read from refs to get the latest values, not stale closure captures
+			const currentValue = valueRef.current
+			const currentCursorIndex = cursorIndexRef.current
+
+			// Escape: clear all
+			if (key.escape) {
+				onEscape?.()
+				return
+			}
+
+			// Ignore inputs that are handled at the App level (global shortcuts)
+			// This includes Ctrl+C (exit), Ctrl+M (mode toggle), etc.
+			if (isGlobalInputSequence(input, key)) {
+				return
+			}
+
+			// Option+Enter (macOS) / Alt+Enter / Shift+Enter: add new line
+			// When Option/Alt is held, the terminal sends \r but key.return is false.
+			// This allows us to distinguish it from a regular Enter.
+			// Also support various terminal encodings for Shift+Enter.
+			const isModifiedEnter =
+				(input === "\r" && !key.return) || // Option+Enter on macOS sends \r but key.return=false
+				(key.return && key.shift) || // Shift+Enter if terminal reports modifiers
+				input === "\x1b[13;2u" || // CSI u encoding for Shift+Enter
+				input === "\x1b[27;2;13~" || // xterm modifyOtherKeys encoding for Shift+Enter
+				input === "\x1b\r" || // Some terminals send ESC+CR for Shift+Enter
+				input === "\x1bOM" || // Some terminals
+				(input.startsWith("\x1b[") && input.includes(";2") && input.endsWith("u")) // General CSI u with shift modifier
+
+			if (isModifiedEnter) {
+				const newValue =
+					currentValue.slice(0, currentCursorIndex) + "\n" + currentValue.slice(currentCursorIndex)
+				const newCursorIndex = currentCursorIndex + 1
+				// Update refs immediately for next keystroke
+				valueRef.current = newValue
+				cursorIndexRef.current = newCursorIndex
+				onChange(newValue)
+				setCursorIndex(newCursorIndex)
+				return
+			}
+
+			// Enter: submit
+			if (key.return) {
+				onSubmit?.(currentValue)
+				return
+			}
+
+			// Tab: ignore for now
+			if (key.tab) {
+				return
+			}
+
+			// Arrow up: move cursor up one line, or trigger history if on first line
+			if (key.upArrow) {
+				if (!showCursor) return
+				const lines = currentValue.split("\n")
+				const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
+
+				if (line > 0) {
+					// Move to previous line
+					const targetLine = lines[line - 1]!
+					const newCol = Math.min(col, targetLine.length)
+					const newCursorIndex = getIndexFromPosition(currentValue, line - 1, newCol)
+					cursorIndexRef.current = newCursorIndex
+					setCursorIndex(newCursorIndex)
+				} else {
+					// On first line - trigger history navigation callback
+					onUpAtFirstLine?.()
+				}
+				return
+			}
+
+			// Arrow down: move cursor down one line, or trigger history if on last line
+			if (key.downArrow) {
+				if (!showCursor) return
+				const lines = currentValue.split("\n")
+				const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
+
+				if (line < lines.length - 1) {
+					// Move to next line
+					const targetLine = lines[line + 1]!
+					const newCol = Math.min(col, targetLine.length)
+					const newCursorIndex = getIndexFromPosition(currentValue, line + 1, newCol)
+					cursorIndexRef.current = newCursorIndex
+					setCursorIndex(newCursorIndex)
+				} else {
+					// On last line - trigger history navigation callback
+					onDownAtLastLine?.()
+				}
+				return
+			}
+
+			// Arrow left: move cursor left
+			if (key.leftArrow) {
+				if (!showCursor) return
+				const newCursorIndex = Math.max(0, currentCursorIndex - 1)
+				cursorIndexRef.current = newCursorIndex
+				setCursorIndex(newCursorIndex)
+				return
+			}
+
+			// Arrow right: move cursor right
+			if (key.rightArrow) {
+				if (!showCursor) return
+				const newCursorIndex = Math.min(currentValue.length, currentCursorIndex + 1)
+				cursorIndexRef.current = newCursorIndex
+				setCursorIndex(newCursorIndex)
+				return
+			}
+
+			// Backspace/Delete
+			if (key.backspace || key.delete) {
+				if (currentCursorIndex > 0) {
+					const newValue =
+						currentValue.slice(0, currentCursorIndex - 1) + currentValue.slice(currentCursorIndex)
+					const newCursorIndex = currentCursorIndex - 1
+					// Update refs immediately for next keystroke
+					valueRef.current = newValue
+					cursorIndexRef.current = newCursorIndex
+					onChange(newValue)
+					setCursorIndex(newCursorIndex)
+				}
+				return
+			}
+
+			// Normal character input
+			if (input) {
+				const normalized = normalizeLineEndings(input)
+				const newValue =
+					currentValue.slice(0, currentCursorIndex) + normalized + currentValue.slice(currentCursorIndex)
+				const newCursorIndex = currentCursorIndex + normalized.length
+				// Update refs immediately for next keystroke
+				valueRef.current = newValue
+				cursorIndexRef.current = newCursorIndex
+				onChange(newValue)
+				setCursorIndex(newCursorIndex)
+			}
+		},
+		{ isActive },
+	)
+
+	// Split value into lines for rendering
+	const lines = useMemo(() => {
+		if (!value && !isActive) {
+			return [placeholder]
+		}
+		if (!value) {
+			return [""]
+		}
+		return value.split("\n")
+	}, [value, placeholder, isActive])
+
+	// Determine which line and column the cursor is on
+	const cursorPosition = useMemo(() => {
+		if (!showCursor || !isActive) return null
+		return getCursorPosition(value, cursorIndex)
+	}, [value, cursorIndex, showCursor, isActive])
+
+	// Calculate visual rows with wrapping
+	const visualRows = useMemo(() => {
+		const rows: VisualRow[] = []
+		const promptLen = prompt.length
+
+		for (let i = 0; i < lines.length; i++) {
+			const lineText = lines[i]!
+			// All rows use the same prefix width (prompt length) for consistent alignment
+			const prefixLen = promptLen
+			// Calculate available width for text (terminal width minus prefix)
+			// Use a large number if columns is not provided
+			const availableWidth = columns ? Math.max(1, columns - prefixLen) : 10000
+
+			const lineRows = wrapLine(lineText, i, availableWidth)
+			rows.push(...lineRows)
+		}
+
+		return rows
+	}, [lines, columns, prompt.length])
+
+	// Render a visual row with optional cursor
+	// Uses a two-column flex layout to ensure all text is vertically aligned:
+	// - Column 1: Fixed width for the prompt (only shown on first row)
+	// - Column 2: Text content
+	const renderVisualRow = useCallback(
+		(row: VisualRow, rowIndex: number) => {
+			const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0
+			const promptWidth = prompt.length
+			// Only show the prompt on the very first visual row (first row of first line)
+			const showPrompt = row.logicalLineIndex === 0 && row.isFirstRowOfLine
+
+			// Check if cursor is on this visual row
+			let hasCursor = false
+			let cursorColInRow = -1
+
+			if (cursorPosition && cursorPosition.line === row.logicalLineIndex && isActive) {
+				const cursorCol = cursorPosition.col
+				// Check if cursor falls within this visual row's range
+				if (cursorCol >= row.startCol && cursorCol < row.startCol + row.text.length) {
+					hasCursor = true
+					cursorColInRow = cursorCol - row.startCol
+				}
+				// Cursor at the end of this row (for the last row of a line)
+				else if (cursorCol === row.startCol + row.text.length) {
+					// Check if this is the last visual row for this logical line
+					const nextRow = visualRows[rowIndex + 1]
+					if (!nextRow || nextRow.logicalLineIndex !== row.logicalLineIndex) {
+						hasCursor = true
+						cursorColInRow = row.text.length
+					}
+				}
+			}
+
+			if (hasCursor) {
+				const beforeCursor = row.text.slice(0, cursorColInRow)
+				const cursorAtEnd = cursorColInRow >= row.text.length
+				const cursorChar = cursorAtEnd ? " " : row.text[cursorColInRow]!
+				const afterCursor = cursorAtEnd ? "" : row.text.slice(cursorColInRow + 1)
+
+				// Check if adding cursor space at end would overflow the line width.
+				// When cursor is at the end of a max-width row, rendering an extra space
+				// would push the content beyond the terminal width, causing visual shift.
+				const wouldOverflow =
+					columns !== undefined && cursorAtEnd && promptWidth + row.text.length + 1 > columns
+
+				if (wouldOverflow) {
+					// Don't add extra space - cursor will appear at start of next row when text wraps
+					return (
+						<Box key={rowIndex} flexDirection="row">
+							<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
+							<Text>{row.text}</Text>
+						</Box>
+					)
+				}
+
+				return (
+					<Box key={rowIndex} flexDirection="row">
+						<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
+						<Text>{beforeCursor}</Text>
+						<Text inverse>{cursorChar}</Text>
+						<Text>{afterCursor}</Text>
+					</Box>
+				)
+			}
+
+			// For rows without cursor, use a space for empty text to ensure the row has height
+			// This fixes the issue where empty newlines don't expand the component height
+			const displayText = row.text.length === 0 ? " " : row.text
+
+			return (
+				<Box key={rowIndex} flexDirection="row">
+					<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
+					<Text dimColor={isPlaceholder}>{displayText}</Text>
+				</Box>
+			)
+		},
+		[prompt, cursorPosition, value, isActive, visualRows, columns],
+	)
+
+	return <Box flexDirection="column">{visualRows.map((row, index) => renderVisualRow(row, index))}</Box>
+}

+ 61 - 0
apps/cli/src/ui/components/ProgressBar.tsx

@@ -0,0 +1,61 @@
+import { memo } from "react"
+import { Text } from "ink"
+
+import * as theme from "../theme.js"
+
+interface ProgressBarProps {
+	/** Current value (e.g., contextTokens) */
+	value: number
+	/** Maximum value (e.g., contextWindow) */
+	max: number
+	/** Width of the bar in characters (default: 16) */
+	width?: number
+}
+
+/**
+ * A progress bar component with color gradient based on fill percentage.
+ *
+ * Colors:
+ * - 0-50%: Green (safe zone)
+ * - 50-75%: Yellow (warning zone)
+ * - 75-100%: Red (danger zone)
+ *
+ * Visual example: [████████░░░░░░░░] 50%
+ */
+function ProgressBar({ value, max, width = 16 }: ProgressBarProps) {
+	// Calculate percentage, clamped to 0-100
+	const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0
+
+	// Calculate how many blocks to fill
+	const filledBlocks = Math.round((percentage / 100) * width)
+	const emptyBlocks = width - filledBlocks
+
+	// Determine color based on percentage
+	let barColor: string
+	if (percentage <= 50) {
+		barColor = theme.successColor // Green
+	} else if (percentage <= 75) {
+		barColor = theme.warningColor // Yellow
+	} else {
+		barColor = theme.errorColor // Red
+	}
+
+	// Unicode block characters for smooth appearance
+	const filledChar = "█"
+	const emptyChar = "░"
+
+	const filledPart = filledChar.repeat(filledBlocks)
+	const emptyPart = emptyChar.repeat(emptyBlocks)
+
+	return (
+		<Text>
+			<Text color={theme.dimText}>[</Text>
+			<Text color={barColor}>{filledPart}</Text>
+			<Text color={theme.dimText}>
+				{emptyPart}] {Math.round(percentage)}%
+			</Text>
+		</Text>
+	)
+}
+
+export default memo(ProgressBar)

+ 398 - 0
apps/cli/src/ui/components/ScrollArea.tsx

@@ -0,0 +1,398 @@
+import { Box, DOMElement, measureElement, Text, useInput } from "ink"
+import { useEffect, useReducer, useRef, useCallback, useMemo, useState } from "react"
+
+import * as theme from "../theme.js"
+
+interface ScrollAreaState {
+	innerHeight: number
+	height: number
+	scrollTop: number
+	autoScroll: boolean
+}
+
+function calculateScrollbar(
+	viewportHeight: number,
+	contentHeight: number,
+	scrollTop: number,
+): { handleStart: number; handleHeight: number; maxScroll: number } {
+	const maxScroll = Math.max(0, contentHeight - viewportHeight)
+
+	if (contentHeight <= viewportHeight || maxScroll === 0) {
+		// No scrolling needed - handle fills entire track
+		return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 }
+	}
+
+	// Calculate handle height as ratio of viewport to content (minimum 1 line)
+	const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight))
+
+	// Calculate handle position
+	const trackSpace = viewportHeight - handleHeight
+	const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0
+	const handleStart = Math.round(scrollRatio * trackSpace)
+
+	return { handleStart, handleHeight, maxScroll }
+}
+
+type ScrollAreaAction =
+	| { type: "SET_INNER_HEIGHT"; innerHeight: number }
+	| { type: "SET_HEIGHT"; height: number }
+	| { type: "SCROLL_DOWN"; amount?: number }
+	| { type: "SCROLL_UP"; amount?: number }
+	| { type: "SCROLL_TO_BOTTOM" }
+	| { type: "SCROLL_TO_LINE"; line: number }
+	| { type: "SET_AUTO_SCROLL"; autoScroll: boolean }
+
+function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState {
+	const maxScroll = Math.max(0, state.innerHeight - state.height)
+
+	switch (action.type) {
+		case "SET_INNER_HEIGHT": {
+			const newMaxScroll = Math.max(0, action.innerHeight - state.height)
+			// If auto-scroll is enabled and content grew, scroll to bottom
+			if (state.autoScroll && action.innerHeight > state.innerHeight) {
+				return {
+					...state,
+					innerHeight: action.innerHeight,
+					scrollTop: newMaxScroll,
+				}
+			}
+			// Clamp scrollTop to valid range
+			return {
+				...state,
+				innerHeight: action.innerHeight,
+				scrollTop: Math.min(state.scrollTop, newMaxScroll),
+			}
+		}
+
+		case "SET_HEIGHT": {
+			const newMaxScroll = Math.max(0, state.innerHeight - action.height)
+			// If auto-scroll is enabled, stay at bottom
+			if (state.autoScroll) {
+				return {
+					...state,
+					height: action.height,
+					scrollTop: newMaxScroll,
+				}
+			}
+			// Clamp scrollTop to valid range
+			return {
+				...state,
+				height: action.height,
+				scrollTop: Math.min(state.scrollTop, newMaxScroll),
+			}
+		}
+
+		case "SCROLL_DOWN": {
+			const amount = action.amount || 1
+			const newScrollTop = Math.min(maxScroll, state.scrollTop + amount)
+			// If we scroll to the bottom, re-enable auto-scroll
+			const atBottom = newScrollTop >= maxScroll
+			return {
+				...state,
+				scrollTop: newScrollTop,
+				autoScroll: atBottom,
+			}
+		}
+
+		case "SCROLL_UP": {
+			const amount = action.amount || 1
+			const newScrollTop = Math.max(0, state.scrollTop - amount)
+			// Disable auto-scroll when user scrolls up
+			return {
+				...state,
+				scrollTop: newScrollTop,
+				autoScroll: newScrollTop >= maxScroll,
+			}
+		}
+
+		case "SCROLL_TO_BOTTOM":
+			return {
+				...state,
+				scrollTop: maxScroll,
+				autoScroll: true,
+			}
+
+		case "SCROLL_TO_LINE": {
+			// Scroll to make a specific line visible
+			// If line is above viewport, scroll up to show it at the top
+			// If line is below viewport, scroll down to show it at the bottom
+			const line = action.line
+			const viewportBottom = state.scrollTop + state.height - 1
+
+			if (line < state.scrollTop) {
+				// Line is above viewport - scroll up to show it at the top
+				return {
+					...state,
+					scrollTop: Math.max(0, line),
+					autoScroll: false,
+				}
+			} else if (line > viewportBottom) {
+				// Line is below viewport - scroll down to show it at the bottom
+				const newScrollTop = Math.min(maxScroll, line - state.height + 1)
+				return {
+					...state,
+					scrollTop: newScrollTop,
+					autoScroll: newScrollTop >= maxScroll,
+				}
+			}
+			// Line is already visible - no change needed
+			return state
+		}
+
+		case "SET_AUTO_SCROLL":
+			return {
+				...state,
+				autoScroll: action.autoScroll,
+				scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
+			}
+
+		default:
+			return state
+	}
+}
+
+export interface ScrollAreaProps {
+	height?: number
+	children: React.ReactNode
+	isActive?: boolean
+	onScroll?: (scrollTop: number, maxScroll: number, isAtBottom: boolean) => void
+	showBorder?: boolean
+	scrollToBottomTrigger?: number
+	scrollToLine?: number
+	scrollToLineTrigger?: number
+	showScrollbar?: boolean
+	/** Whether to auto-scroll to bottom when content grows. Default: true */
+	autoScroll?: boolean
+}
+
+export function ScrollArea({
+	height: heightProp,
+	children,
+	isActive = true,
+	onScroll,
+	showBorder = false,
+	scrollToBottomTrigger,
+	scrollToLine,
+	scrollToLineTrigger,
+	showScrollbar = true,
+	autoScroll: autoScrollProp = true,
+}: ScrollAreaProps) {
+	// Ref for measuring outer container height when not provided
+	const outerRef = useRef<DOMElement>(null)
+	const [measuredHeight, setMeasuredHeight] = useState(0)
+
+	// Use provided height or measured height
+	const height = heightProp ?? measuredHeight
+
+	const [state, dispatch] = useReducer(reducer, {
+		height: height,
+		scrollTop: 0,
+		innerHeight: 0,
+		autoScroll: autoScrollProp,
+	})
+
+	const innerRef = useRef<DOMElement>(null)
+	const lastMeasuredHeight = useRef<number>(0)
+	// Track previous scrollToLineTrigger to detect actual changes (allows scrolling to index 0)
+	const prevScrollToLineTriggerRef = useRef<number | undefined>(undefined)
+
+	// Update height when prop changes
+	useEffect(() => {
+		if (height > 0) {
+			dispatch({ type: "SET_HEIGHT", height })
+		}
+	}, [height])
+
+	// Measure outer container height when no height prop is provided
+	useEffect(() => {
+		if (heightProp !== undefined) return // Skip if height is provided
+
+		const measureOuter = () => {
+			if (!outerRef.current) return
+			const dimensions = measureElement(outerRef.current)
+			if (dimensions.height !== measuredHeight && dimensions.height > 0) {
+				setMeasuredHeight(dimensions.height)
+			}
+		}
+
+		// Initial measurement
+		measureOuter()
+
+		// Re-measure periodically to catch layout changes
+		const interval = setInterval(measureOuter, 100)
+
+		return () => {
+			clearInterval(interval)
+		}
+	}, [heightProp, measuredHeight])
+
+	// Scroll to bottom when trigger changes
+	useEffect(() => {
+		if (scrollToBottomTrigger !== undefined && scrollToBottomTrigger > 0) {
+			dispatch({ type: "SCROLL_TO_BOTTOM" })
+		}
+	}, [scrollToBottomTrigger])
+
+	// Scroll to specific line when trigger changes
+	// FIX: Use ref to detect actual changes instead of `> 0` check, which broke scrolling to index 0
+	useEffect(() => {
+		const prevTrigger = prevScrollToLineTriggerRef.current
+		const triggerChanged = scrollToLineTrigger !== prevTrigger
+
+		// Only dispatch if trigger actually changed and we have valid values
+		// This allows scrolling to index 0 (which was broken by the old `> 0` check)
+		if (triggerChanged && scrollToLineTrigger !== undefined && scrollToLine !== undefined) {
+			dispatch({ type: "SCROLL_TO_LINE", line: scrollToLine })
+		}
+
+		// Update the ref to track the current trigger value
+		prevScrollToLineTriggerRef.current = scrollToLineTrigger
+	}, [scrollToLineTrigger, scrollToLine])
+
+	// Measure inner content height - use MutationObserver pattern for dynamic content
+	useEffect(() => {
+		if (!innerRef.current) return
+
+		const measureAndUpdate = () => {
+			if (!innerRef.current) return
+			const dimensions = measureElement(innerRef.current)
+			if (dimensions.height !== lastMeasuredHeight.current) {
+				lastMeasuredHeight.current = dimensions.height
+				dispatch({ type: "SET_INNER_HEIGHT", innerHeight: dimensions.height })
+			}
+		}
+
+		// Initial measurement
+		measureAndUpdate()
+
+		// Re-measure periodically while component is mounted
+		// This handles streaming content that changes size
+		const interval = setInterval(measureAndUpdate, 100)
+
+		return () => {
+			clearInterval(interval)
+		}
+	}, [children])
+
+	// Notify parent of scroll changes
+	useEffect(() => {
+		if (onScroll) {
+			const maxScroll = Math.max(0, state.innerHeight - state.height)
+			const isAtBottom = state.scrollTop >= maxScroll || maxScroll === 0
+			onScroll(state.scrollTop, maxScroll, isAtBottom)
+		}
+	}, [state.scrollTop, state.innerHeight, state.height, onScroll])
+
+	// Handle keyboard input for scrolling
+	useInput(
+		(_input, key) => {
+			if (!isActive) return
+
+			if (key.downArrow) {
+				dispatch({ type: "SCROLL_DOWN" })
+			}
+
+			if (key.upArrow) {
+				dispatch({ type: "SCROLL_UP" })
+			}
+
+			if (key.pageDown) {
+				dispatch({ type: "SCROLL_DOWN", amount: Math.floor(state.height / 2) })
+			}
+
+			if (key.pageUp) {
+				dispatch({ type: "SCROLL_UP", amount: Math.floor(state.height / 2) })
+			}
+
+			// Home - scroll to top
+			if (key.ctrl && _input === "a") {
+				dispatch({ type: "SCROLL_UP", amount: state.scrollTop })
+			}
+
+			// End - scroll to bottom
+			if (key.ctrl && _input === "e") {
+				dispatch({ type: "SCROLL_TO_BOTTOM" })
+			}
+		},
+		{ isActive },
+	)
+
+	// Calculate scrollbar dimensions
+	const scrollbar = useMemo(() => {
+		return calculateScrollbar(state.height, state.innerHeight, state.scrollTop)
+	}, [state.height, state.innerHeight, state.scrollTop])
+
+	// Determine if scrollbar should be visible
+	// Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state)
+	// Hide scrollbar only when: not focused AND nothing to scroll
+	const showScrollbarVisible = showScrollbar && (scrollbar.maxScroll > 0 || isActive)
+
+	// Scrollbar colors based on focus state
+	// When active: handle is bright purple, track is muted
+	// When inactive: handle is dim gray, track is more muted
+	const handleColor = isActive ? theme.scrollActiveColor : theme.dimText
+	const trackColor = theme.scrollTrackColor
+
+	// When no height prop is provided, use flexGrow to fill available space
+	const useFlexGrow = heightProp === undefined
+
+	return (
+		<Box
+			ref={outerRef}
+			flexDirection="row"
+			height={useFlexGrow ? undefined : height}
+			flexGrow={useFlexGrow ? 1 : undefined}
+			flexShrink={useFlexGrow ? 1 : undefined}
+			overflow="hidden">
+			{/* Scroll content area */}
+			<Box
+				height={useFlexGrow ? undefined : height}
+				borderStyle={showBorder ? "single" : undefined}
+				flexDirection="column"
+				flexGrow={1}
+				flexShrink={1}
+				overflow="hidden">
+				<Box ref={innerRef} flexShrink={0} flexDirection="column" marginTop={-state.scrollTop}>
+					{children}
+				</Box>
+			</Box>
+
+			{/* Scrollbar - rendered with separate colors for handle and track */}
+			{showScrollbar && (
+				<Box flexDirection="column" width={1} flexShrink={0} overflow="hidden">
+					{showScrollbarVisible &&
+						height > 0 &&
+						Array(height)
+							.fill(null)
+							.map((_, i) => {
+								const isHandle =
+									i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
+								return (
+									<Text key={i} color={isHandle ? handleColor : trackColor}>
+										{isHandle ? "┃" : "│"}
+									</Text>
+								)
+							})}
+				</Box>
+			)}
+		</Box>
+	)
+}
+
+/**
+ * Hook to use with ScrollArea for external control
+ */
+export function useScrollToBottom() {
+	const triggerRef = useRef(0)
+	const [, forceUpdate] = useReducer((x) => x + 1, 0)
+
+	const scrollToBottom = useCallback(() => {
+		triggerRef.current += 1
+		forceUpdate()
+	}, [])
+
+	return {
+		scrollToBottomTrigger: triggerRef.current,
+		scrollToBottom,
+	}
+}

+ 26 - 0
apps/cli/src/ui/components/ScrollIndicator.tsx

@@ -0,0 +1,26 @@
+import { Box, Text } from "ink"
+import { memo } from "react"
+
+import * as theme from "../theme.js"
+
+interface ScrollIndicatorProps {
+	scrollTop: number
+	maxScroll: number
+	isScrollFocused?: boolean
+}
+
+function ScrollIndicator({ scrollTop, maxScroll, isScrollFocused = false }: ScrollIndicatorProps) {
+	// Calculate percentage - show 100% when at bottom or no scrolling needed.
+	const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 100
+
+	// Color changes based on focus state.
+	const color = isScrollFocused ? theme.scrollActiveColor : theme.dimText
+
+	return (
+		<Box>
+			<Text color={color}>{percentage}% • ↑↓ scroll • Ctrl+E end</Text>
+		</Box>
+	)
+}
+
+export default memo(ScrollIndicator)

+ 56 - 0
apps/cli/src/ui/components/ToastDisplay.tsx

@@ -0,0 +1,56 @@
+import { memo } from "react"
+import { Text, Box } from "ink"
+
+import * as theme from "../theme.js"
+import type { Toast, ToastType } from "../hooks/useToast.js"
+
+interface ToastDisplayProps {
+	toast: Toast | null
+}
+
+function getToastColor(type: ToastType): string {
+	switch (type) {
+		case "success":
+			return theme.successColor
+		case "warning":
+			return theme.warningColor
+		case "error":
+			return theme.errorColor
+		case "info":
+		default:
+			return theme.focusColor // cyan for info
+	}
+}
+
+function getToastIcon(type: ToastType): string {
+	switch (type) {
+		case "success":
+			return "✓"
+		case "warning":
+			return "⚠"
+		case "error":
+			return "✗"
+		case "info":
+		default:
+			return "ℹ"
+	}
+}
+
+function ToastDisplay({ toast }: ToastDisplayProps) {
+	if (!toast) {
+		return null
+	}
+
+	const color = getToastColor(toast.type)
+	const icon = getToastIcon(toast.type)
+
+	return (
+		<Box>
+			<Text color={color}>
+				{icon} {toast.message}
+			</Text>
+		</Box>
+	)
+}
+
+export default memo(ToastDisplay)

+ 142 - 0
apps/cli/src/ui/components/TodoChangeDisplay.tsx

@@ -0,0 +1,142 @@
+import { memo } from "react"
+import { Box, Text } from "ink"
+
+import type { TodoItem } from "@roo-code/types"
+
+import * as theme from "../theme.js"
+
+/**
+ * Status icons for TODO items using Unicode characters
+ */
+const STATUS_ICONS = {
+	completed: "✓",
+	in_progress: "→",
+	pending: "○",
+} as const
+
+/**
+ * Get the color for a TODO status
+ */
+function getStatusColor(status: TodoItem["status"]): string {
+	switch (status) {
+		case "completed":
+			return theme.successColor
+		case "in_progress":
+			return theme.warningColor
+		case "pending":
+		default:
+			return theme.dimText
+	}
+}
+
+interface TodoChangeDisplayProps {
+	/** Previous TODO list for comparison */
+	previousTodos: TodoItem[]
+	/** New TODO list */
+	newTodos: TodoItem[]
+}
+
+/**
+ * TodoChangeDisplay component for CLI
+ *
+ * Shows only the items that changed between two TODO lists.
+ * Used for compact inline display in the chat history.
+ *
+ * Visual example:
+ * ```
+ * ☑ TODO Updated
+ *   ✓ Design architecture      [completed]
+ *   → Implement core logic     [started]
+ * ```
+ */
+function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) {
+	if (!newTodos || newTodos.length === 0) {
+		return null
+	}
+
+	const isInitialState = previousTodos.length === 0
+
+	// Determine which todos to display
+	let todosToDisplay: TodoItem[]
+
+	if (isInitialState) {
+		// For initial state, show all todos
+		todosToDisplay = newTodos
+	} else {
+		// For updates, only show changes (completed or started items)
+		todosToDisplay = newTodos.filter((newTodo) => {
+			if (newTodo.status === "completed") {
+				const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content)
+				return !previousTodo || previousTodo.status !== "completed"
+			}
+			if (newTodo.status === "in_progress") {
+				const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content)
+				return !previousTodo || previousTodo.status !== "in_progress"
+			}
+			return false
+		})
+	}
+
+	// If no changes to display, show nothing
+	if (todosToDisplay.length === 0) {
+		return null
+	}
+
+	// Calculate progress for summary
+	const totalCount = newTodos.length
+	const completedCount = newTodos.filter((t) => t.status === "completed").length
+
+	return (
+		<Box flexDirection="column" paddingX={1}>
+			{/* Header with progress summary */}
+			<Box>
+				<Text color={theme.toolHeader} bold>
+					☑ TODO {isInitialState ? "List" : "Updated"}
+				</Text>
+				<Text color={theme.dimText}>
+					{" "}
+					({completedCount}/{totalCount})
+				</Text>
+			</Box>
+
+			{/* Changed items */}
+			<Box flexDirection="column" paddingLeft={2}>
+				{todosToDisplay.map((todo, index) => {
+					const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.pending
+					const color = getStatusColor(todo.status)
+
+					// Determine what changed
+					const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content)
+					let changeLabel: string | null = null
+
+					if (isInitialState) {
+						// Don't show labels for initial state
+						changeLabel = null
+					} else if (!previousTodo) {
+						changeLabel = "new"
+					} else if (todo.status === "completed" && previousTodo.status !== "completed") {
+						changeLabel = "done"
+					} else if (todo.status === "in_progress" && previousTodo.status !== "in_progress") {
+						changeLabel = "started"
+					}
+
+					return (
+						<Box key={todo.id || `todo-${index}`}>
+							<Text color={color}>
+								{icon} {todo.content}
+							</Text>
+							{changeLabel && (
+								<Text color={theme.dimText} dimColor>
+									{" "}
+									[{changeLabel}]
+								</Text>
+							)}
+						</Box>
+					)
+				})}
+			</Box>
+		</Box>
+	)
+}
+
+export default memo(TodoChangeDisplay)

Некоторые файлы не были показаны из-за большого количества измененных файлов