diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000000..d10e7b1c8c8f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "references/codex"] + path = references/codex + url = https://github.com/openai/codex +[submodule "references/cline"] + path = references/cline + url = https://github.com/cline/cline +[submodule "references/awesome-opencode"] + path = references/awesome-opencode + url = https://github.com/awesome-opencode/awesome-opencode +[submodule "references/OpenGUI"] + path = references/OpenGUI + url = https://github.com/akemmanuel/OpenGUI +[submodule "references/AionUi"] + path = references/AionUi + url = https://github.com/iOfficeAI/AionUi diff --git a/AI_REVIEW.md b/AI_REVIEW.md new file mode 100644 index 000000000000..bc3462933800 --- /dev/null +++ b/AI_REVIEW.md @@ -0,0 +1,1428 @@ +# 🔍 Code Review - 2/26/2026, 4:03:24 PM + +**Project:** AI Visual Code Review +**Generated by:** AI Visual Code Review v2.0 + +## 📊 Change Summary + +``` +docs/09-temp/codex-queue-steer-architecture.md | 319 +++++++++++++++++++++ + packages/app/src/components/prompt-input.tsx | 256 ++++++++++++++--- + .../app/src/context/global-sync/child-store.ts | 1 + + .../src/context/global-sync/event-reducer.test.ts | 1 + + .../app/src/context/global-sync/event-reducer.ts | 9 +- + packages/app/src/context/global-sync/types.ts | 3 + + packages/opencode/src/server/routes/session.ts | 117 ++++++++ + packages/opencode/src/session/prompt.ts | 51 ++++ + packages/opencode/src/session/steer.ts | 127 ++++++++ + packages/opencode/test/session/steer.test.ts | 235 +++++++++++++++ + 10 files changed, 1084 insertions(+), 35 deletions(-) +``` + +## 📝 Files Changed (10 selected) + + +### ✨ `docs/09-temp/codex-queue-steer-architecture.md` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** Documentation 📖 + +```diff +@@ -0,0 +1,319 @@ + 1 +# Codex Queue/Steer Architecture Analysis + 2 + + 3 +> Deep-dive into OpenAI Codex CLI's queue/steer mechanism for mid-turn user interaction. + 4 +> Source: `references/codex/` submodule + 5 + + 6 +--- + 7 + + 8 +## Overview + 9 + + 10 +Codex implements a **dual-input model** that lets users interact with the agent **during** an active turn, not just between turns: + 11 + + 12 +| Action | Keybinding | Behavior | When Turn Active | + 13 +|--------|-----------|----------|-----------------| + 14 +| **Queue** | `Enter` | Enqueue message for next turn boundary | Message waits in queue, displayed in UI | + 15 +| **Steer** | `⌘Enter` / `Enter` (steer-mode) | Inject input into active turn immediately | Message sent to model in current context | + 16 + + 17 +--- + 18 + + 19 +## Architecture Layers + 20 + + 21 +``` + 22 +┌─────────────────────────────────────────────────┐ + 23 +│ TUI Layer (tui/src/) │ + 24 +│ ┌─────────────────────────────────────────┐ │ + 25 +│ │ ChatComposer │ │ + 26 +│ │ Enter → InputResult::Submitted (steer) │ │ + 27 +│ │ Tab → InputResult::Queued │ │ + 28 +│ └─────────────────┬───────────────────────┘ │ + 29 +│ │ │ + 30 +│ ┌─────────────────▼───────────────────────┐ │ + 31 +│ │ QueuedUserMessages widget │ │ + 32 +│ │ Shows queued messages with "↳" prefix │ │ + 33 +│ │ Alt+Up to pop back into composer │ │ + 34 +│ └─────────────────────────────────────────┘ │ + 35 +└────────────────────┬────────────────────────────┘ + 36 + │ + 37 +┌────────────────────▼────────────────────────────┐ + 38 +│ App Server Protocol (app-server-protocol/) │ + 39 +│ │ + 40 +│ turn/start → TurnStartParams (new turn) │ + 41 +│ turn/steer → TurnSteerParams (mid-turn) │ + 42 +│ │ + 43 +│ TurnSteerParams { │ + 44 +│ thread_id: String, │ + 45 +│ input: Vec, │ + 46 +│ expected_turn_id: String, // guard │ + 47 +│ } │ + 48 +│ │ + 49 +│ TurnSteerResponse { │ + 50 +│ turn_id: String, // confirms active turn │ + 51 +│ } │ + 52 +└────────────────────┬────────────────────────────┘ + 53 + │ + 54 +┌────────────────────▼────────────────────────────┐ + 55 +│ App Server (app-server/src/) │ + 56 +│ codex_message_processor.rs │ + 57 +│ │ + 58 +│ async fn turn_steer(&self, req_id, params) { │ + 59 +│ let thread = load_thread(params.thread_id); │ + 60 +│ thread.steer_input( │ + 61 +│ mapped_items, │ + 62 +│ Some(¶ms.expected_turn_id) │ + 63 +│ ); │ + 64 +│ // Returns turn_id or error: │ + 65 +│ // "no active turn to steer" │ + 66 +│ } │ + 67 +└────────────────────┬────────────────────────────┘ + 68 + │ + 69 +┌────────────────────▼────────────────────────────┐ + 70 +│ Core Engine (core/src/codex.rs) │ + 71 +│ │ + 72 +│ Session::steer_input(input, expected_turn_id) │ + 73 +│ 1. Validate input not empty │ + 74 +│ 2. Lock active_turn mutex │ + 75 +│ 3. Verify active turn exists │ + 76 +│ 4. Check expected_turn_id matches │ + 77 +│ 5. Lock turn_state │ + 78 +│ 6. push_pending_input(input) ← KEY STEP │ + 79 +│ 7. Return active turn_id │ + 80 +└────────────────────┬────────────────────────────┘ + 81 + │ + 82 +┌────────────────────▼────────────────────────────┐ + 83 +│ Turn State (core/src/state/turn.rs) │ + 84 +│ │ + 85 +│ struct TurnState { │ + 86 +│ pending_input: Vec, │ + 87 +│ } │ + 88 +│ │ + 89 +│ push_pending_input(item) → appends to vec │ + 90 +│ take_pending_input() → drains vec │ + 91 +│ has_pending_input() → checks non-empty │ + 92 +└────────────────────┬────────────────────────────┘ + 93 + │ + 94 +┌────────────────────▼────────────────────────────┐ + 95 +│ Task Loop (core/src/codex.rs ~L4970) │ + 96 +│ │ + 97 +│ loop { │ + 98 +│ // At each iteration, drain pending input │ + 99 +│ let pending = sess.get_pending_input().await; │ + 100 +│ if !pending.is_empty() { │ + 101 +│ // Record as conversation items │ + 102 +│ // → injected into model context │ + 103 +│ for item in pending { │ + 104 +│ record_user_prompt_and_emit_turn_item(); │ + 105 +│ } │ + 106 +│ } │ + 107 +│ // ... send to model, process response ... │ + 108 +│ // On ResponseEvent::Completed: │ + 109 +│ needs_follow_up |= has_pending_input(); │ + 110 +│ // If follow_up needed → loop continues │ + 111 +│ } │ + 112 +└─────────────────────────────────────────────────┘ + 113 +``` + 114 + + 115 +--- + 116 + + 117 +## Core Mechanism: `steer_input` + 118 + + 119 +The heart of steer is `Session::steer_input()` in `core/src/codex.rs`: + 120 + + 121 +```rust + 122 +pub async fn steer_input( + 123 + &self, + 124 + input: Vec, + 125 + expected_turn_id: Option<&str>, + 126 +) -> Result { + 127 + if input.is_empty() { + 128 + return Err(SteerInputError::EmptyInput); + 129 + } + 130 + let mut active = self.active_turn.lock().await; + 131 + let Some(active_turn) = active.as_mut() else { + 132 + return Err(SteerInputError::NoActiveTurn(input)); + 133 + }; + 134 + let Some((active_turn_id, _)) = active_turn.tasks.first() else { + 135 + return Err(SteerInputError::NoActiveTurn(input)); + 136 + }; + 137 + if let Some(expected_turn_id) = expected_turn_id + 138 + && expected_turn_id != active_turn_id + 139 + { + 140 + return Err(SteerInputError::ExpectedTurnMismatch { + 141 + expected: expected_turn_id.to_string(), + 142 + actual: active_turn_id.clone(), + 143 + }); + 144 + } + 145 + let mut turn_state = active_turn.turn_state.lock().await; + 146 + turn_state.push_pending_input(input.into()); + 147 + Ok(active_turn_id.clone()) + 148 +} + 149 +``` + 150 + + 151 +### Key Design Decisions + 152 + + 153 +1. **Non-blocking injection**: `steer_input` just pushes to a `Vec` — it doesn't interrupt or cancel the model. The model's active response completes naturally. + 154 + + 155 +2. **Consumption at loop boundary**: The task loop checks `pending_input` at the **top of each iteration**. After the model finishes a response, if pending input exists, it gets recorded as conversation items and the model is called again with the updated context. + 156 + + 157 +3. **`needs_follow_up` flag**: When a model response completes (`ResponseEvent::Completed`), if there's pending input, the loop sets `needs_follow_up = true` and continues instead of ending the turn. + 158 + + 159 +4. **Turn ID validation**: The `expected_turn_id` field prevents race conditions — the steer request fails if the turn has changed between the user pressing Enter and the server processing the request. + 160 + + 161 +--- + 162 + + 163 +## Error Types + 164 + + 165 +```rust + 166 +pub enum SteerInputError { + 167 + NoActiveTurn(Vec), // No model turn running + 168 + ExpectedTurnMismatch { // Turn changed since request + 169 + expected: String, + 170 + actual: String, + 171 + }, + 172 + EmptyInput, // Nothing to inject + 173 +} + 174 +``` + 175 + + 176 +When `NoActiveTurn` occurs, the app-server falls back — the input that failed to steer gets queued for the next `turn/start`. + 177 + + 178 +--- + 179 + + 180 +## Queue vs Steer: Detailed Comparison + 181 + + 182 +### Queue (Tab / Enter in legacy mode) + 183 + + 184 +1. User types message, presses Tab (or Enter in non-steer mode) + 185 +2. TUI returns `InputResult::Queued { text, text_elements }` + 186 +3. Message stored in `QueuedUserMessages.messages: Vec` + 187 +4. Rendered in UI with `↳` prefix, dimmed/italic + 188 +5. User can pop with Alt+Up to edit + 189 +6. When current turn completes → queued messages become the next `turn/start` + 190 + + 191 +### Steer (Enter in steer mode / ⌘Enter) + 192 + + 193 +1. User types message, presses Enter + 194 +2. TUI returns `InputResult::Submitted { text, text_elements }` + 195 +3. App sends `turn/steer` RPC to server + 196 +4. Server calls `thread.steer_input()` → pushes to `pending_input` + 197 +5. Model's current response continues to completion + 198 +6. At next task loop iteration, pending input is drained and recorded + 199 +7. Model sees the user's steer message in context → generates follow-up + 200 +8. **All within the same turn** — no new turn boundary + 201 + + 202 +### Critical Difference + 203 + + 204 +| Aspect | Queue | Steer | + 205 +|--------|-------|-------| + 206 +| **Timing** | After turn ends | During active turn | + 207 +| **Turn boundary** | Creates new turn | Same turn continues | + 208 +| **Model sees it** | On next turn start | At next loop iteration | + 209 +| **Cancels response** | No (waits) | No (appends to context) | + 210 +| **UI display** | Queued messages widget | Injected into chat transcript | + 211 +| **Fallback** | N/A | Falls back to queue if no active turn | + 212 + + 213 +--- + 214 + + 215 +## Turn Lifecycle with Steer + 216 + + 217 +``` + 218 +Turn Start (user submits prompt) + 219 + │ + 220 + ├─→ Model generates response... + 221 + │ │ + 222 + │ │ ← User presses Enter (steer) + 223 + │ │ → steer_input() pushes to pending_input + 224 + │ │ + 225 + │ ▼ + 226 + │ Response completes + 227 + │ │ + 228 + │ ├─→ has_pending_input()? YES + 229 + │ │ → needs_follow_up = true + 230 + │ │ + 231 + │ ▼ + 232 + │ Loop continues → drain pending_input + 233 + │ → Record steered message as conversation item + 234 + │ → Model sees: [original prompt, response, steered message] + 235 + │ → Model generates new response with full context + 236 + │ │ + 237 + │ ├─→ has_pending_input()? NO + 238 + │ │ → needs_follow_up = false + 239 + │ ▼ + 240 + │ Turn Complete + 241 + │ + 242 + └─→ Queued messages (if any) → next turn/start + 243 +``` + 244 + + 245 +--- + 246 + + 247 +## Turn Completion & Leftover Input + 248 + + 249 +When a task finishes (`task_finished()` in `core/src/tasks/mod.rs`): + 250 + + 251 +```rust + 252 +// 1. Lock active turn + 253 +let mut active = self.active_turn.lock().await; + 254 +// 2. Take any remaining pending input + 255 +let pending_input = ts.take_pending_input(); + 256 +// 3. Clear active turn + 257 +*active = None; + 258 +// 4. Record leftover input as conversation items + 259 +if !pending_input.is_empty() { + 260 + record_conversation_items(&turn_context, &pending_response_items); + 261 +} + 262 +// 5. Emit TurnComplete event + 263 +``` + 264 + + 265 +This ensures steered input is **never lost** — even if the turn ends before the pending input could be consumed by the model loop. + 266 + + 267 +--- + 268 + + 269 +## Feature Flag: `steer_enabled` + 270 + + 271 +Steer is gated behind `Feature::Steer` in the TUI: + 272 + + 273 +```rust + 274 +// When steer_enabled == true: + 275 +// Enter → Submitted (steer immediately) + 276 +// Tab → Queued (wait for turn end) + 277 +// + 278 +// When steer_enabled == false (legacy): + 279 +// Enter → Queued + 280 +// Tab → Queued + 281 +``` + 282 + + 283 +--- + 284 + + 285 +## Implications for OpenCode + 286 + + 287 +### What OpenCode Currently Has + 288 +- Session/turn model with `processor.ts` handling model interaction + 289 +- Parallel agents via `task.ts` tool + 290 +- No mid-turn input injection + 291 + + 292 +### What Queue/Steer Would Add + 293 +1. **Pending input buffer** on the session/turn state + 294 +2. **Steer RPC** that pushes to the buffer while model is running + 295 +3. **Loop-boundary drain** that checks for pending input after each model response + 296 +4. **Follow-up continuation** instead of ending the turn when input is pending + 297 +5. **UI queue widget** showing messages waiting for the current turn to finish + 298 +6. **Fallback path**: steer → queue if no active turn + 299 + + 300 +### Key Implementation Points + 301 +- `steer_input()` is a **lock-based, non-cancelling** approach — it doesn't abort the model stream + 302 +- Pending input is consumed at the **top of the agentic loop**, not mid-stream + 303 +- The model sees steered input as additional conversation items on its next iteration + 304 +- `expected_turn_id` prevents stale steer requests from affecting wrong turns + 305 +- Queued messages are a purely UI-side concept until they become a `turn/start` + 306 + + 307 +--- + 308 + + 309 +## References + 310 + + 311 +- Protocol types: `codex-rs/app-server-protocol/src/protocol/v2.rs` + 312 +- Core steer: `codex-rs/core/src/codex.rs` (L3377-3406) + 313 +- Turn state: `codex-rs/core/src/state/turn.rs` (L77-163) + 314 +- Task loop drain: `codex-rs/core/src/codex.rs` (L4970-5000) + 315 +- Follow-up flag: `codex-rs/core/src/codex.rs` (L6364) + 316 +- Task completion: `codex-rs/core/src/tasks/mod.rs` (L190-230) + 317 +- App server handler: `codex-rs/app-server/src/codex_message_processor.rs` + 318 +- TUI queue widget: `codex-rs/tui/src/bottom_pane/queued_user_messages.rs` + 319 +- TUI composer: `codex-rs/tui/src/public_widgets/composer_input.rs` + 320 + +``` + + +### 📄 `packages/app/src/components/prompt-input.tsx` + +**Type:** TypeScript React Component ⚛️ + +```diff +@@ -1,6 +1,6 @@ + 1 1 import { useFilteredList } from "@opencode-ai/ui/hooks" + 2 2 import { showToast } from "@opencode-ai/ui/toast" + 3 -import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" + 3 +import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" + 4 4 import { createStore } from "solid-js/store" + 5 5 import { createFocusSignal } from "@solid-primitives/active-element" + 6 6 import { useLocal } from "@/context/local" +@@ -215,6 +215,7 @@ export const PromptInput: Component = (props) => { +215 215 }, +216 216 ) +217 217 const working = createMemo(() => status()?.type !== "idle") + 218 + const steerQueue = createMemo(() => sync.data.steer_queue[params.id ?? ""] ?? []) +218 219 const imageAttachments = createMemo(() => +219 220 prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), +220 221 ) +@@ -987,9 +988,37 @@ export const PromptInput: Component = (props) => { +987 988 } +988 989 } +989 990 +990 - // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input +991 - // and should always insert a newline regardless of composition state + 991 + // Handle Shift+Enter: when working with text, send as "steer" (inject mid-turn) +992 992 if (event.key === "Enter" && event.shiftKey) { + 993 + if (working() && params.id && store.mode === "normal") { + 994 + const text = prompt + 995 + .current() + 996 + .map((p) => ("content" in p ? p.content : "")) + 997 + .join("") + 998 + .trim() + 999 + if (text.length > 0) { + 1000 + event.preventDefault() + 1001 + const sessionID = params.id + 1002 + fetch(`${sdk.url}/session/${sessionID}/steer`, { + 1003 + method: "POST", + 1004 + headers: { "Content-Type": "application/json" }, + 1005 + body: JSON.stringify({ text, mode: "steer" }), + 1006 + }).catch(() => { + 1007 + showToast({ + 1008 + title: "Failed to steer", + 1009 + description: "Could not inject message into current turn", + 1010 + }) + 1011 + }) + 1012 + prompt.reset() + 1013 + clearEditor() + 1014 + showToast({ + 1015 + title: "Steering", + 1016 + description: "Will be injected at the next step of the current turn", + 1017 + }) + 1018 + return + 1019 + } + 1020 + } + 1021 + // Default: insert newline when not working +993 1022 addPart({ type: "text", content: "\n", start: 0, end: 0 }) +994 1023 event.preventDefault() +995 1024 return +@@ -1056,6 +1085,38 @@ export const PromptInput: Component = (props) => { +1056 1085 +1057 1086 // Note: Shift+Enter is handled earlier, before IME check +1058 1087 if (event.key === "Enter" && !event.shiftKey) { + 1088 + // When busy: Enter queues a steer message instead of normal submit + 1089 + if (working() && params.id && store.mode === "normal") { + 1090 + const text = prompt + 1091 + .current() + 1092 + .map((p) => ("content" in p ? p.content : "")) + 1093 + .join("") + 1094 + .trim() + 1095 + if (text.length > 0) { + 1096 + event.preventDefault() + 1097 + const sessionID = params.id + 1098 + fetch(`${sdk.url}/session/${sessionID}/steer`, { + 1099 + method: "POST", + 1100 + headers: { "Content-Type": "application/json" }, + 1101 + body: JSON.stringify({ text }), + 1102 + }).catch(() => { + 1103 + showToast({ + 1104 + title: "Failed to queue message", + 1105 + description: "Could not steer the session", + 1106 + }) + 1107 + }) + 1108 + prompt.reset() + 1109 + clearEditor() + 1110 + showToast({ + 1111 + title: "Message queued", + 1112 + description: "Will be injected when the model finishes its current step", + 1113 + }) + 1114 + return + 1115 + } + 1116 + // Empty text while working → abort + 1117 + abort() + 1118 + return + 1119 + } +1059 1120 handleSubmit(event) +1060 1121 } +1061 1122 } +@@ -1113,6 +1174,35 @@ export const PromptInput: Component = (props) => { +1113 1174 onRemove={removeImageAttachment} +1114 1175 removeLabel={language.t("prompt.attachment.remove")} +1115 1176 /> + 1177 + 0}> + 1178 +
+ 1179 +
+ 1180 + + 1181 + Queued ({steerQueue().length}) + 1182 +
+ 1183 + + 1184 + {(item) => ( + 1185 +
+ 1186 + {item.text} + 1187 + + 1201 +
+ 1202 + )} + 1203 +
+ 1204 +
+ 1205 +
+1116 1206
{ +@@ -1206,37 +1296,135 @@ export const PromptInput: Component = (props) => { +1206 1296 +1207 1297 +1208 1298 +1209 - +1214 - +1215 -
+1216 - {language.t("prompt.action.stop")} +1217 - {language.t("common.key.esc")} +1218 -
+1219 -
+1220 - +1221 -
+1222 - {language.t("prompt.action.send")} +1223 - +1224 -
+1225 -
+1226 - +1227 - } +1228 - > +1229 - +1239 -
+ 1299 + + 1300 + + 1301 +
+ 1302 + + 1306 +
+ 1307 + Queue + 1308 + + 1309 +
+ 1310 + Send after current response finishes + 1311 +
+ 1312 + } + 1313 + > + 1314 + { + 1322 + const text = prompt + 1323 + .current() + 1324 + .map((p) => ("content" in p ? p.content : "")) + 1325 + .join("") + 1326 + .trim() + 1327 + if (!text || !params.id) return + 1328 + fetch(`${sdk.url}/session/${params.id}/steer`, { + 1329 + method: "POST", + 1330 + headers: { "Content-Type": "application/json" }, + 1331 + body: JSON.stringify({ text, mode: "queue" }), + 1332 + }).catch(() => { + 1333 + showToast({ + 1334 + title: "Failed to queue message", + 1335 + description: "Could not queue the message", + 1336 + }) + 1337 + }) + 1338 + prompt.reset() + 1339 + clearEditor() + 1340 + showToast({ + 1341 + title: "Message queued", + 1342 + description: "Will be sent when the model finishes its current response", + 1343 + }) + 1344 + }} + 1345 + /> + 1346 + + 1347 + + 1351 +
+ 1352 + Steer + 1353 + ⇧⏎ + 1354 +
+ 1355 + Inject into current turn at next step + 1356 +
+ 1357 + } + 1358 + > + 1359 + { + 1367 + const text = prompt + 1368 + .current() + 1369 + .map((p) => ("content" in p ? p.content : "")) + 1370 + .join("") + 1371 + .trim() + 1372 + if (!text || !params.id) return + 1373 + fetch(`${sdk.url}/session/${params.id}/steer`, { + 1374 + method: "POST", + 1375 + headers: { "Content-Type": "application/json" }, + 1376 + body: JSON.stringify({ text, mode: "steer" }), + 1377 + }).catch(() => { + 1378 + showToast({ + 1379 + title: "Failed to steer", + 1380 + description: "Could not inject message into current turn", + 1381 + }) + 1382 + }) + 1383 + prompt.reset() + 1384 + clearEditor() + 1385 + showToast({ + 1386 + title: "Steering", + 1387 + description: "Will be injected at the next step of the current turn", + 1388 + }) + 1389 + }} + 1390 + /> + 1391 + + 1392 + + 1393 + + 1394 + + 1395 + + 1400 + + 1401 +
+ 1402 + {language.t("prompt.action.stop")} + 1403 + {language.t("common.key.esc")} + 1404 +
+ 1405 +
+ 1406 + + 1407 +
+ 1408 + {language.t("prompt.action.send")} + 1409 + + 1410 +
+ 1411 +
+ 1412 + + 1413 + } + 1414 + > + 1415 + + 1425 +
+ 1426 +
+ 1427 + +1240 1428 +1241 1429 +1242 1430 +1243 1431 + +``` + + +### 📄 `packages/app/src/context/global-sync/child-store.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -167,6 +167,7 @@ export function createChildStoreManager(input: { +167 167 session: [], +168 168 sessionTotal: 0, +169 169 session_status: {}, + 170 + steer_queue: {}, +170 171 session_diff: {}, +171 172 todo: {}, +172 173 permission: {}, +173 174 + +``` + + +### 📄 `packages/app/src/context/global-sync/event-reducer.test.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -75,6 +75,7 @@ const baseState = (input: Partial = {}) => + 75 75 todo: {}, + 76 76 permission: {}, + 77 77 question: {}, + 78 + steer_queue: {}, + 78 79 mcp: {}, + 79 80 lsp: [], + 80 81 vcs: undefined, + 81 82 + +``` + + +### 📄 `packages/app/src/context/global-sync/event-reducer.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -52,7 +52,8 @@ function cleanupSessionCaches( + 52 52 store.todo[sessionID] !== undefined || + 53 53 store.permission[sessionID] !== undefined || + 54 54 store.question[sessionID] !== undefined || + 55 - store.session_status[sessionID] !== undefined + 55 + store.session_status[sessionID] !== undefined || + 56 + store.steer_queue[sessionID] !== undefined + 56 57 setSessionTodo?.(sessionID, undefined) + 57 58 if (!hasAny) return + 58 59 setStore( +@@ -71,6 +72,7 @@ function cleanupSessionCaches( + 71 72 delete draft.permission[sessionID] + 72 73 delete draft.question[sessionID] + 73 74 delete draft.session_status[sessionID] + 75 + delete draft.steer_queue[sessionID] + 74 76 }), + 75 77 ) + 76 78 } +@@ -164,6 +166,11 @@ export function applyDirectoryEvent(input: { +164 166 input.setStore("session_status", props.sessionID, reconcile(props.status)) +165 167 break +166 168 } + 169 + case "session.queue.changed": { + 170 + const props = event.properties as { sessionID: string; queue: { id: string; text: string; time: number; mode: "queue" | "steer" }[] } + 171 + input.setStore("steer_queue", props.sessionID, reconcile(props.queue, { key: "id" })) + 172 + break + 173 + } +167 174 case "message.updated": { +168 175 const info = (event.properties as { info: Message }).info +169 176 const messages = input.store.message[info.sessionID] +170 177 + +``` + + +### 📄 `packages/app/src/context/global-sync/types.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -46,6 +46,9 @@ export type State = { + 46 46 session_status: { + 47 47 [sessionID: string]: SessionStatus + 48 48 } + 49 + steer_queue: { + 50 + [sessionID: string]: { id: string; text: string; time: number; mode: "queue" | "steer" }[] + 51 + } + 49 52 session_diff: { + 50 53 [sessionID: string]: FileDiff[] + 51 54 } + 52 55 + +``` + + +### 📄 `packages/opencode/src/server/routes/session.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -14,6 +14,7 @@ import { Agent } from "../../agent/agent" + 14 14 import { Snapshot } from "@/snapshot" + 15 15 import { Log } from "../../util/log" + 16 16 import { PermissionNext } from "@/permission/next" + 17 +import { SessionSteer } from "@/session/steer" + 17 18 import { errors } from "../error" + 18 19 import { lazy } from "../../util/lazy" + 19 20 +@@ -933,6 +934,122 @@ export const SessionRoutes = lazy(() => +933 934 return c.json(session) +934 935 }, +935 936 ) + 937 + .post( + 938 + "/:sessionID/steer", + 939 + describeRoute({ + 940 + summary: "Steer session", + 941 + description: + 942 + "Push a message into the session's pending input buffer. If the session is busy, the message will be injected at the next agentic loop boundary. If idle, it is queued for the next turn.", + 943 + operationId: "session.steer", + 944 + responses: { + 945 + 200: { + 946 + description: "Queued message", + 947 + content: { + 948 + "application/json": { + 949 + schema: resolver( + 950 + z.object({ + 951 + id: z.string(), + 952 + text: z.string(), + 953 + time: z.number(), + 954 + mode: z.enum(["queue", "steer"]), + 955 + }), + 956 + ), + 957 + }, + 958 + }, + 959 + }, + 960 + ...errors(400, 404), + 961 + }, + 962 + }), + 963 + validator( + 964 + "param", + 965 + z.object({ + 966 + sessionID: z.string().meta({ description: "Session ID" }), + 967 + }), + 968 + ), + 969 + validator( + 970 + "json", + 971 + z.object({ + 972 + text: z.string().min(1).meta({ description: "The message text to inject" }), + 973 + mode: z.enum(["queue", "steer"]).optional().default("queue").meta({ description: "queue waits for turn end, steer injects mid-turn" }), + 974 + }), + 975 + ), + 976 + async (c) => { + 977 + const sessionID = c.req.valid("param").sessionID + 978 + const body = c.req.valid("json") + 979 + const entry = SessionSteer.push(sessionID, body.text, body.mode) + 980 + return c.json(entry) + 981 + }, + 982 + ) + 983 + .get( + 984 + "/:sessionID/steer", + 985 + describeRoute({ + 986 + summary: "Get steer queue", + 987 + description: "List all pending steered messages for a session without draining the queue.", + 988 + operationId: "session.steer.list", + 989 + responses: { + 990 + 200: { + 991 + description: "Pending steered messages", + 992 + content: { + 993 + "application/json": { + 994 + schema: resolver( + 995 + z.array( + 996 + z.object({ + 997 + id: z.string(), + 998 + text: z.string(), + 999 + time: z.number(), + 1000 + mode: z.enum(["queue", "steer"]), + 1001 + }), + 1002 + ), + 1003 + ), + 1004 + }, + 1005 + }, + 1006 + }, + 1007 + ...errors(400, 404), + 1008 + }, + 1009 + }), + 1010 + validator( + 1011 + "param", + 1012 + z.object({ + 1013 + sessionID: z.string().meta({ description: "Session ID" }), + 1014 + }), + 1015 + ), + 1016 + async (c) => { + 1017 + const sessionID = c.req.valid("param").sessionID + 1018 + const queue = SessionSteer.list(sessionID) + 1019 + return c.json(queue) + 1020 + }, + 1021 + ) + 1022 + .delete( + 1023 + "/:sessionID/steer/:steerID", + 1024 + describeRoute({ + 1025 + summary: "Remove steered message", + 1026 + description: "Remove a specific queued steered message by its ID before it gets injected.", + 1027 + operationId: "session.steer.remove", + 1028 + responses: { + 1029 + 200: { + 1030 + description: "Whether the message was found and removed", + 1031 + content: { + 1032 + "application/json": { + 1033 + schema: resolver(z.boolean()), + 1034 + }, + 1035 + }, + 1036 + }, + 1037 + ...errors(400, 404), + 1038 + }, + 1039 + }), + 1040 + validator( + 1041 + "param", + 1042 + z.object({ + 1043 + sessionID: z.string().meta({ description: "Session ID" }), + 1044 + steerID: z.string().meta({ description: "Steer message ID" }), + 1045 + }), + 1046 + ), + 1047 + async (c) => { + 1048 + const params = c.req.valid("param") + 1049 + const removed = SessionSteer.remove(params.sessionID, params.steerID) + 1050 + return c.json(removed) + 1051 + }, + 1052 + ) +936 1053 .post( +937 1054 "/:sessionID/permissions/:permissionID", +938 1055 describeRoute({ +939 1056 + +``` + + +### 📄 `packages/opencode/src/session/prompt.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -45,6 +45,7 @@ import { LLM } from "./llm" + 45 45 import { iife } from "@/util/iife" + 46 46 import { Shell } from "@/shell/shell" + 47 47 import { Truncate } from "@/tool/truncation" + 48 +import { SessionSteer } from "./steer" + 48 49 + 49 50 // @ts-ignore + 50 51 globalThis.AI_SDK_LOG_WARNINGS = false +@@ -320,6 +321,56 @@ export namespace SessionPrompt { +320 321 !["tool-calls", "unknown"].includes(lastAssistant.finish) && +321 322 lastUser.id < lastAssistant.id +322 323 ) { + 324 + // Check for "steer" mode messages — these inject mid-turn at loop + 325 + // boundaries. "queue" mode messages wait until the turn fully ends. + 326 + const steered = SessionSteer.takeByMode(sessionID, "steer") + 327 + if (steered.length > 0) { + 328 + log.info("steer: injecting pending input", { sessionID, count: steered.length }) + 329 + const text = steered.map((m) => m.text).join("\n\n") + 330 + const steerMsg: MessageV2.User = { + 331 + id: Identifier.ascending("message"), + 332 + sessionID, + 333 + role: "user", + 334 + time: { created: Date.now() }, + 335 + agent: lastUser.agent, + 336 + model: lastUser.model, + 337 + } + 338 + await Session.updateMessage(steerMsg) + 339 + await Session.updatePart({ + 340 + id: Identifier.ascending("part"), + 341 + messageID: steerMsg.id, + 342 + sessionID, + 343 + type: "text", + 344 + text, + 345 + } satisfies MessageV2.TextPart) + 346 + continue + 347 + } + 348 + + 349 + // Turn is finished. Drain "queue" mode messages and auto-submit + 350 + // them as new user messages so the model starts a fresh turn. + 351 + const queued = SessionSteer.takeByMode(sessionID, "queue") + 352 + if (queued.length > 0) { + 353 + log.info("steer: auto-submitting queued input", { sessionID, count: queued.length }) + 354 + const text = queued.map((m) => m.text).join("\n\n") + 355 + const queueMsg: MessageV2.User = { + 356 + id: Identifier.ascending("message"), + 357 + sessionID, + 358 + role: "user", + 359 + time: { created: Date.now() }, + 360 + agent: lastUser.agent, + 361 + model: lastUser.model, + 362 + } + 363 + await Session.updateMessage(queueMsg) + 364 + await Session.updatePart({ + 365 + id: Identifier.ascending("part"), + 366 + messageID: queueMsg.id, + 367 + sessionID, + 368 + type: "text", + 369 + text, + 370 + } satisfies MessageV2.TextPart) + 371 + continue + 372 + } + 373 + +323 374 log.info("exiting loop", { sessionID }) +324 375 break +325 376 } +326 377 + +``` + + +### ✨ `packages/opencode/src/session/steer.ts` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** TypeScript Source File 📘 + +```diff +@@ -0,0 +1,127 @@ + 1 +import { Bus } from "../bus" + 2 +import { BusEvent } from "../bus/bus-event" + 3 +import { Instance } from "../project/instance" + 4 +import { Log } from "../util/log" + 5 +import z from "zod" + 6 + + 7 +export namespace SessionSteer { + 8 + const log = Log.create({ service: "session.steer" }) + 9 + + 10 + export type Mode = "queue" | "steer" + 11 + + 12 + const QueuedMessageSchema = z.object({ + 13 + id: z.string(), + 14 + text: z.string(), + 15 + time: z.number(), + 16 + mode: z.enum(["queue", "steer"]), + 17 + }) + 18 + + 19 + export const Event = { + 20 + QueueChanged: BusEvent.define( + 21 + "session.queue.changed", + 22 + z.object({ + 23 + sessionID: z.string(), + 24 + queue: z.array(QueuedMessageSchema), + 25 + }), + 26 + ), + 27 + } + 28 + + 29 + export interface QueuedMessage { + 30 + id: string + 31 + text: string + 32 + time: number + 33 + mode: Mode + 34 + } + 35 + + 36 + interface SteerState { + 37 + pending: QueuedMessage[] + 38 + } + 39 + + 40 + const state = Instance.state( + 41 + () => { + 42 + const data: Record = {} + 43 + return data + 44 + }, + 45 + async () => {}, + 46 + ) + 47 + + 48 + function ensure(sessionID: string): SteerState { + 49 + const s = state() + 50 + if (!s[sessionID]) s[sessionID] = { pending: [] } + 51 + return s[sessionID] + 52 + } + 53 + + 54 + /** Push a message into the pending buffer for an active session. */ + 55 + export function push(sessionID: string, text: string, mode: Mode = "queue"): QueuedMessage { + 56 + const entry: QueuedMessage = { + 57 + id: crypto.randomUUID(), + 58 + text, + 59 + time: Date.now(), + 60 + mode, + 61 + } + 62 + const s = ensure(sessionID) + 63 + s.pending.push(entry) + 64 + log.info("steer.push", { sessionID, id: entry.id, queueLength: s.pending.length }) + 65 + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + 66 + return entry + 67 + } + 68 + + 69 + /** Drain all pending messages and return them. Clears the buffer. */ + 70 + export function take(sessionID: string): QueuedMessage[] { + 71 + const s = state()[sessionID] + 72 + if (!s || s.pending.length === 0) return [] + 73 + const result = s.pending.splice(0) + 74 + log.info("steer.take", { sessionID, count: result.length }) + 75 + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + 76 + return result + 77 + } + 78 + + 79 + /** Drain only messages matching the given mode. Leaves other messages in the buffer. */ + 80 + export function takeByMode(sessionID: string, mode: Mode): QueuedMessage[] { + 81 + const s = state()[sessionID] + 82 + if (!s || s.pending.length === 0) return [] + 83 + const matched: QueuedMessage[] = [] + 84 + const remaining: QueuedMessage[] = [] + 85 + for (const m of s.pending) { + 86 + if (m.mode === mode) matched.push(m) + 87 + else remaining.push(m) + 88 + } + 89 + if (matched.length === 0) return [] + 90 + s.pending = remaining + 91 + log.info("steer.takeByMode", { sessionID, mode, count: matched.length }) + 92 + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + 93 + return matched + 94 + } + 95 + + 96 + /** Check if there's pending steered input for a session. */ + 97 + export function has(sessionID: string): boolean { + 98 + const s = state()[sessionID] + 99 + return !!s && s.pending.length > 0 + 100 + } + 101 + + 102 + /** Get the current queue without draining. */ + 103 + export function list(sessionID: string): QueuedMessage[] { + 104 + return state()[sessionID]?.pending ?? [] + 105 + } + 106 + + 107 + /** Remove a specific queued message by id. */ + 108 + export function remove(sessionID: string, id: string): boolean { + 109 + const s = state()[sessionID] + 110 + if (!s) return false + 111 + const idx = s.pending.findIndex((m) => m.id === id) + 112 + if (idx === -1) return false + 113 + s.pending.splice(idx, 1) + 114 + log.info("steer.remove", { sessionID, id }) + 115 + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + 116 + return true + 117 + } + 118 + + 119 + /** Clear all pending messages for a session. */ + 120 + export function clear(sessionID: string) { + 121 + const s = state()[sessionID] + 122 + if (!s || s.pending.length === 0) return + 123 + s.pending.length = 0 + 124 + log.info("steer.clear", { sessionID }) + 125 + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + 126 + } + 127 +} + 128 + +``` + + +### ✨ `packages/opencode/test/session/steer.test.ts` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** TypeScript Source File 📘 + +```diff +@@ -0,0 +1,235 @@ + 1 +import { describe, expect, test, beforeEach } from "bun:test" + 2 +import path from "path" + 3 +import { SessionSteer } from "../../src/session/steer" + 4 +import { Instance } from "../../src/project/instance" + 5 +import { Log } from "../../src/util/log" + 6 + + 7 +const projectRoot = path.join(__dirname, "../..") + 8 +const SESSION = "session_test_steer_001" + 9 +Log.init({ print: false }) + 10 + + 11 +/** Helper to run a test function inside Instance.provide context */ + 12 +function withInstance(fn: () => void | Promise) { + 13 + return Instance.provide({ + 14 + directory: projectRoot, + 15 + fn: async () => { + 16 + await fn() + 17 + }, + 18 + }) + 19 +} + 20 + + 21 +describe("SessionSteer", () => { + 22 + describe("push", () => { + 23 + test("creates a queued message with default mode 'queue'", async () => { + 24 + await withInstance(() => { + 25 + SessionSteer.clear(SESSION) + 26 + const msg = SessionSteer.push(SESSION, "hello") + 27 + expect(msg.text).toBe("hello") + 28 + expect(msg.mode).toBe("queue") + 29 + expect(msg.id).toBeTruthy() + 30 + expect(msg.time).toBeGreaterThan(0) + 31 + }) + 32 + }) + 33 + + 34 + test("accepts explicit mode 'steer'", async () => { + 35 + await withInstance(() => { + 36 + SessionSteer.clear(SESSION) + 37 + const msg = SessionSteer.push(SESSION, "redirect", "steer") + 38 + expect(msg.text).toBe("redirect") + 39 + expect(msg.mode).toBe("steer") + 40 + }) + 41 + }) + 42 + + 43 + test("accepts explicit mode 'queue'", async () => { + 44 + await withInstance(() => { + 45 + SessionSteer.clear(SESSION) + 46 + const msg = SessionSteer.push(SESSION, "later", "queue") + 47 + expect(msg.mode).toBe("queue") + 48 + }) + 49 + }) + 50 + }) + 51 + + 52 + describe("take", () => { + 53 + test("drains all messages regardless of mode", async () => { + 54 + await withInstance(() => { + 55 + SessionSteer.clear(SESSION) + 56 + SessionSteer.push(SESSION, "a", "queue") + 57 + SessionSteer.push(SESSION, "b", "steer") + 58 + SessionSteer.push(SESSION, "c", "queue") + 59 + + 60 + const taken = SessionSteer.take(SESSION) + 61 + expect(taken).toHaveLength(3) + 62 + expect(taken.map((m) => m.text)).toEqual(["a", "b", "c"]) + 63 + expect(SessionSteer.list(SESSION)).toHaveLength(0) + 64 + }) + 65 + }) + 66 + + 67 + test("returns empty array when no messages", async () => { + 68 + await withInstance(() => { + 69 + SessionSteer.clear(SESSION) + 70 + expect(SessionSteer.take(SESSION)).toEqual([]) + 71 + }) + 72 + }) + 73 + }) + 74 + + 75 + describe("takeByMode", () => { + 76 + test("drains only 'steer' messages, leaving 'queue' messages", async () => { + 77 + await withInstance(() => { + 78 + SessionSteer.clear(SESSION) + 79 + SessionSteer.push(SESSION, "queued-1", "queue") + 80 + SessionSteer.push(SESSION, "steer-1", "steer") + 81 + SessionSteer.push(SESSION, "queued-2", "queue") + 82 + SessionSteer.push(SESSION, "steer-2", "steer") + 83 + + 84 + const steered = SessionSteer.takeByMode(SESSION, "steer") + 85 + expect(steered).toHaveLength(2) + 86 + expect(steered.map((m) => m.text)).toEqual(["steer-1", "steer-2"]) + 87 + + 88 + const remaining = SessionSteer.list(SESSION) + 89 + expect(remaining).toHaveLength(2) + 90 + expect(remaining.map((m) => m.text)).toEqual(["queued-1", "queued-2"]) + 91 + }) + 92 + }) + 93 + + 94 + test("drains only 'queue' messages, leaving 'steer' messages", async () => { + 95 + await withInstance(() => { + 96 + SessionSteer.clear(SESSION) + 97 + SessionSteer.push(SESSION, "queued-1", "queue") + 98 + SessionSteer.push(SESSION, "steer-1", "steer") + 99 + SessionSteer.push(SESSION, "queued-2", "queue") + 100 + + 101 + const queued = SessionSteer.takeByMode(SESSION, "queue") + 102 + expect(queued).toHaveLength(2) + 103 + expect(queued.map((m) => m.text)).toEqual(["queued-1", "queued-2"]) + 104 + + 105 + const remaining = SessionSteer.list(SESSION) + 106 + expect(remaining).toHaveLength(1) + 107 + expect(remaining[0].text).toBe("steer-1") + 108 + }) + 109 + }) + 110 + + 111 + test("returns empty when no messages match mode", async () => { + 112 + await withInstance(() => { + 113 + SessionSteer.clear(SESSION) + 114 + SessionSteer.push(SESSION, "queued", "queue") + 115 + const steered = SessionSteer.takeByMode(SESSION, "steer") + 116 + expect(steered).toEqual([]) + 117 + expect(SessionSteer.list(SESSION)).toHaveLength(1) + 118 + }) + 119 + }) + 120 + + 121 + test("returns empty when buffer is empty", async () => { + 122 + await withInstance(() => { + 123 + SessionSteer.clear(SESSION) + 124 + expect(SessionSteer.takeByMode(SESSION, "steer")).toEqual([]) + 125 + expect(SessionSteer.takeByMode(SESSION, "queue")).toEqual([]) + 126 + }) + 127 + }) + 128 + + 129 + test("sequential takeByMode drains both modes completely", async () => { + 130 + await withInstance(() => { + 131 + SessionSteer.clear(SESSION) + 132 + SessionSteer.push(SESSION, "s1", "steer") + 133 + SessionSteer.push(SESSION, "q1", "queue") + 134 + SessionSteer.push(SESSION, "s2", "steer") + 135 + SessionSteer.push(SESSION, "q2", "queue") + 136 + + 137 + const steered = SessionSteer.takeByMode(SESSION, "steer") + 138 + expect(steered).toHaveLength(2) + 139 + + 140 + const queued = SessionSteer.takeByMode(SESSION, "queue") + 141 + expect(queued).toHaveLength(2) + 142 + + 143 + expect(SessionSteer.has(SESSION)).toBe(false) + 144 + expect(SessionSteer.list(SESSION)).toHaveLength(0) + 145 + }) + 146 + }) + 147 + }) + 148 + + 149 + describe("has", () => { + 150 + test("returns false for empty session", async () => { + 151 + await withInstance(() => { + 152 + SessionSteer.clear(SESSION) + 153 + expect(SessionSteer.has(SESSION)).toBe(false) + 154 + }) + 155 + }) + 156 + + 157 + test("returns true after push", async () => { + 158 + await withInstance(() => { + 159 + SessionSteer.clear(SESSION) + 160 + SessionSteer.push(SESSION, "test") + 161 + expect(SessionSteer.has(SESSION)).toBe(true) + 162 + }) + 163 + }) + 164 + + 165 + test("returns false after take drains all", async () => { + 166 + await withInstance(() => { + 167 + SessionSteer.clear(SESSION) + 168 + SessionSteer.push(SESSION, "test") + 169 + SessionSteer.take(SESSION) + 170 + expect(SessionSteer.has(SESSION)).toBe(false) + 171 + }) + 172 + }) + 173 + + 174 + test("returns true when takeByMode leaves remaining", async () => { + 175 + await withInstance(() => { + 176 + SessionSteer.clear(SESSION) + 177 + SessionSteer.push(SESSION, "q", "queue") + 178 + SessionSteer.takeByMode(SESSION, "steer") + 179 + expect(SessionSteer.has(SESSION)).toBe(true) + 180 + }) + 181 + }) + 182 + }) + 183 + + 184 + describe("list", () => { + 185 + test("returns current queue without draining", async () => { + 186 + await withInstance(() => { + 187 + SessionSteer.clear(SESSION) + 188 + SessionSteer.push(SESSION, "a", "queue") + 189 + SessionSteer.push(SESSION, "b", "steer") + 190 + + 191 + const first = SessionSteer.list(SESSION) + 192 + expect(first).toHaveLength(2) + 193 + + 194 + const second = SessionSteer.list(SESSION) + 195 + expect(second).toHaveLength(2) + 196 + }) + 197 + }) + 198 + }) + 199 + + 200 + describe("remove", () => { + 201 + test("removes specific message by id", async () => { + 202 + await withInstance(() => { + 203 + SessionSteer.clear(SESSION) + 204 + const msg = SessionSteer.push(SESSION, "target", "steer") + 205 + SessionSteer.push(SESSION, "keep", "queue") + 206 + + 207 + const removed = SessionSteer.remove(SESSION, msg.id) + 208 + expect(removed).toBe(true) + 209 + expect(SessionSteer.list(SESSION)).toHaveLength(1) + 210 + expect(SessionSteer.list(SESSION)[0].text).toBe("keep") + 211 + }) + 212 + }) + 213 + + 214 + test("returns false for non-existent id", async () => { + 215 + await withInstance(() => { + 216 + SessionSteer.clear(SESSION) + 217 + SessionSteer.push(SESSION, "test") + 218 + expect(SessionSteer.remove(SESSION, "nonexistent")).toBe(false) + 219 + }) + 220 + }) + 221 + }) + 222 + + 223 + describe("clear", () => { + 224 + test("removes all pending messages", async () => { + 225 + await withInstance(() => { + 226 + SessionSteer.clear(SESSION) + 227 + SessionSteer.push(SESSION, "a", "queue") + 228 + SessionSteer.push(SESSION, "b", "steer") + 229 + SessionSteer.clear(SESSION) + 230 + expect(SessionSteer.has(SESSION)).toBe(false) + 231 + expect(SessionSteer.list(SESSION)).toHaveLength(0) + 232 + }) + 233 + }) + 234 + }) + 235 +}) + 236 + +``` + +## 🤖 Comprehensive Review Checklist + +### ✅ Code Quality & Standards +- [ ] **Syntax & Formatting**: Consistent indentation, proper spacing +- [ ] **Naming Conventions**: Clear, descriptive variable/function names +- [ ] **Code Structure**: Logical organization, appropriate function size +- [ ] **Documentation**: Clear comments explaining complex logic +- [ ] **Type Safety**: Proper typing (if applicable) + +### 🔍 Logic & Functionality +- [ ] **Algorithm Correctness**: Logic implements requirements correctly +- [ ] **Edge Case Handling**: Boundary conditions properly addressed +- [ ] **Error Handling**: Appropriate try-catch blocks and error messages +- [ ] **Performance**: Efficient algorithms, no unnecessary loops +- [ ] **Memory Management**: Proper cleanup, no memory leaks + +### 🐛 Potential Issues & Bugs +- [ ] **Runtime Errors**: No null/undefined dereferencing +- [ ] **Type Mismatches**: Consistent data types throughout +- [ ] **Race Conditions**: Proper async/await handling +- [ ] **Resource Leaks**: Event listeners, timers properly cleaned up +- [ ] **Off-by-one Errors**: Array/loop bounds correctly handled + +### 🔒 Security Considerations +- [ ] **Input Validation**: User inputs properly sanitized +- [ ] **XSS Prevention**: No unsafe HTML injection +- [ ] **Authentication**: Proper access controls if applicable +- [ ] **Data Exposure**: No sensitive information in logs/client +- [ ] **Dependency Security**: No known vulnerable packages + +### 📱 User Experience & Accessibility +- [ ] **Responsive Design**: Works on different screen sizes +- [ ] **Loading States**: Proper feedback during operations +- [ ] **Error Messages**: User-friendly error communication +- [ ] **Accessibility**: ARIA labels, keyboard navigation +- [ ] **Performance**: Fast loading, smooth interactions + +### 💡 Improvement Suggestions + +#### Code Organization +- [ ] Consider extracting complex logic into separate functions +- [ ] Evaluate if constants should be moved to configuration +- [ ] Check for code duplication opportunities + +#### Performance Optimizations +- [ ] Identify opportunities for memoization +- [ ] Consider lazy loading for heavy operations +- [ ] Evaluate database query efficiency (if applicable) + +#### Testing Recommendations +- [ ] Unit tests for core functionality +- [ ] Integration tests for API endpoints +- [ ] Edge case testing scenarios + +#### Documentation Needs +- [ ] API documentation updates +- [ ] Code comments for complex algorithms +- [ ] README updates if public interfaces changed + +### 📝 Review Notes +*Add your specific feedback, suggestions, and observations here:* + +--- +*Individual file review generated by AI Visual Code Review v2.0* +*Generated: 2026-02-26T10:33:24.938Z* diff --git a/docs/09-temp/cline-subagent-research.md b/docs/09-temp/cline-subagent-research.md new file mode 100644 index 000000000000..76d7684e9929 --- /dev/null +++ b/docs/09-temp/cline-subagent-research.md @@ -0,0 +1,66 @@ +# Research: Cline Subagent Architecture + +**Date:** 2026-02-24 +**Status:** TODO — pick up in next session + +## Research Questions + +1. How does Cline form subagents? How does the AI decide how many to create? +2. What task distribution strategy is used? How are tasks assigned to each subagent? +3. How is context shared between parent agent and subagents? +4. How are subagent outputs aggregated back into the main conversation? +5. What happens when a subagent task errors? Error handling and recovery. +6. How could this inspire improvements to opencode's existing subagent system? + +## Key References + +- **CLI Subagent Command Transformation**: `src/integrations/cli-subagents/subagent_command.ts` + - `isSubagentCommand()` — identifies simplified cline commands + - `transformClineCommand()` — injects `--json -y` flags for autonomous execution + +- **Agent Client Protocol (ACP)**: `cli/src/acp/AcpAgent.ts` + - Bridges ClineAgent with AgentSideConnection for stdio-based communication + - Handles permission requests, forwards session events + +- **ClineAgent**: `cli/src/agent/ClineAgent.ts` + - Implements ACP agent interface + - Translates ACP requests into core Controller operations + - Manages authentication, session modes, processes user prompts + +- **Message Translator**: `cli/src/agent/messageTranslator.ts` + - Converts ClineMessage objects to ACP SessionUpdate messages + - Computes deltas for streaming (avoids duplicate content) + +## CodeWiki References + +- https://codewiki.google/github.com/cline/cline#cli-subagent-command-transformation +- https://codewiki.google/github.com/cline/cline#command-line-interface-cli-functionality +- https://codewiki.google/github.com/cline/cline#agent-client-protocol-acp-integration-for-external-control + +## Comparison with OpenCode's Subagent System + +OpenCode already has subagents (`TaskTool` in `packages/opencode/src/tool/task.ts`): +- Subagents are spawned via the `task` tool +- Each subagent gets its own child session +- Subagent types: explore, plan, general (configurable per agent) +- Results returned as tool output to parent session + +**Gaps to investigate:** +- Does Cline support parallel subagents? (OpenCode does via plan mode Phase 1) +- How does Cline's ACP protocol compare to opencode's Bus event system? +- Can we adopt Cline's streaming delta pattern for subagent updates? + +## Tonight's Session Summary (2026-02-24, 2:37 AM - 4:57 AM) + +### 6 PRs Submitted to opencode (sst/opencode): +1. **#14820** — Streaming content duplication fix (global-sdk.tsx voided Set) +2. **#14821** — Font size settings (CSS vars + terminal + UI stepper) +3. **#14826** — ContextOverflowError auto-recovery (processor.ts) +4. **#14827** — Prune before compaction (prompt.ts) +5. **#14831** — Context usage card with compact button (session-context-tab.tsx) +6. **#14835** — Wide mode setting (full-width chat toggle) + +### Issues Created: +- #14822, #14823, #14824, #14825, #14830, #14834 + +### All branches merged into `origin/dev` on fork (PrakharMNNIT/opencode) diff --git a/docs/09-temp/codex-queue-steer-architecture.md b/docs/09-temp/codex-queue-steer-architecture.md new file mode 100644 index 000000000000..f2012489aa4f --- /dev/null +++ b/docs/09-temp/codex-queue-steer-architecture.md @@ -0,0 +1,319 @@ +# Codex Queue/Steer Architecture Analysis + +> Deep-dive into OpenAI Codex CLI's queue/steer mechanism for mid-turn user interaction. +> Source: `references/codex/` submodule + +--- + +## Overview + +Codex implements a **dual-input model** that lets users interact with the agent **during** an active turn, not just between turns: + +| Action | Keybinding | Behavior | When Turn Active | +|--------|-----------|----------|-----------------| +| **Queue** | `Enter` | Enqueue message for next turn boundary | Message waits in queue, displayed in UI | +| **Steer** | `⌘Enter` / `Enter` (steer-mode) | Inject input into active turn immediately | Message sent to model in current context | + +--- + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────┐ +│ TUI Layer (tui/src/) │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ChatComposer │ │ +│ │ Enter → InputResult::Submitted (steer) │ │ +│ │ Tab → InputResult::Queued │ │ +│ └─────────────────┬───────────────────────┘ │ +│ │ │ +│ ┌─────────────────▼───────────────────────┐ │ +│ │ QueuedUserMessages widget │ │ +│ │ Shows queued messages with "↳" prefix │ │ +│ │ Alt+Up to pop back into composer │ │ +│ └─────────────────────────────────────────┘ │ +└────────────────────┬────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────┐ +│ App Server Protocol (app-server-protocol/) │ +│ │ +│ turn/start → TurnStartParams (new turn) │ +│ turn/steer → TurnSteerParams (mid-turn) │ +│ │ +│ TurnSteerParams { │ +│ thread_id: String, │ +│ input: Vec, │ +│ expected_turn_id: String, // guard │ +│ } │ +│ │ +│ TurnSteerResponse { │ +│ turn_id: String, // confirms active turn │ +│ } │ +└────────────────────┬────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────┐ +│ App Server (app-server/src/) │ +│ codex_message_processor.rs │ +│ │ +│ async fn turn_steer(&self, req_id, params) { │ +│ let thread = load_thread(params.thread_id); │ +│ thread.steer_input( │ +│ mapped_items, │ +│ Some(¶ms.expected_turn_id) │ +│ ); │ +│ // Returns turn_id or error: │ +│ // "no active turn to steer" │ +│ } │ +└────────────────────┬────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────┐ +│ Core Engine (core/src/codex.rs) │ +│ │ +│ Session::steer_input(input, expected_turn_id) │ +│ 1. Validate input not empty │ +│ 2. Lock active_turn mutex │ +│ 3. Verify active turn exists │ +│ 4. Check expected_turn_id matches │ +│ 5. Lock turn_state │ +│ 6. push_pending_input(input) ← KEY STEP │ +│ 7. Return active turn_id │ +└────────────────────┬────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────┐ +│ Turn State (core/src/state/turn.rs) │ +│ │ +│ struct TurnState { │ +│ pending_input: Vec, │ +│ } │ +│ │ +│ push_pending_input(item) → appends to vec │ +│ take_pending_input() → drains vec │ +│ has_pending_input() → checks non-empty │ +└────────────────────┬────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────┐ +│ Task Loop (core/src/codex.rs ~L4970) │ +│ │ +│ loop { │ +│ // At each iteration, drain pending input │ +│ let pending = sess.get_pending_input().await; │ +│ if !pending.is_empty() { │ +│ // Record as conversation items │ +│ // → injected into model context │ +│ for item in pending { │ +│ record_user_prompt_and_emit_turn_item(); │ +│ } │ +│ } │ +│ // ... send to model, process response ... │ +│ // On ResponseEvent::Completed: │ +│ needs_follow_up |= has_pending_input(); │ +│ // If follow_up needed → loop continues │ +│ } │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## Core Mechanism: `steer_input` + +The heart of steer is `Session::steer_input()` in `core/src/codex.rs`: + +```rust +pub async fn steer_input( + &self, + input: Vec, + expected_turn_id: Option<&str>, +) -> Result { + if input.is_empty() { + return Err(SteerInputError::EmptyInput); + } + let mut active = self.active_turn.lock().await; + let Some(active_turn) = active.as_mut() else { + return Err(SteerInputError::NoActiveTurn(input)); + }; + let Some((active_turn_id, _)) = active_turn.tasks.first() else { + return Err(SteerInputError::NoActiveTurn(input)); + }; + if let Some(expected_turn_id) = expected_turn_id + && expected_turn_id != active_turn_id + { + return Err(SteerInputError::ExpectedTurnMismatch { + expected: expected_turn_id.to_string(), + actual: active_turn_id.clone(), + }); + } + let mut turn_state = active_turn.turn_state.lock().await; + turn_state.push_pending_input(input.into()); + Ok(active_turn_id.clone()) +} +``` + +### Key Design Decisions + +1. **Non-blocking injection**: `steer_input` just pushes to a `Vec` — it doesn't interrupt or cancel the model. The model's active response completes naturally. + +2. **Consumption at loop boundary**: The task loop checks `pending_input` at the **top of each iteration**. After the model finishes a response, if pending input exists, it gets recorded as conversation items and the model is called again with the updated context. + +3. **`needs_follow_up` flag**: When a model response completes (`ResponseEvent::Completed`), if there's pending input, the loop sets `needs_follow_up = true` and continues instead of ending the turn. + +4. **Turn ID validation**: The `expected_turn_id` field prevents race conditions — the steer request fails if the turn has changed between the user pressing Enter and the server processing the request. + +--- + +## Error Types + +```rust +pub enum SteerInputError { + NoActiveTurn(Vec), // No model turn running + ExpectedTurnMismatch { // Turn changed since request + expected: String, + actual: String, + }, + EmptyInput, // Nothing to inject +} +``` + +When `NoActiveTurn` occurs, the app-server falls back — the input that failed to steer gets queued for the next `turn/start`. + +--- + +## Queue vs Steer: Detailed Comparison + +### Queue (Tab / Enter in legacy mode) + +1. User types message, presses Tab (or Enter in non-steer mode) +2. TUI returns `InputResult::Queued { text, text_elements }` +3. Message stored in `QueuedUserMessages.messages: Vec` +4. Rendered in UI with `↳` prefix, dimmed/italic +5. User can pop with Alt+Up to edit +6. When current turn completes → queued messages become the next `turn/start` + +### Steer (Enter in steer mode / ⌘Enter) + +1. User types message, presses Enter +2. TUI returns `InputResult::Submitted { text, text_elements }` +3. App sends `turn/steer` RPC to server +4. Server calls `thread.steer_input()` → pushes to `pending_input` +5. Model's current response continues to completion +6. At next task loop iteration, pending input is drained and recorded +7. Model sees the user's steer message in context → generates follow-up +8. **All within the same turn** — no new turn boundary + +### Critical Difference + +| Aspect | Queue | Steer | +|--------|-------|-------| +| **Timing** | After turn ends | During active turn | +| **Turn boundary** | Creates new turn | Same turn continues | +| **Model sees it** | On next turn start | At next loop iteration | +| **Cancels response** | No (waits) | No (appends to context) | +| **UI display** | Queued messages widget | Injected into chat transcript | +| **Fallback** | N/A | Falls back to queue if no active turn | + +--- + +## Turn Lifecycle with Steer + +``` +Turn Start (user submits prompt) + │ + ├─→ Model generates response... + │ │ + │ │ ← User presses Enter (steer) + │ │ → steer_input() pushes to pending_input + │ │ + │ ▼ + │ Response completes + │ │ + │ ├─→ has_pending_input()? YES + │ │ → needs_follow_up = true + │ │ + │ ▼ + │ Loop continues → drain pending_input + │ → Record steered message as conversation item + │ → Model sees: [original prompt, response, steered message] + │ → Model generates new response with full context + │ │ + │ ├─→ has_pending_input()? NO + │ │ → needs_follow_up = false + │ ▼ + │ Turn Complete + │ + └─→ Queued messages (if any) → next turn/start +``` + +--- + +## Turn Completion & Leftover Input + +When a task finishes (`task_finished()` in `core/src/tasks/mod.rs`): + +```rust +// 1. Lock active turn +let mut active = self.active_turn.lock().await; +// 2. Take any remaining pending input +let pending_input = ts.take_pending_input(); +// 3. Clear active turn +*active = None; +// 4. Record leftover input as conversation items +if !pending_input.is_empty() { + record_conversation_items(&turn_context, &pending_response_items); +} +// 5. Emit TurnComplete event +``` + +This ensures steered input is **never lost** — even if the turn ends before the pending input could be consumed by the model loop. + +--- + +## Feature Flag: `steer_enabled` + +Steer is gated behind `Feature::Steer` in the TUI: + +```rust +// When steer_enabled == true: +// Enter → Submitted (steer immediately) +// Tab → Queued (wait for turn end) +// +// When steer_enabled == false (legacy): +// Enter → Queued +// Tab → Queued +``` + +--- + +## Implications for OpenCode + +### What OpenCode Currently Has +- Session/turn model with `processor.ts` handling model interaction +- Parallel agents via `task.ts` tool +- No mid-turn input injection + +### What Queue/Steer Would Add +1. **Pending input buffer** on the session/turn state +2. **Steer RPC** that pushes to the buffer while model is running +3. **Loop-boundary drain** that checks for pending input after each model response +4. **Follow-up continuation** instead of ending the turn when input is pending +5. **UI queue widget** showing messages waiting for the current turn to finish +6. **Fallback path**: steer → queue if no active turn + +### Key Implementation Points +- `steer_input()` is a **lock-based, non-cancelling** approach — it doesn't abort the model stream +- Pending input is consumed at the **top of the agentic loop**, not mid-stream +- The model sees steered input as additional conversation items on its next iteration +- `expected_turn_id` prevents stale steer requests from affecting wrong turns +- Queued messages are a purely UI-side concept until they become a `turn/start` + +--- + +## References + +- Protocol types: `codex-rs/app-server-protocol/src/protocol/v2.rs` +- Core steer: `codex-rs/core/src/codex.rs` (L3377-3406) +- Turn state: `codex-rs/core/src/state/turn.rs` (L77-163) +- Task loop drain: `codex-rs/core/src/codex.rs` (L4970-5000) +- Follow-up flag: `codex-rs/core/src/codex.rs` (L6364) +- Task completion: `codex-rs/core/src/tasks/mod.rs` (L190-230) +- App server handler: `codex-rs/app-server/src/codex_message_processor.rs` +- TUI queue widget: `codex-rs/tui/src/bottom_pane/queued_user_messages.rs` +- TUI composer: `codex-rs/tui/src/public_widgets/composer_input.rs` diff --git a/docs/09-temp/escape-key-ux-research.md b/docs/09-temp/escape-key-ux-research.md new file mode 100644 index 000000000000..37575a6243bb --- /dev/null +++ b/docs/09-temp/escape-key-ux-research.md @@ -0,0 +1,32 @@ +# Research: Escape Key Cancel UX + +**Date:** 2026-02-24 +**Status:** TODO — brainstorm in next session + +## Problem +Pressing Escape accidentally during AI response immediately stops the response with no confirmation. No visual feedback in chat that response was interrupted. + +## Current Behavior +- Escape → immediately cancels the LLM response +- Shows a notification/warning toast +- No visual indicator in the chat thread that the message was interrupted +- No confirmation dialog before cancelling + +## User's Proposed Improvements +1. **Confirmation before cancel** — Alert/dialog: "Are you sure you want to interrupt?" +2. **Visual interruption indicator** — Show in chat that the message was interrupted (red line, badge, etc.) +3. **Better UX** — Maybe double-tap Escape to cancel, or Escape once to show warning + +## Files to Investigate +- `packages/app/src/pages/session.tsx` — handleKeyDown, Escape handling +- `packages/app/src/components/prompt-input.tsx` — Escape key handling in input +- `packages/opencode/src/session/prompt.ts` — cancel() function +- `packages/ui/src/components/message-part.tsx` — interrupted state rendering +- `packages/app/src/pages/session/use-session-commands.tsx` — session.cancel command + +## Design Questions +1. Should Escape require double-tap? (like VS Code terminal) +2. Should there be a small "Esc to cancel" indicator during streaming? +3. Should interrupted messages have a visual indicator (red border/badge)? +4. Should there be an "undo cancel" option (resume if possible)? +5. How does Cline/Cursor handle this? diff --git a/docs/09-temp/issues.md b/docs/09-temp/issues.md new file mode 100644 index 000000000000..263f75fe81c8 --- /dev/null +++ b/docs/09-temp/issues.md @@ -0,0 +1,599 @@ +# OpenCode — Parallel Agent & Retry Storm Issues + +> **Created**: 2025-02-25 +> **Source**: Combined RCA by Cline + Antigravity +> **Status**: Approved for implementation + +--- + +## Issue #1: `processor-max-retries` — Infinite Retry Loop in processor.ts + +### Priority: P0 — Stop The Bleeding + +### What is the issue? +The session processor retries failed API calls in an infinite `while(true)` loop with **no maximum retry count**. When an error is classified as "retryable" by `retry.ts`, the processor will retry it forever — user observed **2,244 identical retries over 3.5 hours** before manual abort. + +### What is the bug? +`packages/opencode/src/session/processor.ts` line ~53 has a `while(true)` loop. When the catch block determines an error is retryable via `SessionRetry.retryable(error)`, it increments `attempt` and `continue`s the loop. There is **no guard** like `if (attempt >= MAX_RETRIES) break`. + +### Where it can happen? +- Any API call that returns a retryable error (transient network issues, rate limits, Bedrock context overflow misclassified as retryable) +- Most critically: Bedrock "prompt is too long" errors that get misclassified as retryable by the catch-all in `retry.ts` (see Issue #2) +- Affects both parent sessions and subagent sessions independently + +### What any agent needs to look for? +``` +File: packages/opencode/src/session/processor.ts +Location: The while(true) loop (~line 53) +Pattern: Look for the catch block that calls SessionRetry.retryable() and does `continue` +``` + +### How to make the fix? +Add a `MAX_RETRIES` constant and guard before the `continue`: + +```typescript +// At top of file or inside the function +const MAX_RETRIES = 10 + +// Inside the catch block, before `continue`: +if (attempt >= MAX_RETRIES) { + input.assistantMessage.error = { + name: "RetryLimitExceeded", + message: `Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`, + } + break +} +``` + +The error should be stored on `input.assistantMessage.error` so the session stops and the UI shows the error. Make sure the status is set to idle after breaking. + +### Testing +- Trigger a retryable error (e.g., rate limit) and verify it stops after 10 attempts +- Verify the error message appears in the session UI +- Verify the session status returns to "idle" (not stuck in "retry") + +--- + +## Issue #2: `bedrock-undefined-message` — error.ts Fails to Parse Bedrock Error Messages + +### Priority: P0 — Stop The Bleeding + +### What is the issue? +When Amazon Bedrock returns an API error (e.g., "prompt is too long"), the `message()` function in `error.ts` receives `e.message = "undefined"` (the literal string, not the JS undefined value). The function only checks for empty string `""`, so it passes `"undefined"` through to `isOverflow()`, which fails to match any overflow pattern. This means **Bedrock context overflow errors are never detected as overflow**, preventing compaction from triggering. + +### What is the bug? +`packages/opencode/src/provider/error.ts` function `message()` (~line 50-80): +```typescript +const msg = e.message +if (msg === "") { + if (e.responseBody) return e.responseBody + // ... +} +``` +When Bedrock SDK sets `e.message` to the literal string `"undefined"`, this check passes through. The actual error details are in `e.responseBody` but never extracted. + +### Where it can happen? +- Any Bedrock API call that returns an error (context overflow, validation errors, throttling) +- The Bedrock SDK wraps errors differently than the Anthropic direct SDK +- Specifically observed with "prompt is too long: 208845 tokens > 200000 maximum" errors + +### What any agent needs to look for? +``` +File: packages/opencode/src/provider/error.ts +Location: The message() function, specifically the `if (msg === "")` check +Also check: isOverflow() function and the OVERFLOW_PATTERNS regex +``` + +### How to make the fix? +Extend the empty-message check to also handle `"undefined"`: + +```typescript +function message(providerID: string, e: APICallError) { + return iife(() => { + const msg = e.message + if (msg === "" || msg === "undefined") { + if (e.responseBody) return e.responseBody + // ... rest of existing fallback logic + } + return msg + }) +} +``` + +This ensures the actual error body (which contains "prompt is too long") is used for overflow detection instead of the meaningless `"undefined"` string. + +### Testing +- Mock a Bedrock APICallError with `message: "undefined"` and `responseBody: "prompt is too long: 208845 tokens > 200000 maximum"` +- Verify `message()` returns the responseBody, not `"undefined"` +- Verify `isOverflow()` correctly detects the overflow pattern from the responseBody + +--- + +## Issue #3: `task-swallows-errors` — task.ts Silently Swallows Subagent Failures + +### Priority: P0 — Stop The Bleeding + +### What is the issue? +When a subagent (child session spawned by the `task` tool) fails with an error, the parent session shows it as **successfully completed with empty output**. The user sees a green ✅ checkmark for a task that actually errored. This is THE primary cause of "failures not reflected in main chat." + +### What is the bug? +`packages/opencode/src/tool/task.ts` line ~145: +```typescript +const result = await SessionPrompt.prompt({...}) +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" +``` + +`result.info` contains an `.error` field when the child session errored (set by `processor.ts` at `input.assistantMessage.error = error`). But `task.ts` **never checks `result.info.error`** — it only looks for text parts. When the child errored, there are no text parts, so `text = ""`, and the parent receives `\n\n` as a "successful" empty result. + +### Where it can happen? +- Any subagent failure: context overflow, API error, tool execution error, rate limit +- Parallel subagents: if 1 of 3 subagents fails, parent sees 3 "completed" tasks with one having empty output +- The parent LLM may then hallucinate that the task completed or silently move on + +### What any agent needs to look for? +``` +File: packages/opencode/src/tool/task.ts +Location: After the `SessionPrompt.prompt()` call, before building the output +Pattern: result.info should have an error field — check result.info type definition +Also check: packages/opencode/src/session/prompt.ts for the return type of prompt() +``` + +### How to make the fix? +Add an error check immediately after the `SessionPrompt.prompt()` call: + +```typescript +const result = await SessionPrompt.prompt({...}) + +// Check if child session errored +if (result.info.error) { + const error = result.info.error + const msg = error.message ?? error.name ?? "Subagent task failed" + return { + title: params.description, + metadata: { sessionId: session.id, model }, + output: [ + `task_id: ${session.id}`, + "", + "", + `ERROR: ${msg}`, + `The subtask encountered an error and could not complete.`, + "", + ].join("\n"), + } +} + +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" +``` + +**Important**: Check the actual type of `result.info` to use proper typing instead of `(result.info as any).error`. Look at how `processor.ts` sets the error on `input.assistantMessage.error` to understand the shape. + +### Testing +- Trigger a subagent error (e.g., invalid tool call, context overflow) +- Verify the parent session shows "ERROR: ..." in the task result, not empty +- Verify the parent LLM receives the error and can report it to the user + +--- + +## Issue #4: `bedrock-context-cap` — Bedrock Provider Missing Context Limit Override + +### Priority: P0 — This Sprint + +### What is the issue? +The `models-snapshot.ts` file (auto-generated from models.dev) lists Claude Opus 4.6 on Bedrock with `context: 1,000,000`. This is the model's capability WITH the `context-1m` beta header. However, the Bedrock provider handler in `provider.ts` **never sends the 1M beta header**, so Bedrock actually enforces a 200K limit. The result: UI shows "20% context usage" when the user is actually at 100% of the real limit, and compaction never triggers. + +### What is the bug? +Two bugs combine: + +1. **`models-snapshot.ts`** lists Opus 4.6 Bedrock models at 1M context (reflects model capability, not runtime limit) +2. **`provider.ts`** `"amazon-bedrock"` handler has NO logic to: + - Send `additionalModelRequestFields: { anthropic_beta: ["context-1m-2025-08-07"] }` to enable 1M + - Override the context limit to 200K when 1M beta is NOT active + +**Affected models in snapshot**: +``` +amazon-bedrock / anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ +amazon-bedrock / us.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ +amazon-bedrock / eu.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ +amazon-bedrock / global.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ +``` + +All other Bedrock Claude models correctly show 200K. + +### Where it can happen? +- Any user running Claude Opus 4.6 via Amazon Bedrock +- Compaction threshold is calculated from `model.limit.context` → 1M → threshold ~900K +- Bedrock rejects at 200K → 700K token gap where compaction never fires but API always rejects +- Combined with Issue #1 (infinite retries), this causes the 3.5-hour freeze + +### What any agent needs to look for? +``` +File: packages/opencode/src/provider/provider.ts +Location: The "amazon-bedrock" entry in CUSTOM_LOADERS (~line 211) +Pattern: The returned object has options (providerOptions) and getModel() but NO context limit override +Also: Look at how compaction.ts uses model.limit.context (~line 33) +Also: Look at how Cline handles this — they use additionalModelRequestFields for Bedrock + +DO NOT edit models-snapshot.ts directly — it is auto-generated by build.ts +``` + +### How to make the fix? +**Option A (Recommended)**: Add provider-level context limit override in the model resolution logic. When provider is "amazon-bedrock" and model is Claude, cap context at 200K unless a 1M configuration is explicitly enabled. + +Look at where models are resolved and limits are applied. The fix should go in `provider.ts` where models are loaded/resolved, adding a context limit override: + +```typescript +// Inside amazon-bedrock handler or model resolution +if (providerID === "amazon-bedrock" && modelData.limit?.context > 200000) { + // Cap at 200K unless 1M beta is explicitly configured + modelData.limit.context = 200000 +} +``` + +**Option B (Future)**: Implement Cline's `:1m` suffix pattern — user explicitly opts into 1M context, which triggers adding `anthropic_beta: ["context-1m-2025-08-07"]` via `additionalModelRequestFields`. + +### Testing +- Configure Bedrock with Opus 4.6 +- Verify UI shows context limit as 200K (not 1M) +- Verify compaction triggers before hitting Bedrock's actual 200K limit +- Verify no "prompt is too long" errors during normal usage + +--- + +## Issue #5: `subagent-timeout` — task.ts Has No Execution Timeout + +### Priority: P0 — This Sprint + +### What is the issue? +The `task` tool calls `SessionPrompt.prompt()` with **no timeout or deadline**. If a subagent gets stuck (infinite retry storm, permission hang, or any other blocking issue), the parent tool call never resolves. The parent session appears frozen with a spinning "running" indicator forever. + +### What is the bug? +`packages/opencode/src/tool/task.ts`: +```typescript +const result = await SessionPrompt.prompt({ + messageID, + sessionID: session.id, + model: { modelID: model.modelID, providerID: model.providerID }, + agent: agent.name, + tools: { ... }, + parts: promptParts, +}) +// ← No timeout wrapper, no AbortController deadline +``` + +This Promise can hang indefinitely if the child session encounters: +- Infinite retry loop (Issue #1 before fix) +- Permission hang (Issue #6) +- Slow API responses that never complete + +### Where it can happen? +- Any subagent execution, but especially: + - When subagent hits context overflow with retries + - When subagent needs permission and user is watching parent + - When API provider is slow or unresponsive + +### What any agent needs to look for? +``` +File: packages/opencode/src/tool/task.ts +Location: The SessionPrompt.prompt() call +Pattern: Check if there's an AbortSignal or timeout mechanism available +Also check: How the abort signal flows from processor.ts → tool execution → task.ts +Also check: ctx parameter in execute() — does it carry an abort signal? +``` + +### How to make the fix? +Wrap the `SessionPrompt.prompt()` call with an AbortController timeout: + +```typescript +const timeout = 5 * 60 * 1000 // 5 minutes (configurable) +const controller = new AbortController() +const timer = setTimeout(() => controller.abort(), timeout) + +try { + const result = await SessionPrompt.prompt({ + // ... existing params ... + abort: controller.signal, // Pass abort signal if prompt() supports it + }) + clearTimeout(timer) + // ... process result ... +} catch (e) { + clearTimeout(timer) + if (controller.signal.aborted) { + return { + title: params.description, + metadata: { sessionId: session.id, model }, + output: `ERROR: Subtask timed out after ${timeout / 1000}s. The task may still be running in session ${session.id}.`, + } + } + throw e +} +``` + +Check if `SessionPrompt.prompt()` already accepts an `abort` parameter. If not, trace how `processor.ts` passes its abort signal and ensure the plumbing exists. + +### Testing +- Trigger a subagent that would hang (e.g., long-running task) +- Verify it times out after the configured deadline +- Verify the parent receives a timeout error message, not silent hang +- Verify the child session is properly cleaned up + +--- + +## Issue #6: `permission-abort` — next.ts Permission Promises Hang Forever in Subagents + +### Priority: P0 — This Sprint + +### What is the issue? +When a subagent's tool requires permission (e.g., file write, command execution), the permission prompt appears **only in the child session**. If the user is watching the parent session, they never see the prompt. The child session hangs forever waiting for permission, which blocks the parent's tool call. + +### What is the bug? +`packages/opencode/src/permission/next.ts` lines ~143-156: +```typescript +export function ask(input: AskInput) { + return new Promise((resolve, reject) => { + // ... sets up permission request ... + // NO abort signal listener + // NO timeout + // Promise resolves only when user explicitly grants/denies + }) +} +``` + +`grep -c "abort" next.ts` returns **0** — there is zero abort signal awareness in the entire file. + +### Where it can happen? +- Any subagent tool call that requires permission +- Parallel subagents: one hangs on permission → parent hangs → all other parallel results blocked +- Even with auto-approve policies, edge cases (new tools, destructive operations) may still prompt + +### What any agent needs to look for? +``` +File: packages/opencode/src/permission/next.ts +Location: The ask() function (exported, ~line 143) +Pattern: The Promise constructor — no abort/timeout handling +Also check: How ask() is called from tool execution context +Also check: Whether an AbortSignal is available in the call chain +Also check: packages/opencode/src/session/prompt.ts for where permissions are requested +``` + +### How to make the fix? +Add AbortSignal support to the `ask()` function: + +```typescript +export function ask(input: AskInput & { abort?: AbortSignal }) { + return new Promise((resolve, reject) => { + // Check if already aborted + if (input.abort?.aborted) { + return reject(new Error("Permission request aborted")) + } + + // Listen for abort + const onAbort = () => { + reject(new Error("Permission request aborted")) + } + input.abort?.addEventListener("abort", onAbort, { once: true }) + + // ... existing permission logic ... + // Clean up abort listener in resolve/reject paths + }) +} +``` + +**Important**: The abort signal must be plumbed from `processor.ts` through the tool execution chain to `next.ts`. Trace the call path: +``` +processor.ts (has abort) → tool execution → specific tool → permission check → next.ts ask() +``` + +### Testing +- Trigger a subagent that needs permission +- Abort the parent session while permission is pending +- Verify the child permission promise rejects +- Verify the parent tool call resolves with an error (not hangs forever) + +--- + +## Issue #7: `retry-catch-all` — retry.ts Catch-All Makes All JSON Errors Retryable + +### Priority: P1 — Robustness + +### What is the issue? +The `retryable()` function in `retry.ts` has a catch-all at line ~96 that makes **any error with a parseable JSON response body** retryable. This means Bedrock 400 errors ("prompt is too long"), which should NOT be retried, get classified as retryable — fueling the infinite retry storm. + +### What is the bug? +`packages/opencode/src/session/retry.ts` line ~96: +```typescript +// After checking specific patterns (rate limit, overloaded, etc.)... +return JSON.stringify(json) // ← ANY remaining JSON error = retryable +``` + +The Bedrock "prompt is too long" error response is valid JSON with `"isRetryable": false` in the body, but the catch-all ignores this field and returns the body as a retryable error message. + +### Where it can happen? +- Any API error that returns a JSON response body +- Specifically: Bedrock validation errors (400), authentication errors, quota errors +- Combined with Issue #1 (no max retries), this creates infinite retry storms + +### What any agent needs to look for? +``` +File: packages/opencode/src/session/retry.ts +Location: The retryable() function, specifically the catch-all after all pattern checks +Pattern: The final `return JSON.stringify(json)` that runs for any unmatched JSON error +Also check: What specific patterns ARE checked before the catch-all +Also check: Whether the JSON body contains "isRetryable" or HTTP status fields +``` + +### How to make the fix? +Replace the blanket catch-all with HTTP status-aware classification: + +```typescript +// Instead of: return JSON.stringify(json) +// Use: +const status = (json as any).status ?? (json as any).statusCode +if (typeof status === "number" && status >= 400 && status < 500) { + // 4xx errors are client errors — NOT retryable (bad request, auth, not found, etc.) + return undefined +} +// 5xx and truly unknown → retryable (but capped by MAX_RETRIES from Issue #1) +return JSON.stringify(json) +``` + +Also check for the `isRetryable` field that Bedrock includes: +```typescript +if ((json as any).isRetryable === false) return undefined +``` + +**Note**: This fix is SAFER when combined with Issue #1 (MAX_RETRIES), since any misclassification is bounded by the retry cap. + +### Testing +- Send a Bedrock 400 "prompt is too long" error → verify NOT retried +- Send a 429 rate limit error → verify IS retried +- Send a 500 server error → verify IS retried (up to MAX_RETRIES) +- Send a JSON error with `isRetryable: false` → verify NOT retried + +--- + +## Issue #8: `tool-error-metadata` — processor.ts Drops Metadata on Tool Errors + +### Priority: P1 — Robustness + +### What is the issue? +When a tool execution errors, the tool-error handler in `processor.ts` rebuilds the tool state but **drops the `title` and `metadata` fields**. This means the UI loses the tool's display name and any navigation metadata (like `sessionId` for subagent links). + +### What is the bug? +`packages/opencode/src/session/processor.ts` lines ~207-218, the `"tool-error"` case: +```typescript +case "tool-error": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + status: "error", + input: value.input ?? match.state.input, + error: (value.error as any).toString(), + // ❌ Missing: title: match.state.title, + // ❌ Missing: metadata: match.state.metadata, + time: { + start: match.state.time.start, + end: Date.now(), + }, + }, + }) + } +} +``` + +### Where it can happen? +- Any tool that errors during execution +- Most visible for task tool errors — the `sessionId` metadata (used for navigating to child sessions) is lost +- Also affects batch tool parts and any tool with custom title/metadata + +### What any agent needs to look for? +``` +File: packages/opencode/src/session/processor.ts +Location: The "tool-error" case in the stream event handler +Pattern: Compare the "tool-error" state update with the "tool-result" state update +The "tool-result" case preserves title and metadata, but "tool-error" does not +``` + +### How to make the fix? +Add `title` and `metadata` preservation to the error state: + +```typescript +case "tool-error": { + const match = toolcalls[value.toolCallId] + if (match && match.state.status === "running") { + await Session.updatePart({ + ...match, + state: { + status: "error", + input: value.input ?? match.state.input, + error: (value.error as any).toString(), + title: match.state.title, // ← ADD + metadata: match.state.metadata, // ← ADD + time: { + start: match.state.time.start, + end: Date.now(), + }, + }, + }) + } +} +``` + +### Testing +- Trigger a tool error (e.g., file read on non-existent path) +- Verify the error part in the UI shows the tool title +- Trigger a subagent error → verify the sessionId metadata is preserved in the error part + +--- + +## Issue #9: `batch-error-details` — batch.ts Output Lacks Per-Tool Error Details + +### Priority: P2 — Nice to Have + +### What is the issue? +When batch tool calls fail, the output summary only says `"Executed X/Y tools successfully. Z failed."` without including **which tools failed or why**. The LLM receiving this output cannot diagnose or intelligently retry the failures. + +### What is the bug? +`packages/opencode/src/tool/batch.ts` output message: +```typescript +const outputMessage = failedCalls > 0 + ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` + : `All ${successfulCalls} tools executed successfully.` +``` + +Note: Individual tool-call parts ARE written to the database with their errors (via `Session.updatePart` in the catch block), so the UI shows them. But the **summary message returned to the LLM** lacks details. + +### Where it can happen? +- Any batch execution where one or more tools fail +- The LLM sees the summary but not the individual error details +- Can cause the LLM to blindly retry the same failing operations + +### What any agent needs to look for? +``` +File: packages/opencode/src/tool/batch.ts +Location: The outputMessage construction after Promise.all results +Pattern: The results array has { success, tool, error? } for each call +``` + +### How to make the fix? +Include per-tool error details in the output: + +```typescript +const outputMessage = failedCalls > 0 + ? [ + `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`, + "", + "Failed tools:", + ...results + .filter((r) => !r.success) + .map((r) => `- ${r.tool}: ${r.error instanceof Error ? r.error.message : String(r.error)}`), + ].join("\n") + : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` +``` + +### Testing +- Execute a batch with one intentionally failing tool (e.g., read non-existent file) +- Verify the output includes the tool name and error message +- Verify the LLM can see which tool failed and why + +--- + +## Implementation Order + +``` +✅ DONE — Commit 3670d5f2f: + #1 processor-max-retries → MAX_RETRIES=10 cap + #2 bedrock-undefined-message → "undefined" → responseBody fallback + #3 task-swallows-errors → result.info.error check in task.ts + #8 tool-error-metadata → metadata preserved on tool-error + +✅ DONE — Commit a8758b20f: + #4 bedrock-context-cap → 200K cap in both fromModelsDevModel + config path + #7 retry-catch-all → isRetryable:false + 4xx status guards + #9 batch-error-details → per-tool error details in output + +REMAINING (P0 — Needs deep plumbing): + #5 subagent-timeout → Hung subagent prevention + #6 permission-abort → Permission hang prevention +``` diff --git a/docs/09-temp/ui-overhaul-plan.md b/docs/09-temp/ui-overhaul-plan.md new file mode 100644 index 000000000000..70d205acaf1a --- /dev/null +++ b/docs/09-temp/ui-overhaul-plan.md @@ -0,0 +1,94 @@ +# UI/UX Overhaul Plan — OpenCode Desktop + +**Date:** 2026-02-24 +**Status:** ✅ PHASE 1 COMPLETE + +## User Requirements +- UI looks "very bad" — needs visual polish and tactile feel ✅ +- More themes and theme customization ✅ +- Better UI rendering quality ✅ +- Font size ✅ (fixed in PR #14821) +- Zoom in/out ✅ (already works via Cmd+/-/0) +- Wide mode ✅ (added in PR #14835) +- More UI settings options needed (future) + +## Phase 1 Changes (Completed) + +### 1. Font Rendering (`base.css`) +- Added `-webkit-font-smoothing: antialiased` for crisp text on macOS +- Added `-moz-osx-font-smoothing: grayscale` for Firefox +- Added `text-rendering: optimizeLegibility` for better kerning +- Added `scroll-behavior: smooth` for smooth scrolling + +### 2. Animation System (`animations.css`) +- Added CSS custom property easing tokens (`--ease-out-expo`, `--ease-spring`, etc.) +- Added duration tokens (`--duration-instant` through `--duration-slower`) +- Added new keyframes: `fadeIn`, `fadeInScale`, `slideInFromRight/Left/Bottom` +- Added `subtleGlow` for focus states, `shimmer` for loading, `spin` +- Halved stagger delay (50ms instead of 100ms) for snappier text reveals +- Added `prefers-reduced-motion: reduce` media query for accessibility + +### 3. Utilities (`utilities.css`) +- Added `::selection` styling with theme-aware color +- Added global transition defaults for all interactive elements +- Added `:focus-visible` ring with theme color +- Added thin scrollbar styling for scroll views +- Suppressed focus ring for components that handle their own + +### 4. Shadow/Depth System (`theme.css`) +- Refined `--shadow-xs` with slightly stronger presence +- Added new `--shadow-sm` level for subtle elevation +- Enhanced `--shadow-md` with deeper, more dramatic depth +- Enhanced `--shadow-lg` with softer, more premium feel +- Added new `--shadow-xl` for maximum elevation (modals, floating panels) + +### 5. Button Micro-Interactions (`button.css`) +- Added explicit transition for bg-color, border, box-shadow, transform, opacity +- Primary: hover now lifts with `--shadow-sm`, active presses with `scale(0.98)` +- Ghost: icon color transitions on hover, active presses with `scale(0.97)` +- Secondary: hover adds border shadow hint, active presses +- Disabled states now use `opacity: 0.6` for clearer visual feedback + +### 6. Card Polish (`card.css`) +- Upgraded border-radius from `--radius-md` to `--radius-lg` +- Added full transition for bg-color, border-color, box-shadow, transform +- Hover state now shows subtle border highlight and `--shadow-xs` elevation + +### 7. Dialog Animations (`dialog.css`) +- Overlay now uses `backdrop-filter: blur(4px)` for frosted glass effect +- Overlay opacity increased from 0.2 to 0.35 for better focus +- Content now uses combined `scale(0.96) + translateY(4px)` entrance +- Animation uses `cubic-bezier(0.16, 1, 0.3, 1)` expo-out for premium feel +- Added subtle 1px border ring on dialog content for depth definition +- Overlay entrance/exit now animated separately + +### 8. Icon Button Interactions (`icon-button.css`) +- Added explicit transitions for bg-color, box-shadow, transform +- Ghost variant: icon color now transitions on hover (to `--icon-hover`) +- Active state now scales to `0.92` for satisfying tactile press +- Icon SVG color now properly transitions through states +- Disabled state uses `opacity: 0.5` + +### 9. New Themes (3 premium additions) +- **Rosé Pine** — Dreamy, soft palette with purple/rose accents. Very popular community theme. +- **Kanagawa** — Japanese-inspired warm palette. Distinctive golden/purple tones based on "The Great Wave." +- **Everforest** — Calming green/earth tones nature-inspired palette. Easy on the eyes for long sessions. + +All themes include full light + dark variants with seeds, borders, surfaces, text, syntax highlighting, and markdown colors. + +## Files Modified +- `packages/ui/src/styles/base.css` — Font rendering +- `packages/ui/src/styles/animations.css` — Animation system +- `packages/ui/src/styles/utilities.css` — Selection, focus, transitions, scrollbars +- `packages/ui/src/styles/theme.css` — Shadow system +- `packages/ui/src/components/button.css` — Button interactions +- `packages/ui/src/components/card.css` — Card polish +- `packages/ui/src/components/dialog.css` — Dialog animations +- `packages/ui/src/components/icon-button.css` — Icon button interactions +- `packages/ui/src/theme/themes/rosepine.json` — NEW +- `packages/ui/src/theme/themes/kanagawa.json` — NEW +- `packages/ui/src/theme/themes/everforest.json` — NEW +- `packages/ui/src/theme/default-themes.ts` — Theme registration + +## Build Status +✅ `vite build` passes with zero errors (7.98s) diff --git a/docs/09-temp/ui-redesign-spec.md b/docs/09-temp/ui-redesign-spec.md new file mode 100644 index 000000000000..718b5ee2ecfb --- /dev/null +++ b/docs/09-temp/ui-redesign-spec.md @@ -0,0 +1,37 @@ +# UI Redesign Spec + +## Reference Design +See HTML mockup provided by user. Key elements: + +### Sidebar +- "New Session" button with icon, primary color border +- "RECENT CHATS" section header (uppercase, tracking-wider) +- Chat items with icon + title + timestamp +- "CONTEXT" section with file list +- Bottom: plan usage bar + +### Message Timeline +- Assistant: Robot icon (32x32 rounded square) + "OPENCODE AI" label (uppercase, primary color, bold) +- User: Timestamp + "You" label (accent-cyan color, bold) +- User message: Glass panel, rounded-2xl with rounded-tr-none + +### Thinking Block +- Collapsible `
` with: + - Cyan pulsing dot + "Thinking process..." text + - Expand/collapse arrow + - Mono font content with `>` prefix + - Border-top separator + +### Prompt Input +- Glass panel with backdrop-blur +- Model selector pills ("GPT-4o", "Web Search") +- Textarea +- Send button with primary color + glow shadow +- Bottom bar: keyboard shortcuts + sync status + +### Right Activity Bar +- Vertical icon strip: Extensions, Source Control, History +- Bottom: Settings + user avatar + +### Settings (from screenshot) +- Already looks reasonable, minor polish needed diff --git a/docs/plans/2025-02-26-aurora-design-system.md b/docs/plans/2025-02-26-aurora-design-system.md new file mode 100644 index 000000000000..9dd67f391618 --- /dev/null +++ b/docs/plans/2025-02-26-aurora-design-system.md @@ -0,0 +1,1977 @@ +# 🌌 Aurora Design System for opencode + +> **Vision**: "Code illuminated from within" +> +> A unified design language for opencode that creates an ethereal, digital-native interface where UI elements emit light rather than receive it. + +--- + +## Table of Contents + +1. [Design Vision Summary](#part-1-design-vision-summary) +2. [Design Approaches Explored](#part-2-design-approaches-explored) +3. [Color System](#part-3-color-system) +4. [Typography System](#part-4-typography-system) +5. [Spacing System](#part-5-spacing-system) +6. [Motion & Animation System](#part-6-motion--animation-system) +7. [Component Specifications](#part-7-component-specifications) +8. [TUI (Terminal) Component Translations](#part-8-tui-terminal-component-translations) +9. [Stitch Prompts for Visual Prototyping](#part-9-stitch-prompts-for-visual-prototyping) +10. [Final Summary & Implementation Guide](#part-10-final-summary--implementation-guide) +11. [Accessibility & Review Amendments](#part-11-accessibility--review-amendments) + +--- + +## Part 1: Design Vision Summary + +### Design Requirements Gathered + +| Aspect | Choice | +|--------|--------| +| **Scope** | Unified design language (Web Console + Terminal UI) | +| **Tone** | Luxury Minimal | +| **Color** | Dark-first luxury with luminous accents | +| **Motion** | Confident, tactile, functional | +| **Reference** | Future-forward (Tesla/Rivian interiors) | + +### Core Identity + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ opencode AURORA │ +│ │ +│ "Code illuminated from within" │ +│ │ +│ Not a tool that shows you code— │ +│ A window into a dimension where code IS light. │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Design Principles + +| Principle | Description | Implementation | +|-----------|-------------|----------------| +| **Light as Material** | UI elements emit light rather than receive it | Glows, gradients, luminous borders | +| **Depth through Transparency** | Layers visible through glassmorphism | backdrop-blur, low-opacity backgrounds | +| **Confident Motion** | Every animation serves purpose and feels physical | Spring physics, 200-300ms durations | +| **Chromatic Restraint** | Rich palette but used sparingly | Monochrome base, color for meaning | +| **Unified Language** | Same DNA across Web and TUI | Shared color tokens, adapted to medium | + +--- + +## Part 2: Design Approaches Explored + +Three design approaches were explored before settling on Aurora: + +### Approach A: "Carbon Fiber" — Industrial Luxury (Rejected) + +**Concept:** Premium materials meet precision engineering. Think machined aluminum bezels, carbon fiber textures, and surgical-grade steel accents. + +**Web Console:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ Background: Subtle carbon weave pattern with depth │ +│ Cards: Brushed metal finish with soft inner glow │ +│ Accent: Copper/rose gold highlights (warm against cold) │ +│ │ +│ 3D Elements: │ +│ • Cards tilt on hover (perspective transform) │ +│ • Depth shadows that respond to mouse position │ +│ • Metallic sheen that catches virtual "light" │ +│ │ +│ Motion: │ +│ • Spring-based button depressions (like mechanical keys) │ +│ • Smooth state transitions with mass/velocity physics │ +│ • Loading: Rotating machined bezel indicator │ +└─────────────────────────────────────────────────────────────┘ +``` + +**TUI Translation:** +``` +┌─ SESSION: Project Analysis ─────────────── ◈ ────┐ +│ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░ Processing... │ +│ │ +│ ╭──────────────────────────────────────────╮ │ +│ │ ◆ Analyzing codebase │ │ +│ │ └─ Found 127 TypeScript files │ │ +│ │ └─ Detected SolidJS framework │ │ +│ ╰──────────────────────────────────────────╯ │ +│ │ +│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ +│ Unicode: Heavy borders, diamond bullets │ +│ Colors: Warm copper (#B87333) on charcoal │ +└───────────────────────────────────────────────────┘ +``` + +**Pros:** +- Distinctive, memorable aesthetic +- Strong brand identity ("the tool that feels engineered") +- Warm accent prevents cold/sterile feeling + +**Cons:** +- Carbon texture could feel dated if not executed perfectly +- Copper might clash with some terminal color schemes +- More complex to implement subtle material effects + +**Verdict:** Too industrial - user wanted something more ethereal/digital, less physical materials + +--- + +### Approach B: "Aurora" — Digital Luminescence (SELECTED ✓) + +**Concept:** Pure light and energy. No physical materials—just gradients, glows, and luminous color that feels alive. Like looking at code through a prism of pure digital light. + +**Web Console:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ Background: Deep void black (#0A0A0F) with subtle │ +│ animated gradient aurora (very slow drift) │ +│ │ +│ Cards: Glassmorphism with luminous edge glow │ +│ bg: rgba(255,255,255,0.03) │ +│ border: gradient (cyan → purple → magenta) │ +│ backdrop-filter: blur(20px) │ +│ │ +│ Accent Colors (shifting spectrum): │ +│ • Primary: Electric Cyan (#00D4FF) │ +│ • Secondary: Soft Violet (#A78BFA) │ +│ • Tertiary: Rose (#FF6B9D) │ +│ │ +│ 3D Elements: │ +│ • Subtle glow pulses (like breathing light) │ +│ • Hover reveals inner luminescence │ +│ • Focus states bloom with soft radiance │ +│ • Depth through layered transparency, not shadows │ +│ │ +│ Motion: │ +│ • Smooth spring physics on all interactions │ +│ • Elements "float" with subtle parallax │ +│ • Loading: Gradient shimmer / aurora wave │ +│ • Transitions: Fade + scale with glow trail │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Light Theme Variant:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Background: Soft pearl (#FAFAFA) with subtle iridescence │ +│ Cards: Frosted glass with prismatic edge highlights │ +│ Accents: Deeper cyan, rich violet (contrast preserved) │ +│ Effect: "Daylight aurora" - colors visible but softer │ +└─────────────────────────────────────────────────────────────┘ +``` + +**TUI Translation:** +``` +╭───────────────────────────────────────────────────────╮ +│ ● opencode ◐ processing │ +├───────────────────────────────────────────────────────┤ +│ │ +│ ┃ Analyzing your codebase... │ +│ ┃ │ +│ ├─● packages/opencode/src/cli/ │ +│ │ ├─○ cmd/tui/app.tsx │ +│ │ ├─○ cmd/tui/context/theme.tsx │ +│ │ └─● cmd/tui/routes/session/ │ +│ │ └─○ index.tsx ← focus │ +│ │ │ +│ ╰─ Found 247 files in 3.2s │ +│ │ +│ ░░░░░░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ │ +╰───────────────────────────────────────────────────────╯ +│ Unicode: Rounded corners, thin lines, ● ○ bullets │ +│ Colors: Cyan/violet/magenta gradient hierarchy │ +│ Effect: "Glowing" text via bright-on-dark contrast │ +└───────────────────────────────────────────────────────┘ +``` + +**Key Differentiators:** +- **Depth through light, not shadow** — Elements glow from within rather than casting shadows +- **Living gradients** — Subtle color shifts that feel organic, not static +- **Ethereal presence** — UI feels like it exists in digital space, weightless + +**Pros:** +- Truly unique aesthetic (few tools look like this) +- Perfectly digital - no physical material metaphors +- Light/dark themes can share the same luminous DNA +- Scalable: subtle for everyday use, dramatic for hero moments + +**Cons:** +- Risk of "gaming aesthetic" if not carefully restrained +- Gradient animations need to be VERY subtle or becomes distracting +- Performance consideration for animated gradients + +**Verdict:** Selected as final direction - ethereal, digital, distinctive + +--- + +### Approach C: Not Developed + +Since Approach B (Aurora) was selected immediately, a third approach was not fully developed. + +--- + +## Part 3: Color System + +### Dark Theme (Primary) + +```css +/* ═══════════════════════════════════════════════════════════ + AURORA DARK — PRIMARY THEME + ═══════════════════════════════════════════════════════════ */ + +:root[data-theme="aurora-dark"] { + /* ─── VOID BACKGROUNDS ─── */ + --void-deepest: #050508; /* True dark, almost black */ + --void-deep: #0A0A0F; /* Primary background */ + --void-base: #0F0F14; /* Card backgrounds */ + --void-elevated: #14141A; /* Elevated surfaces */ + --void-hover: #1A1A22; /* Hover states */ + + /* ─── SURFACE GLASS ─── */ + --glass-subtle: rgba(255, 255, 255, 0.02); + --glass-light: rgba(255, 255, 255, 0.04); + --glass-medium: rgba(255, 255, 255, 0.06); + --glass-strong: rgba(255, 255, 255, 0.08); + + /* ─── LUMINOUS SPECTRUM ─── */ + --aurora-cyan: #00D4FF; /* Primary accent */ + --aurora-cyan-soft: #00A3CC; /* Cyan muted */ + --aurora-cyan-glow: rgba(0, 212, 255, 0.15); + + --aurora-violet: #A78BFA; /* Secondary accent */ + --aurora-violet-soft:#8B6ED9; + --aurora-violet-glow:rgba(167, 139, 250, 0.15); + + --aurora-rose: #FF6B9D; /* Tertiary / attention */ + --aurora-rose-soft: #D94A7B; + --aurora-rose-glow: rgba(255, 107, 157, 0.15); + + --aurora-amber: #FFBB33; /* Warning / warm accent */ + --aurora-green: #4ADE80; /* Success */ + --aurora-red: #F87171; /* Error / danger */ + + /* ─── TEXT HIERARCHY ─── */ + --text-primary: #F5F5F7; /* Bright white */ + --text-secondary: #A1A1AA; /* Muted gray */ + --text-tertiary: #71717A; /* Subtle gray */ + --text-disabled: #3F3F46; /* Very dim */ + + /* ─── BORDER LUMINANCE ─── */ + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.10); + --border-strong: rgba(255, 255, 255, 0.15); + --border-glow: var(--aurora-cyan); +} +``` + +### Light Theme (Secondary) + +```css +/* ═══════════════════════════════════════════════════════════ + AURORA LIGHT — DAYLIGHT VARIANT + ═══════════════════════════════════════════════════════════ */ + +:root[data-theme="aurora-light"] { + /* ─── PEARL BACKGROUNDS ─── */ + --void-deepest: #FFFFFF; + --void-deep: #FAFAFA; + --void-base: #F4F4F5; + --void-elevated: #FFFFFF; + --void-hover: #E4E4E7; + + /* ─── SURFACE FROST ─── */ + --glass-subtle: rgba(0, 0, 0, 0.02); + --glass-light: rgba(0, 0, 0, 0.04); + --glass-medium: rgba(0, 0, 0, 0.06); + --glass-strong: rgba(0, 0, 0, 0.08); + + /* ─── LUMINOUS SPECTRUM (deeper for contrast) ─── */ + --aurora-cyan: #0891B2; /* Deeper cyan */ + --aurora-cyan-soft: #06B6D4; + --aurora-cyan-glow: rgba(8, 145, 178, 0.10); + + --aurora-violet: #7C3AED; /* Richer violet */ + --aurora-violet-soft:#8B5CF6; + --aurora-violet-glow:rgba(124, 58, 237, 0.10); + + --aurora-rose: #DB2777; /* Deeper rose */ + --aurora-rose-soft: #EC4899; + --aurora-rose-glow: rgba(219, 39, 119, 0.10); + + --aurora-amber: #D97706; + --aurora-green: #16A34A; + --aurora-red: #DC2626; + + /* ─── TEXT HIERARCHY ─── */ + --text-primary: #18181B; + --text-secondary: #52525B; + --text-tertiary: #A1A1AA; + --text-disabled: #D4D4D8; + + /* ─── BORDER LUMINANCE ─── */ + --border-subtle: rgba(0, 0, 0, 0.06); + --border-default: rgba(0, 0, 0, 0.10); + --border-strong: rgba(0, 0, 0, 0.15); + --border-glow: var(--aurora-cyan); +} +``` + +### TUI Color Mapping + +```typescript +// Aurora theme for terminal (TUI) +export const auroraDark = { + // Backgrounds (mapped to closest ANSI/24-bit) + background: RGBA.fromHex("#0A0A0F"), + backgroundPanel: RGBA.fromHex("#0F0F14"), + backgroundElement: RGBA.fromHex("#14141A"), + backgroundMenu: RGBA.fromHex("#1A1A22"), + + // Aurora spectrum + primary: RGBA.fromHex("#00D4FF"), // Cyan + secondary: RGBA.fromHex("#A78BFA"), // Violet + accent: RGBA.fromHex("#FF6B9D"), // Rose + + // Semantic + success: RGBA.fromHex("#4ADE80"), + warning: RGBA.fromHex("#FFBB33"), + error: RGBA.fromHex("#F87171"), + info: RGBA.fromHex("#00D4FF"), + + // Text + text: RGBA.fromHex("#F5F5F7"), + textMuted: RGBA.fromHex("#A1A1AA"), + + // Borders + border: RGBA.fromHex("#1E1E26"), + borderActive: RGBA.fromHex("#00D4FF"), + borderSubtle: RGBA.fromHex("#14141A"), + + // Syntax highlighting (aurora-themed) + syntaxKeyword: RGBA.fromHex("#A78BFA"), // Violet + syntaxFunction: RGBA.fromHex("#00D4FF"), // Cyan + syntaxString: RGBA.fromHex("#4ADE80"), // Green + syntaxNumber: RGBA.fromHex("#FF6B9D"), // Rose + syntaxComment: RGBA.fromHex("#71717A"), // Muted + syntaxVariable: RGBA.fromHex("#F5F5F7"), // White + syntaxType: RGBA.fromHex("#FFBB33"), // Amber + syntaxOperator: RGBA.fromHex("#A1A1AA"), + syntaxPunctuation: RGBA.fromHex("#71717A"), + + // Diff colors + diffAdded: RGBA.fromHex("#4ADE80"), + diffRemoved: RGBA.fromHex("#F87171"), + diffAddedBg: RGBA.fromHex("#0D2818"), + diffRemovedBg: RGBA.fromHex("#2D1216"), +} +``` + +--- + +## Part 4: Typography System + +### Font Stack + +```css +/* ═══════════════════════════════════════════════════════════ + AURORA TYPOGRAPHY + ═══════════════════════════════════════════════════════════ */ + +:root { + /* ─── PRIMARY: Code & Interface ─── */ + --font-mono: "JetBrains Mono", "SF Mono", "Fira Code", + "Cascadia Code", monospace; + + /* ─── DISPLAY: Headers & Hero Text ─── */ + /* Option A: Geometric (Future-forward) */ + --font-display: "Geist", "Inter", "SF Pro Display", + system-ui, sans-serif; + + /* Option B: More distinctive (if we want stronger brand) */ + /* --font-display: "Space Grotesk", "Outfit", sans-serif; */ + + /* ─── BODY: Documentation & Long-form ─── */ + --font-body: "Inter", "SF Pro Text", system-ui, sans-serif; +} +``` + +### Type Scale + +```css +/* ─── MODULAR SCALE: 1.250 (Major Third) ─── */ + +:root { + --text-xs: 0.64rem; /* 10.24px - Labels, captions */ + --text-sm: 0.8rem; /* 12.8px - Small UI text */ + --text-base: 1rem; /* 16px - Body text */ + --text-md: 1.25rem; /* 20px - Large body */ + --text-lg: 1.563rem; /* 25px - Section headers */ + --text-xl: 1.953rem; /* 31.25px - Page headers */ + --text-2xl: 2.441rem; /* 39px - Hero subheads */ + --text-3xl: 3.052rem; /* 48.8px - Hero headlines */ + --text-4xl: 3.815rem; /* 61px - Display text */ + + /* ─── LINE HEIGHTS ─── */ + --leading-none: 1; + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 1.75; + + /* ─── LETTER SPACING ─── */ + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + + /* ─── FONT WEIGHTS ─── */ + --weight-normal: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; +} +``` + +### Typography Classes + +```css +/* ─── SEMANTIC TEXT STYLES ─── */ + +.text-display-hero { + font-family: var(--font-display); + font-size: var(--text-4xl); + font-weight: var(--weight-bold); + line-height: var(--leading-none); + letter-spacing: var(--tracking-tighter); +} + +.text-display-title { + font-family: var(--font-display); + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-tight); +} + +.text-heading-lg { + font-family: var(--font-display); + font-size: var(--text-xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); +} + +.text-heading-md { + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: var(--weight-medium); + line-height: var(--leading-snug); +} + +.text-body { + font-family: var(--font-body); + font-size: var(--text-base); + font-weight: var(--weight-normal); + line-height: var(--leading-relaxed); +} + +.text-body-sm { + font-family: var(--font-body); + font-size: var(--text-sm); + line-height: var(--leading-normal); +} + +.text-code { + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: var(--leading-normal); + font-variant-ligatures: contextual; /* Enable code ligatures */ +} + +.text-label { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; +} +``` + +--- + +## Part 5: Spacing System + +```css +/* ═══════════════════════════════════════════════════════════ + AURORA SPACING — 4px Base Grid + ═══════════════════════════════════════════════════════════ */ + +:root { + --space-px: 1px; + --space-0: 0; + --space-0.5: 0.125rem; /* 2px */ + --space-1: 0.25rem; /* 4px */ + --space-1.5: 0.375rem; /* 6px */ + --space-2: 0.5rem; /* 8px */ + --space-2.5: 0.625rem; /* 10px */ + --space-3: 0.75rem; /* 12px */ + --space-3.5: 0.875rem; /* 14px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-7: 1.75rem; /* 28px */ + --space-8: 2rem; /* 32px */ + --space-9: 2.25rem; /* 36px */ + --space-10: 2.5rem; /* 40px */ + --space-11: 2.75rem; /* 44px */ + --space-12: 3rem; /* 48px */ + --space-14: 3.5rem; /* 56px */ + --space-16: 4rem; /* 64px */ + --space-20: 5rem; /* 80px */ + --space-24: 6rem; /* 96px */ + --space-28: 7rem; /* 112px */ + --space-32: 8rem; /* 128px */ + + /* ─── SEMANTIC SPACING ─── */ + --gap-xs: var(--space-1); /* 4px - Inline elements */ + --gap-sm: var(--space-2); /* 8px - Tight groups */ + --gap-md: var(--space-4); /* 16px - Default gap */ + --gap-lg: var(--space-6); /* 24px - Section spacing */ + --gap-xl: var(--space-8); /* 32px - Major sections */ + --gap-2xl: var(--space-12); /* 48px - Page sections */ + + /* ─── COMPONENT PADDING ─── */ + --padding-button: var(--space-2) var(--space-4); + --padding-button-sm: var(--space-1.5) var(--space-3); + --padding-button-lg: var(--space-3) var(--space-6); + + --padding-card: var(--space-5); + --padding-card-sm: var(--space-3); + --padding-card-lg: var(--space-6); + + --padding-input: var(--space-2.5) var(--space-3); + + /* ─── BORDER RADIUS ─── */ + --radius-none: 0; + --radius-sm: 0.25rem; /* 4px - Small elements */ + --radius-md: 0.5rem; /* 8px - Buttons, inputs */ + --radius-lg: 0.75rem; /* 12px - Cards */ + --radius-xl: 1rem; /* 16px - Large cards */ + --radius-2xl: 1.5rem; /* 24px - Modals */ + --radius-full: 9999px; /* Pills, avatars */ +} +``` + +--- + +## Part 6: Motion & Animation System + +```css +/* ═══════════════════════════════════════════════════════════ + AURORA MOTION — Spring-Based Animation + ═══════════════════════════════════════════════════════════ */ + +:root { + /* ─── DURATION ─── */ + --duration-instant: 50ms; + --duration-fast: 150ms; + --duration-normal: 250ms; + --duration-slow: 350ms; + --duration-slower: 500ms; + --duration-slowest: 700ms; + + /* ─── EASING (CSS) ─── */ + --ease-linear: linear; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* ─── SPRING EASING (For Motion library) ─── */ + --spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --spring-smooth: cubic-bezier(0.22, 1, 0.36, 1); + --spring-snappy: cubic-bezier(0.16, 1, 0.3, 1); + + /* ─── SEMANTIC TRANSITIONS ─── */ + --transition-colors: color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out); + + --transition-opacity: opacity var(--duration-normal) var(--ease-out); + + --transition-transform: transform var(--duration-normal) var(--spring-smooth); + + --transition-all: all var(--duration-normal) var(--spring-smooth); + + --transition-glow: box-shadow var(--duration-slow) var(--ease-out); +} +``` + +### Motion Principles + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA MOTION PRINCIPLES │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ENTER: Scale up + fade in (0.95 → 1.0, 0 → 1) │ +│ 2. EXIT: Scale down + fade out (1.0 → 0.95, 1 → 0) │ +│ 3. HOVER: Subtle lift (translateY -2px) + glow increase │ +│ 4. PRESS: Slight compression (scale 0.98) │ +│ 5. FOCUS: Glow ring expansion │ +│ │ +│ Key insight: Aurora elements GLOW more on interaction, │ +│ they don't cast shadows—they emit light. │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Animation Keyframes + +```css +/* ─── ENTRY ANIMATIONS ─── */ +@keyframes aurora-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes aurora-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes aurora-slide-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ─── GLOW PULSE (for loading/processing) ─── */ +@keyframes aurora-pulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 0 var(--aurora-cyan-glow); + } + 50% { + opacity: 0.8; + box-shadow: 0 0 20px 4px var(--aurora-cyan-glow); + } +} + +/* ─── SHIMMER (for skeleton loaders) ─── */ +@keyframes aurora-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* ─── GRADIENT DRIFT (for hero backgrounds) ─── */ +@keyframes aurora-drift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} +``` + +--- + +## Part 7: Component Specifications + +### 7.1 Buttons + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA BUTTONS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ PRIMARY (Glowing CTA) │ +│ ┌─────────────────────────────────────┐ │ +│ │ ◉ Start Session │ ← Cyan glow ring │ +│ └─────────────────────────────────────┘ │ +│ bg: var(--aurora-cyan) │ +│ text: var(--void-deepest) │ +│ hover: glow expands, brightness +10% │ +│ active: scale(0.98), glow contracts │ +│ │ +│ SECONDARY (Glass) │ +│ ┌─────────────────────────────────────┐ │ +│ │ View History │ ← Subtle border │ +│ └─────────────────────────────────────┘ │ +│ bg: var(--glass-light) │ +│ border: var(--border-default) │ +│ hover: bg → glass-medium, border glows │ +│ │ +│ GHOST (Minimal) │ +│ ┌─────────────────────────────────────┐ │ +│ │ Cancel │ ← No bg │ +│ └─────────────────────────────────────┘ │ +│ bg: transparent │ +│ hover: var(--glass-subtle) │ +│ │ +│ DANGER (Warning glow) │ +│ ┌─────────────────────────────────────┐ │ +│ │ Delete Session │ ← Red glow │ +│ └─────────────────────────────────────┘ │ +│ bg: var(--aurora-red) at 15% opacity │ +│ border: var(--aurora-red) │ +│ hover: red glow expands │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +```css +/* Primary Button */ +.btn-primary { + background: var(--aurora-cyan); + color: var(--void-deepest); + padding: var(--padding-button); + border-radius: var(--radius-md); + font-weight: var(--weight-medium); + transition: var(--transition-all); + box-shadow: + 0 0 0 0 var(--aurora-cyan-glow), + 0 0 20px -5px var(--aurora-cyan); +} + +.btn-primary:hover { + box-shadow: + 0 0 0 4px var(--aurora-cyan-glow), + 0 0 30px -5px var(--aurora-cyan); + filter: brightness(1.1); +} + +.btn-primary:active { + transform: scale(0.98); + box-shadow: + 0 0 0 2px var(--aurora-cyan-glow), + 0 0 15px -5px var(--aurora-cyan); +} + +/* Secondary Button */ +.btn-secondary { + background: var(--glass-light); + border: 1px solid var(--border-default); + color: var(--text-primary); + padding: var(--padding-button); + border-radius: var(--radius-md); + backdrop-filter: blur(8px); + transition: var(--transition-all); +} + +.btn-secondary:hover { + background: var(--glass-medium); + border-color: var(--aurora-cyan); + box-shadow: 0 0 15px -5px var(--aurora-cyan-glow); +} +``` + +--- + +### 7.2 Cards + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA CARDS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ GLASS CARD (Default) │ +│ ╭───────────────────────────────────────────╮ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ +│ │ │ │ +│ │ Session Title │ │ +│ │ Subtitle or metadata │ │ +│ │ │ │ +│ │ Content area with sufficient padding │ │ +│ │ for comfortable reading and scanning. │ │ +│ │ │ │ +│ ╰───────────────────────────────────────────╯ │ +│ │ +│ Properties: │ +│ • bg: var(--glass-light) │ +│ • border: 1px solid var(--border-subtle) │ +│ • border-radius: var(--radius-lg) │ +│ • backdrop-filter: blur(12px) │ +│ • padding: var(--padding-card) │ +│ │ +│ ELEVATED CARD (Interactive) │ +│ ╭───────────────────────────────────────────╮ │ +│ │ │ ← Hover: │ +│ │ Select Provider │ Lift + │ +│ │ Choose your AI model │ Glow │ +│ │ │ │ +│ │ → Claude 4 → GPT-4 │ │ +│ │ │ │ +│ ╰───────────────────────────────────────────╯ │ +│ │ +│ Hover state: │ +│ • transform: translateY(-2px) │ +│ • border-color: var(--aurora-cyan) │ +│ • box-shadow: 0 0 30px -10px var(--aurora-cyan-glow) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7.3 Input Fields + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA INPUTS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ DEFAULT STATE │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Ask opencode anything... │ │ +│ └─────────────────────────────────────────────────┘ │ +│ bg: var(--glass-subtle) │ +│ border: var(--border-subtle) │ +│ text: var(--text-tertiary) ← placeholder │ +│ │ +│ FOCUS STATE │ +│ ╭─────────────────────────────────────────────────╮ │ +│ │ How do I implement_ ░▓▓▓ │ │ +│ ╰═════════════════════════════════════════════════╯ │ +│ ↑ ↑ │ +│ Cyan glow border Cursor pulse │ +│ │ +│ border: 2px solid var(--aurora-cyan) │ +│ box-shadow: 0 0 0 4px var(--aurora-cyan-glow) │ +│ bg: var(--glass-light) │ +│ │ +│ ERROR STATE │ +│ ╭─────────────────────────────────────────────────╮ │ +│ │ Invalid API key │ │ +│ ╰═════════════════════════════════════════════════╯ │ +│ border-color: var(--aurora-red) │ +│ box-shadow: 0 0 0 4px var(--aurora-red-glow) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7.4 The Prompt Input (Hero Component) + +This is the MOST IMPORTANT component—the main chat input: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA PROMPT INPUT │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ╭═══════════════════════════════════════════════════════╮ │ +│ ║ ║ │ +│ ║ │ How can I refactor this React component ║ │ +│ ║ │ to use hooks instead of class components?_ ║ │ +│ ║ ║ │ +│ ╟───────────────────────────────────────────────────────╢ │ +│ ║ ◎ @file ◎ @folder ◎ @web [⌘ + Enter] ║ │ +│ ╚═══════════════════════════════════════════════════════╝ │ +│ │ +│ Design Details: │ +│ • Double-line border with subtle gradient │ +│ • Inner glow when focused (aurora-cyan) │ +│ • Attachment chips below with hover states │ +│ • Send button pulses subtly when ready │ +│ • Expands smoothly as content grows │ +│ │ +│ Animation: │ +│ • On focus: border brightens, inner glow appears │ +│ • On type: subtle scale micro-pulse (1.002x) │ +│ • On send: content slides up + fades, input shrinks │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7.5 Message Bubbles + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA MESSAGE BUBBLES │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ USER MESSAGE │ +│ ╭─────────────────────────────╮ │ +│ │ How do I fix this TypeScript│ │ +│ │ error in my component? │ │ +│ ╰─────────────────────────────╯ │ +│ │ +│ • Aligned right │ +│ • bg: var(--aurora-cyan) at 15% opacity │ +│ • border-left: 2px solid var(--aurora-cyan) │ +│ • Subtle cyan tint │ +│ │ +│ ASSISTANT MESSAGE │ +│ ╭──────────────────────────────────────────────────────╮ │ +│ │ ◈ Let me help you with that TypeScript error. │ │ +│ │ │ │ +│ │ The issue is that your component expects a │ │ +│ │ `string` but receives `string | undefined`. │ │ +│ │ │ │ +│ │ ```typescript │ │ +│ │ // Add type guard │ │ +│ │ if (typeof value === 'string') { │ │ +│ │ processValue(value) │ │ +│ │ } │ │ +│ │ ``` │ │ +│ ╰──────────────────────────────────────────────────────╯ │ +│ │ +│ • Aligned left │ +│ • bg: var(--glass-light) │ +│ • border-left: 2px solid var(--aurora-violet) │ +│ • Code blocks: var(--void-elevated) bg │ +│ │ +│ STREAMING STATE │ +│ ╭──────────────────────────────────────────────────────╮ │ +│ │ ◈ Analyzing your codebase... │ │ +│ │ │ │ +│ │ ▓▓▓▓▓▓▓▓░░░░░░░░░░░░ Scanning files │ │ +│ │ ◌ ◌ ◌ ← Pulsing dots │ │ +│ ╰──────────────────────────────────────────────────────╯ │ +│ │ +│ • Shimmer effect on loading areas │ +│ • Typing indicator: 3 dots with staggered pulse │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7.6 Navigation & Header + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA HEADER │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ +│ ┃ ◈ opencode Session: Project Analysis ┃ │ +│ ┃ ⚙ ◐ ▤ ┃ │ +│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ +│ │ +│ Properties: │ +│ • bg: var(--void-elevated) with backdrop-blur │ +│ • border-bottom: 1px solid var(--border-subtle) │ +│ • position: sticky │ +│ • Logo: ◈ glyph with subtle cyan glow │ +│ • Session title: truncated with ellipsis │ +│ • Actions: icon buttons with hover glow │ +│ │ +│ SIDEBAR (Collapsed) │ +│ ┌──┐ │ +│ │◈│ ← Logo only │ +│ │──│ │ +│ │⊕│ ← New session │ +│ │📄│ ← Recent │ +│ │⚙│ ← Settings │ +│ └──┘ │ +│ │ +│ SIDEBAR (Expanded) │ +│ ╭────────────────────────╮ │ +│ │ ◈ opencode │ │ +│ ├────────────────────────┤ │ +│ │ ⊕ New Session │ │ +│ ├────────────────────────┤ │ +│ │ RECENT │ │ +│ │ ├─ Project Analysis │ ← Selected, cyan highlight │ +│ │ ├─ Code Review │ │ +│ │ └─ Bug Investigation │ │ +│ ├────────────────────────┤ │ +│ │ ⚙ Settings │ │ +│ ╰────────────────────────╯ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7.7 Dialogs & Modals + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA MODALS │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ BACKDROP │ +│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ bg: rgba(5, 5, 8, 0.8) │ +│ backdrop-filter: blur(4px) │ +│ │ +│ MODAL CARD │ +│ ╭═══════════════════════════════════════════════════════╮ │ +│ ║ SELECT MODEL ✕ ║ │ +│ ╟───────────────────────────────────────────────────────╢ │ +│ ║ ║ │ +│ ║ ◉ Claude 4 Opus ║ │ +│ ║ Best for complex reasoning ║ │ +│ ║ ║ │ +│ ║ ○ Claude 4 Sonnet ║ │ +│ ║ Balanced performance ║ │ +│ ║ ║ │ +│ ║ ○ GPT-4o ║ │ +│ ║ OpenAI's flagship ║ │ +│ ║ ║ │ +│ ╟───────────────────────────────────────────────────────╢ │ +│ ║ [Cancel] [ Confirm ] ║ │ +│ ╚═══════════════════════════════════════════════════════╝ │ +│ │ +│ Entry animation: │ +│ • Backdrop fades in (0→1, 200ms) │ +│ • Modal scales + fades (0.95→1, 0→1, 250ms, spring) │ +│ │ +│ Exit animation: │ +│ • Modal scales down + fades (1→0.95, 1→0, 150ms) │ +│ • Backdrop fades out (1→0, 150ms) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 8: TUI (Terminal) Component Translations + +The terminal can't do true 3D or blur, but we can create the *feeling* of Aurora through: + +### 8.1 Aurora TUI Character Palette + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA TUI — CHARACTER DESIGN LANGUAGE │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ─── BORDERS ─── │ +│ Light: ─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼ │ +│ Rounded: ╭ ╮ ╰ ╯ │ +│ Heavy: ━ ┃ ┏ ┓ ┗ ┛ ┣ ┫ ┳ ┻ ╋ │ +│ Double: ═ ║ ╔ ╗ ╚ ╝ ╠ ╣ ╦ ╩ ╬ │ +│ │ +│ ─── AURORA PREFERENCE ─── │ +│ Primary borders: ╭ ─ ╮ (rounded, elegant) │ +│ │ │ │ +│ ╰ ─ ╯ │ +│ │ +│ Active/Focus: ╭═══╮ (double top = "glow") │ +│ │ │ │ +│ ╰───╯ │ +│ │ +│ ─── BULLETS & MARKERS ─── │ +│ Filled: ● ◉ ◆ ◈ ■ ▲ ▶ │ +│ Empty: ○ ◇ □ △ ▷ │ +│ Special: ◐ ◑ ◒ ◓ (half-filled, for progress) │ +│ │ +│ Aurora preference: │ +│ • Logo/brand: ◈ (diamond with dot = light source) │ +│ • Selected: ● │ +│ • Unselected: ○ │ +│ • Active: ◉ (ring = glow) │ +│ • Tree nodes: ├─ └─ │ │ +│ │ +│ ─── PROGRESS INDICATORS ─── │ +│ Block gradient: ░ ▒ ▓ █ │ +│ Thin bar: ─ ━ │ +│ │ +│ Aurora progress: ░░░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ (dim → bright = filling with light) │ +│ │ +│ ─── SPINNERS ─── │ +│ Dots: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ │ +│ Circle: ◴ ◷ ◶ ◵ │ +│ Quarter: ◜ ◝ ◞ ◟ │ +│ │ +│ Aurora spinner: ◌ ◍ ◎ ● ◎ ◍ (breathing pulse) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 8.2 TUI Layout Templates + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA TUI — MAIN SESSION VIEW │ +├─────────────────────────────────────────────────────────────┤ + +╭─── ◈ opencode ─────────────────── Session: Code Review ────╮ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┃ How can I optimize this database query? │ ← User (cyan │) +│ │ +│ ╭─────────────────────────────────────────────────────╮ │ +│ │ ◈ I'll analyze your query and suggest optimizations.│ │ ← Assistant +│ │ │ │ +│ │ Looking at your query, I see several opportunities: │ │ +│ │ │ │ +│ │ 1. Add an index on `user_id` │ │ +│ │ 2. Use EXPLAIN ANALYZE to identify bottlenecks │ │ +│ │ 3. Consider pagination for large result sets │ │ +│ │ │ │ +│ │ ```sql │ │ +│ │ CREATE INDEX idx_user_id ON orders(user_id); │ │ +│ │ ``` │ │ +│ ╰─────────────────────────────────────────────────────╯ │ +│ │ +│ ░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓ Processing file changes │ ← Progress +│ │ +╰─────────────────────────────────────────────────────────────╯ +│ [?] Help [m] Model [t] Theme [s] Sessions $0.003 │ ← Footer +└─────────────────────────────────────────────────────────────┘ + +COLOR MAPPING: +• Header border: aurora-cyan (#00D4FF) +• Header text: text-primary (#F5F5F7) +• User message │: aurora-cyan +• Assistant border: aurora-violet (#A78BFA) +• Code blocks: void-elevated bg +• Progress filled: aurora-cyan +• Progress empty: border-subtle +• Footer: text-muted +``` + +### 8.3 TUI Dialog Example + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AURORA TUI — MODEL SELECTOR DIALOG │ +├─────────────────────────────────────────────────────────────┤ + + ╭═══════════════════════════════════════════╮ + ║ SELECT MODEL ║ + ╠═══════════════════════════════════════════╣ + ║ ║ + ║ ◉ Claude 4 Opus ║ ← Selected (cyan) + ║ Best for complex reasoning ║ + ║ ║ + ║ ○ Claude 4 Sonnet ║ + ║ Balanced performance ║ + ║ ║ + ║ ○ GPT-4o ║ + ║ OpenAI's flagship ║ + ║ ║ + ║ ○ Gemini 1.5 Pro ║ + ║ Google's multimodal ║ + ║ ║ + ╟───────────────────────────────────────────╢ + ║ [↑↓] Navigate [Enter] Select [Esc] ║ + ╚═══════════════════════════════════════════╝ + +COLOR MAPPING: +• Dialog border: aurora-cyan (double line = "glowing") +• Selected item: aurora-cyan fg + ◉ marker +• Unselected: text-muted + ○ marker +• Description: text-tertiary +• Keybinds: text-muted +• Dialog bg: void-elevated +``` + +--- + +## Part 9: Stitch Prompts for Visual Prototyping + +Copy & paste these prompts directly into [stitch.google.com](https://stitch.google.com) to generate visual mockups. + +### Prompt 1: Main Chat Interface (Dark Theme) + +``` +Create a dark mode AI chat interface for a developer tool called "opencode". + +DESIGN DIRECTION: +- Style: Luxury minimal, future-forward like Tesla/Rivian interiors +- Aesthetic: Digital luminescence - elements emit light rather than cast shadows +- Feel: Clean but bold, pushing boundaries while staying usable + +COLORS: +- Background: Deep void black (#0A0A0F) +- Cards/panels: Glassmorphism with very subtle white tint (rgba(255,255,255,0.04)) +- Primary accent: Electric cyan (#00D4FF) - used for glows and highlights +- Secondary accent: Soft violet (#A78BFA) +- Text: Bright white (#F5F5F7) +- Borders: Subtle glow, not hard edges + +LAYOUT: +- Left sidebar (collapsed): narrow strip with logo ◈, new session button, recent sessions list +- Main area: chat message history with clear visual hierarchy +- Bottom: prominent input field with glassmorphism, glowing cyan border on focus +- Header: session title, context/token count, settings icons + +MESSAGE STYLING: +- User messages: aligned right, subtle cyan tint background, thin cyan left border +- Assistant messages: aligned left, glass card with rounded corners, thin violet left border +- Code blocks inside messages: darker elevated background + +EFFECTS: +- Buttons glow brighter on hover (cyan halo expands) +- Cards have subtle lift on hover +- Input field has pulsing glow when focused +- Use smooth spring-based animations, not linear + +TYPOGRAPHY: +- Font: JetBrains Mono for code, Inter/Geist for UI text +- Clean, modern, monospace aesthetic + +Show this as a full desktop application interface (1440x900) with an ongoing conversation about code refactoring. +``` + +--- + +### Prompt 2: Model Selection Modal + +``` +Create a modal dialog for selecting AI models in a dark mode developer tool. + +DESIGN: +- Style: Glassmorphism modal floating over blurred dark background +- Background behind modal: Deep black (#0A0A0F) with 80% opacity overlay + blur +- Modal card: Glass effect (rgba(255,255,255,0.06)) with luminous cyan border + +MODAL CONTENT: +- Title: "SELECT MODEL" at top +- List of 4-5 AI models as selectable options: + • Claude 4 Opus - "Best for complex reasoning" + • Claude 4 Sonnet - "Balanced performance" + • GPT-4o - "OpenAI's flagship" + • Gemini 1.5 Pro - "Multimodal capabilities" + +INTERACTIONS: +- Selected option: Has filled cyan radio button, text is brighter +- Unselected: Empty circle, muted text +- Hover state: Subtle glow behind the option row +- Footer: "Cancel" ghost button, "Confirm" primary button with cyan glow + +ANIMATION: +- Modal scales in from 0.95 to 1.0 with fade +- Backdrop blurs in smoothly +- Radio selection has smooth transition + +Colors: +- Accent cyan: #00D4FF +- Background void: #0A0A0F +- Glass: rgba(255,255,255,0.06) +- Text: #F5F5F7 (primary), #A1A1AA (muted) +``` + +--- + +### Prompt 3: Empty State / Welcome Screen + +``` +Create a welcome screen for an AI coding assistant called "opencode". + +DESIGN DIRECTION: +- Dark mode, luxury minimal aesthetic +- Ethereal, digital luminescence feel +- Background: Very dark (#050508) with subtle animated gradient aurora effect (cyan/violet/rose, VERY subtle and slow) + +CONTENT: +- Large diamond logo (◈) in center, glowing softly with cyan light +- Tagline: "Code illuminated" +- Subtitle: "Your AI pair programming assistant" +- 3-4 quick action cards below: + • "Start new session" (primary CTA with cyan glow) + • "Continue recent: [session name]" + • "Explore templates" + • "Settings & preferences" + +VISUAL EFFECTS: +- Logo has subtle breathing pulse (glow expands/contracts slowly) +- Quick action cards are glass panels that lift and glow on hover +- Very subtle particle/star field effect in background (optional, keep it minimal) +- Typography is clean, modern, confident + +COLORS: +- Primary: #00D4FF (cyan) +- Secondary: #A78BFA (violet) +- Tertiary: #FF6B9D (rose) +- Background: #050508 to #0A0A0F gradient +- Text: #F5F5F7 + +Show as full screen application (1440x900), centered composition. +``` + +--- + +### Prompt 4: Light Theme Variant + +``` +Create a light mode variant of an AI chat interface for developers. + +DESIGN DIRECTION: +- Same luxury minimal aesthetic as dark mode, but inverted +- "Daylight aurora" - colors are richer/deeper for contrast +- Feel: Clean, bright, professional, premium + +COLORS: +- Background: Soft pearl (#FAFAFA) +- Cards: Frosted white glass with very subtle shadows +- Primary accent: Deeper cyan (#0891B2) for contrast +- Secondary: Rich violet (#7C3AED) +- Text: Near-black (#18181B) +- Borders: Very subtle gray, barely visible + +LAYOUT (same as dark): +- Collapsed sidebar on left +- Chat messages in center +- Glowing input at bottom (cyan glow still works in light mode) + +MESSAGE STYLING: +- User: Subtle cyan wash background, deeper cyan left border +- Assistant: White glass card, violet left border +- Code blocks: Light gray (#F4F4F5) background + +KEY DIFFERENCE FROM DARK: +- Shadows can be used (subtle, soft) +- Glass effect uses slight darkness instead of lightness +- Accents are richer/more saturated +- Same spring animations, same glow effects on focus + +Show as desktop app (1440x900) with same conversation as dark version. +``` + +--- + +### Prompt 5: Session List / Sidebar Expanded + +``` +Create an expanded sidebar view for a developer chat application. + +DESIGN: +- Dark mode, glassmorphism sidebar panel +- Sidebar width: ~280px +- Background: Slightly elevated from main (#0F0F14) + +CONTENT: +- Top: Logo "◈ opencode" with subtle cyan glow +- Below logo: "+ New Session" button (primary, cyan glow) +- Section: "RECENT" label (small, muted, uppercase) +- Session list items showing: + • Session title (truncated) + • Brief preview of last message + • Timestamp (relative: "2h ago", "Yesterday") + • Subtle icon showing model used + +INTERACTIONS: +- Current/selected session: Cyan highlight bar on left, brighter text +- Hover: Glass background appears, subtle glow +- List items have smooth slide-in animation on load + +VISUAL DETAILS: +- Divider lines are very subtle (border-subtle) +- Sessions grouped by time (Today, Yesterday, This Week) +- Scroll area with fading edge at top/bottom +- Search input at top with glass styling + +Colors: +- Selected highlight: #00D4FF +- Muted text: #71717A +- Timestamps: #A1A1AA +``` + +--- + +### Prompt 6: Tool/Permission Dialog + +``` +Create a permission request dialog for an AI coding assistant. + +CONTEXT: +The AI wants to edit a file and needs user approval. + +DESIGN: +- Dark glassmorphism modal +- Slightly different accent - using amber/warning color to indicate caution + +CONTENT: +- Header icon: Edit/pencil icon with amber glow +- Title: "Edit File Request" +- Description: "opencode wants to modify:" +- File path displayed: `src/components/Button.tsx` +- Preview section showing diff: + • Green highlighted lines for additions + • Red highlighted lines for deletions + • Context lines in muted color + +ACTIONS: +- "Allow" button - Primary with amber accent (#FFBB33) +- "Allow All" button - Secondary +- "Deny" button - Ghost/danger hint + +VISUAL DETAILS: +- Diff preview has code syntax highlighting +- Line numbers visible +- Modal has amber-tinted border (warning state) +- Keep the same glass effect and animation patterns + +Show the dialog centered over a blurred chat interface background. +``` + +--- + +### Prompt 7: Loading/Processing State + +``` +Create a message streaming/loading state for an AI response. + +DESIGN: +- Dark mode chat interface +- Assistant message in progress of being generated + +VISUAL: +- Glass card for assistant message (violet left border) +- Inside the card: + • "◈" logo pulsing with violet glow (breathing animation) + • First line of text appearing with typewriter effect + • Remaining area has subtle shimmer/skeleton loader + • Three dots "◌ ◌ ◌" with staggered pulse animation + +PROGRESS INDICATOR: +- Horizontal progress bar at bottom of card +- Uses the "filling with light" metaphor +- Empty portion: dim gray (░░░) +- Filled portion: cyan gradient (▓▓▓) +- Shows: "Analyzing codebase... 127 files" + +EFFECTS: +- Text fades in word by word +- Shimmer effect uses subtle gradient animation +- Overall feel: the AI is "thinking" and response is "materializing from light" + +Keep consistent with the Aurora design system - ethereal, luminous, not mechanical. +``` + +--- + +### Prompt 8: Settings Panel + +``` +Create a settings/preferences panel for a developer AI tool. + +DESIGN: +- Full-width panel that slides in from right (or modal) +- Dark glassmorphism style +- Organized into clear sections + +SECTIONS: +1. APPEARANCE + - Theme selector (dropdown or visual cards) + - Font size slider + +2. MODEL DEFAULTS + - Default model dropdown + - Temperature slider with visual indicator + +3. KEYBINDINGS + - List of keyboard shortcuts in two columns + - Each shows action + keybind + +4. INTEGRATIONS + - Toggle switches for: Git, LSP, MCP servers + - Each with subtle description + +VISUAL STYLE: +- Section headers: Small caps, cyan accent, muted +- Form controls: Glass styling, cyan focus states +- Toggle switches: Off = muted, On = cyan glow +- Sliders: Thin track, glowing thumb + +LAYOUT: +- Clean vertical stack with generous spacing +- Dividers between sections (very subtle) +- "Save" and "Cancel" buttons at bottom + +Background: #0F0F14 (elevated from main void) +``` + +--- + +### Prompt 9: Error State + +``` +Create an error notification/toast for an AI coding assistant. + +SCENARIO: API rate limit exceeded + +DESIGN: +- Toast notification appearing at top-right +- Dark glass with RED accent (error state) + +CONTENT: +- Left: Warning icon with red glow (⚠ or !) +- Title: "Rate Limit Exceeded" +- Description: "Please wait 30 seconds before trying again" +- Dismiss X button on right + +VISUAL: +- Glass background with subtle red tint +- Red left border (2-3px) +- Soft red outer glow (not harsh) +- Red accent: #F87171 + +ANIMATION: +- Slides in from right with spring physics +- Slight bounce at end +- Auto-dismiss with progress bar along bottom +- Fades out when dismissed + +ERROR COLOR MAPPING: +- Error: #F87171 (rose-red) +- Warning: #FFBB33 (amber) +- Success: #4ADE80 (green) +- Info: #00D4FF (cyan) + +Show toast over blurred chat interface. +``` + +--- + +### Prompt 10: Code Diff View + +``` +Create a code diff viewer for an AI coding assistant's file changes. + +DESIGN: +- Dark mode with syntax highlighting +- Side-by-side or unified diff view + +VISUAL STRUCTURE: +- Header: File path, "View Full File" link +- Line numbers on left (muted color) +- Two-tone background for changes: + • Added lines: Very subtle green tint background (#0D2818) + • Removed lines: Very subtle red tint background (#2D1216) + • Context lines: Default void background + +SYNTAX HIGHLIGHTING (Aurora theme): +- Keywords: Violet (#A78BFA) +- Functions: Cyan (#00D4FF) +- Strings: Green (#4ADE80) +- Numbers: Rose (#FF6B9D) +- Comments: Muted gray (#71717A) +- Variables: White (#F5F5F7) + +ADDITIONS: +- + symbol in green +- Line highlighted with green left border +- Changed text within line has brighter green background + +DELETIONS: +- - symbol in red +- Line highlighted with red left border +- Changed text within line has brighter red background + +GLASS CARD: +- Wrap entire diff in glass panel +- Rounded corners +- Subtle border + +Show a realistic diff of a TypeScript React component being refactored. +``` + +--- + +## Part 10: Final Summary & Implementation Guide + +### Design DNA at a Glance + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ AURORA DESIGN SYSTEM │ +│ "Code illuminated from within" │ +│ │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ VISUAL MOTION │ +│ ├─ Dark-first luxury ├─ Spring physics │ +│ ├─ Digital luminescence ├─ Confident/tactile │ +│ ├─ Glassmorphism ├─ 200-350ms timing │ +│ └─ Glowing accents └─ Glow as feedback │ +│ │ +│ COLOR TYPOGRAPHY │ +│ ├─ Cyan primary ├─ JetBrains Mono (code) │ +│ ├─ Violet secondary ├─ Geist/Inter (UI) │ +│ ├─ Rose tertiary └─ Major Third scale │ +│ └─ Void backgrounds │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Quick Reference Card + +| Aspect | Specification | +|--------|---------------| +| **Primary Accent** | `#00D4FF` (Electric Cyan) | +| **Secondary** | `#A78BFA` (Soft Violet) | +| **Tertiary** | `#FF6B9D` (Rose) | +| **Dark Background** | `#0A0A0F` (Void) | +| **Light Background** | `#FAFAFA` (Pearl) | +| **Border Style** | Subtle glow, not hard edges | +| **Glass Effect** | `rgba(255,255,255,0.04)` + `blur(12px)` | +| **Border Radius** | `8px` buttons, `12px` cards, `24px` modals | +| **Animation Duration** | 150-350ms | +| **Easing** | Spring-based (`cubic-bezier(0.22, 1, 0.36, 1)`) | +| **Code Font** | JetBrains Mono | +| **UI Font** | Geist / Inter | + +### Component Mapping: Web → TUI + +| Web Component | TUI Equivalent | +|---------------|----------------| +| Cyan glow border | Double-line border `═══` | +| Glassmorphism card | Rounded box `╭─╮ │ ╰─╯` | +| Hover lift effect | Highlight color change | +| Loading shimmer | Block gradient `░▒▓█` | +| Pulsing glow | Braille spinner `⠋⠙⠹...` or `◌◍◎●` | +| User cyan tint | Cyan foreground + `┃` pipe | +| Assistant violet border | Violet `│` left margin | + +### Implementation Phases (Recommended) + +#### Phase 1: Theme Foundation +- [ ] Create `aurora-dark.json` and `aurora-light.json` theme files +- [ ] Add to TUI theme selector +- [ ] Update CSS custom properties for web console + +#### Phase 2: Core Components +- [ ] Buttons (primary, secondary, ghost, danger) +- [ ] Input fields with focus glow +- [ ] Cards with glass effect +- [ ] Modals with backdrop blur + +#### Phase 3: Chat Interface +- [ ] Message bubbles (user/assistant) +- [ ] Prompt input (hero component) +- [ ] Loading/streaming states +- [ ] Code blocks with Aurora syntax theme + +#### Phase 4: Motion Polish +- [ ] Spring animations library integration +- [ ] Enter/exit transitions +- [ ] Micro-interactions +- [ ] Loading states + +### Existing Component Touchpoints + +Based on analysis of the codebase, these are the key files to modify: + +**TUI Theme System:** +- `packages/opencode/src/cli/cmd/tui/context/theme.tsx` — Theme provider and color types +- `packages/opencode/src/cli/cmd/tui/context/theme/` — Theme JSON files (add aurora-dark.json, aurora-light.json) + +**TUI Components:** +- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` — Main session view +- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` — Prompt input component +- `packages/opencode/src/cli/cmd/tui/component/dialog-*.tsx` — All dialog components + +**Web Console:** +- `packages/console/app/src/style/token/color.css` — CSS color tokens +- `packages/console/app/src/routes/index.css` — Landing page styles +- `packages/console/app/src/component/` — Shared components + +### Success Criteria + +The Aurora redesign is successful when: + +1. **Visual Coherence**: TUI and Web feel like the same product family +2. **Motion Quality**: Interactions feel tactile and confident, not floaty or delayed +3. **Performance**: Animations run at 60fps, no jank +4. **Accessibility**: 4.5:1 contrast ratios maintained, focus states visible +5. **Brand Recognition**: Users recognize "the opencode look" instantly + +--- + +## Appendix: Theme JSON Template + +```json +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "voidDeepest": "#050508", + "voidDeep": "#0A0A0F", + "voidBase": "#0F0F14", + "voidElevated": "#14141A", + "voidHover": "#1A1A22", + "auroraCyan": "#00D4FF", + "auroraViolet": "#A78BFA", + "auroraRose": "#FF6B9D", + "auroraAmber": "#FFBB33", + "auroraGreen": "#4ADE80", + "auroraRed": "#F87171", + "textPrimary": "#F5F5F7", + "textSecondary": "#A1A1AA", + "textTertiary": "#71717A" + }, + "theme": { + "primary": "auroraCyan", + "secondary": "auroraViolet", + "accent": "auroraRose", + "error": "auroraRed", + "warning": "auroraAmber", + "success": "auroraGreen", + "info": "auroraCyan", + "text": "textPrimary", + "textMuted": "textSecondary", + "background": "voidDeep", + "backgroundPanel": "voidBase", + "backgroundElement": "voidElevated", + "border": "#1E1E26", + "borderActive": "auroraCyan", + "borderSubtle": "#14141A", + "syntaxKeyword": "auroraViolet", + "syntaxFunction": "auroraCyan", + "syntaxString": "auroraGreen", + "syntaxNumber": "auroraRose", + "syntaxComment": "textTertiary", + "syntaxVariable": "textPrimary", + "syntaxType": "auroraAmber", + "syntaxOperator": "textSecondary", + "syntaxPunctuation": "textTertiary", + "diffAdded": "auroraGreen", + "diffRemoved": "auroraRed", + "diffAddedBg": "#0D2818", + "diffRemovedBg": "#2D1216", + "diffContext": "textTertiary", + "diffContextBg": "voidBase" + } +} +``` + +--- + +**Document created:** 2025-02-26 +**Design direction:** Aurora — Digital Luminescence +**Status:** Ready for implementation +**Last reviewed:** 2025-02-26 (UI/UX Pro Max review incorporated) + +--- + +## Part 11: Accessibility & Review Amendments + +*This section addresses feedback from the UI/UX Pro Max review and adds critical accessibility requirements.* + +### 11.1 Motion Sickness Prevention (CRITICAL) + +**Issue:** The original design suggested animated aurora backgrounds and continuous pulse effects which can trigger motion sensitivity. + +**Resolution:** + +```css +/* ═══════════════════════════════════════════════════════════ + REDUCED MOTION SUPPORT — MANDATORY + ═══════════════════════════════════════════════════════════ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + /* Disable specific Aurora effects */ + .aurora-background { + background: var(--void-deep) !important; + animation: none !important; + } + + .glow-pulse { + box-shadow: none !important; + } +} +``` + +**Guidelines:** +- ❌ **NEVER** use infinite animations on backgrounds or decorative elements +- ✅ Continuous animation ONLY permitted during active loading states +- ✅ Aurora drift effect should be opt-in, disabled by default +- ✅ All spring animations must have `prefers-reduced-motion` fallback + +--- + +### 11.2 Line Length Constraints (HIGH) + +**Issue:** Chat interfaces and documentation need line-length limits for readability. + +**Resolution:** + +```css +/* Add to spacing system */ +:root { + --max-prose-width: 70ch; /* 65-75 characters optimal */ +} + +/* Apply to text containers */ +.chat-message, +.documentation-content, +.modal-body { + max-width: var(--max-prose-width); +} + +/* Ensure full-width code blocks still work */ +.code-block { + max-width: 100%; + overflow-x: auto; +} +``` + +**Application:** +| Component | Max Width | +|-----------|-----------| +| Chat message bubbles | `70ch` | +| Modal body text | `70ch` | +| Documentation paragraphs | `70ch` | +| Code blocks | `100%` (scrollable) | +| Headers | No limit | + +--- + +### 11.3 Light Mode Glass Contrast (CRITICAL) + +**Issue:** Light mode glass effects were too subtle to establish visual hierarchy. + +**Resolution — Updated Light Theme:** + +```css +:root[data-theme="aurora-light"] { + /* ─── ADJUSTED GLASS OPACITIES ─── */ + --glass-subtle: rgba(0, 0, 0, 0.03); /* was 0.02 */ + --glass-light: rgba(0, 0, 0, 0.06); /* was 0.04 */ + --glass-medium: rgba(0, 0, 0, 0.09); /* was 0.06 */ + --glass-strong: rgba(0, 0, 0, 0.12); /* was 0.08 */ + + /* ─── STRONGER BORDERS ─── */ + --border-subtle: rgba(0, 0, 0, 0.08); /* was 0.06 */ + --border-default: rgba(0, 0, 0, 0.12); /* was 0.10 */ + --border-strong: rgba(0, 0, 0, 0.18); /* was 0.15 */ + + /* ─── SUBTLE SHADOWS (light mode only) ─── */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.10); +} + +/* Apply shadows to cards in light mode only */ +[data-theme="aurora-light"] .glass-card { + box-shadow: var(--shadow-sm); +} +``` + +**Contrast Verification:** + +| Text | Background | Ratio | Status | +|------|------------|-------|--------| +| `#18181B` | `#FAFAFA` | 16.2:1 | ✅ Pass | +| `#52525B` | `#FAFAFA` | 7.4:1 | ✅ Pass | +| `#A1A1AA` | `#FAFAFA` | 3.0:1 | ⚠️ Large text only | +| `#F5F5F7` | `#0A0A0F` | 19.6:1 | ✅ Pass | +| `#A1A1AA` | `#0A0A0F` | 8.5:1 | ✅ Pass | + +--- + +### 11.4 Interactive Element Requirements (MEDIUM) + +**Issue:** Interactive cues need explicit mandates. + +**Resolution — Mandatory Interaction Patterns:** + +```css +/* All clickable elements */ +button, +[role="button"], +.clickable, +.interactive-card, +a { + cursor: pointer; +} + +/* Focus-visible states (keyboard navigation) */ +:focus-visible { + outline: 2px solid var(--aurora-cyan); + outline-offset: 2px; +} + +/* Disable outline for mouse users */ +:focus:not(:focus-visible) { + outline: none; +} +``` + +**Icon Standards:** +- ✅ **Required:** Lucide Icons (React: `lucide-react`, Web: `lucide`) +- ✅ **Acceptable:** Heroicons, Phosphor Icons +- ❌ **Forbidden:** Emoji as UI icons (OS rendering inconsistency) +- ❌ **Forbidden:** Font Awesome (too generic, doesn't fit Aurora aesthetic) + +--- + +### 11.5 WCAG Compliance Checklist + +Before implementation, verify: + +#### Color & Contrast +- [ ] All body text has 4.5:1 minimum contrast ratio +- [ ] All large text (18px+) has 3:1 minimum contrast ratio +- [ ] Focus indicators are clearly visible (2px cyan outline) +- [ ] Error states use red AND icon/text (not color alone) + +#### Motion & Animation +- [ ] `prefers-reduced-motion` media query implemented +- [ ] No infinite animations on decorative elements +- [ ] Loading animations can be paused or are under 5s +- [ ] No flashing content (3 flashes per second limit) + +#### Interaction +- [ ] All interactive elements have `cursor: pointer` +- [ ] Touch targets are minimum 44x44px +- [ ] Keyboard navigation follows visual order +- [ ] Focus states are distinct from hover states + +#### Typography +- [ ] Minimum 16px body text (mobile) +- [ ] Line height minimum 1.5 for body text +- [ ] Line length limited to 70ch for prose +- [ ] Text is resizable to 200% without loss of functionality + +--- + +### Review Response Summary + +| Feedback Item | Severity | Action Taken | +|---------------|----------|--------------| +| Motion sickness / `prefers-reduced-motion` | CRITICAL | Added §11.1 with full CSS implementation | +| Line length 65-75ch | HIGH | Added §11.2 with `--max-prose-width: 70ch` | +| Light mode glass contrast | CRITICAL | Added §11.3 with adjusted opacity values | +| `cursor-pointer` mandate | MEDIUM | Added §11.4 with interactive patterns | +| SVG icons only | MEDIUM | Added §11.4 with Lucide Icons mandate | +| WCAG compliance | — | Added §11.5 checklist | + +--- + +*Review incorporated from: UI/UX Pro Max analysis (2025-02-26)* diff --git a/docs/plans/2025-02-26-roo-code-orphaned-tool-use-debug.md b/docs/plans/2025-02-26-roo-code-orphaned-tool-use-debug.md new file mode 100644 index 000000000000..4c70e250500a --- /dev/null +++ b/docs/plans/2025-02-26-roo-code-orphaned-tool-use-debug.md @@ -0,0 +1,442 @@ +# Systematic Root-Cause Analysis: Roo Code Orphaned `tool_use` Error + +## Phase 1: Root Cause Investigation + +### 1.1 The Error (Read Carefully) + +``` +messages.12: `tool_use` ids were found without `tool_result` blocks immediately after: + tooluse_gcKGmk7V7opjkl8G2V6v0N, tooluse_ldg9S86J2GK8UzcQqvOQXR. +Each `tool_use` block must have a corresponding `tool_result` block in the next message. +``` + +**What this tells us precisely:** + +| Fact | Implication | +| ---------------------------------------------------- | --------------------------------------------------------------------------------- | +| `messages.12` | The **13th message** (0-indexed) in the conversation array is the problem | +| Two IDs: `tooluse_gcKG…`, `tooluse_ldg9…` | The assistant called **exactly 2 tools** in that turn | +| "without `tool_result` blocks **immediately after**" | Message 13 (the next user message) does NOT contain matching `tool_result` blocks | +| Cost `$0.0000` | API rejects the request **before** processing — this is a pre-validation error | +| First attempt at 7%, $1.61 spent | **~6 successful API round-trips** happened before this (at ~$0.25/turn) | +| IDs start with `tooluse_` | This is Anthropic's native tool calling format (not OpenAI-style `call_*`) | + +### 1.2 Reproducing the Scenario + +**User's task:** "Create a latest DMG for me, before that redesign the SVG for the app icon. Use Skill UI UX Pro Max." + +This task would trigger the following tool sequence: + +1. **read_file** — Read existing SVG icon file(s) +2. **list_files** — Scan project structure for icon locations +3. **write_to_file** — Write new SVG design +4. **execute_command** — Build/package DMG + +The **"Use Skill UI UX Pro Max"** instruction is key — it tells the assistant to use a custom mode/skill, which could trigger a **`switch_mode`** or **`skill`** tool call alongside regular tools. + +At the point of failure (message 12, ~7% progress, $1.61), the assistant would have been in the early **file reading/scanning phase**, likely calling 2 tools in parallel. + +### 1.3 Backward Trace: From Error to Root Cause + +``` +Error received by: Anthropic API server + ↑ Sent by: this.api.createMessage() at Task.ts:4271 + ↑ Built from: cleanConversationHistory at Task.ts:4193 + ↑ Derived from: effectiveHistory → mergedForApi → messagesWithoutImages + ↑ Sourced from: this.apiConversationHistory (the persistent storage) + ↑ CORRUPTED HERE: message 12 has tool_use but message 13 lacks tool_result +``` + +**The question is: HOW did message 13 get saved without the tool_results?** + +There are exactly 3 code paths that save user messages with `tool_result` blocks: + +#### Path A: Normal tool execution flow (`recursivelyMakeClineRequests`) + +``` +Task.ts:3542 → Save assistant message (with tool_use blocks) +Task.ts:3561 → presentAssistantMessage(this) → executes tools → pushToolResult() +Task.ts:3581 → pWaitFor(() => this.userMessageContentReady) +Task.ts:2651 → addToApiConversationHistory({ role: "user", content: finalUserContent }) +``` + +In this path, `finalUserContent` at line 2641 includes `this.userMessageContent` which is populated by `pushToolResult` during tool execution. The `pWaitFor` at 3581 blocks until all tools complete. + +**Could this path lose tool_results?** → Only if `presentAssistantMessage` fails to call `pushToolResult`. + +#### Path B: `flushPendingToolResultsToHistory()` (delegation via `new_task`) + +``` +Task.ts:1048 → Check userMessageContent.length > 0 +Task.ts:1067 → Wait for assistantMessageSavedToHistory (30s timeout) +Task.ts:1085 → Build user message from this.userMessageContent +Task.ts:1096 → Push to apiConversationHistory +``` + +**Could this path lose tool_results?** → Yes, if abort/timeout triggers. + +#### Path C: Task resume (`resumeTaskFromHistory`) + +``` +Task.ts:2109-2117 → Generate placeholder tool_results for all tool_use blocks +Task.ts:2142-2159 → Find missing tool_results and fill them in +Task.ts:2217 → overwriteApiConversationHistory(modifiedApiConversationHistory) +``` + +**Could this path lose tool_results?** → No, it explicitly generates them. + +--- + +### 1.4 The Root Cause: `presentAssistantMessage` + `AskIgnoredError` = Silent Failure + +Here is the critical code path that causes the corruption: + +#### Step 1: Stream completes, assistant has 2 tool_use blocks + +At [`Task.ts:3542`](references/Roo-Code/src/core/task/Task.ts:3542): + +```ts +await this.addToApiConversationHistory( + { role: "assistant", content: assistantContent }, // Contains 2 tool_use blocks + reasoningMessage || undefined, +) +this.assistantMessageSavedToHistory = true // ← message 12 is now persisted +``` + +#### Step 2: Tools begin executing via `presentAssistantMessage` + +At [`presentAssistantMessage.ts:61`](references/Roo-Code/src/core/assistant-message/presentAssistantMessage.ts:61), the function is called to process each tool_use block. Each tool handler calls `askApproval()` which internally calls `cline.ask()`. + +#### Step 3: `ask()` throws `AskIgnoredError` — the silent killer + +At [`Task.ts:1304`](references/Roo-Code/src/core/task/Task.ts:1304): + +```ts +throw new AskIgnoredError("updating existing partial") +``` + +And at [`Task.ts:1312`](references/Roo-Code/src/core/task/Task.ts:1312): + +```ts +throw new AskIgnoredError("new partial") +``` + +This error is thrown when: + +- A tool starts streaming its approval request as a partial message +- Another partial update comes in before the user responds +- The earlier ask is **silently abandoned** + +#### Step 4: `handleError` catches `AskIgnoredError` but DOES NOTHING + +At [`presentAssistantMessage.ts:540-544`](references/Roo-Code/src/core/assistant-message/presentAssistantMessage.ts:540): + +```ts +const handleError = async (action: string, error: Error) => { + // Silently ignore AskIgnoredError - this is an internal control flow + // signal, not an actual error. + if (error instanceof AskIgnoredError) { + return // ← NO tool_result pushed! Silent return! + } + // ... + pushToolResult(formatResponse.toolError(errorString)) +} +``` + +**THIS IS THE BUG.** + +When `AskIgnoredError` is caught: + +- `pushToolResult()` is **never called** +- `hasToolResult` remains `false` +- The `tool_use` block has **no corresponding `tool_result`** +- But the tool handler returns normally (no re-throw) + +#### Step 5: The loop continues, user message gets saved incomplete + +After `presentAssistantMessage` completes all blocks: + +- `userMessageContentReady` is set to `true` +- The `pWaitFor` at [`Task.ts:3581`](references/Roo-Code/src/core/task/Task.ts:3581) resolves +- The user message is saved at [`Task.ts:2651`](references/Roo-Code/src/core/task/Task.ts:2651) with **1 out of 2 tool_results** (or 0 out of 2) +- The `validateAndFixToolResultIds` at [`Task.ts:1016`](references/Roo-Code/src/core/task/Task.ts:1016) SHOULD catch this... + +#### Step 6: But wait — does `validateAndFixToolResultIds` catch it? + +At [`validateToolResultIds.ts:118-121`](references/Roo-Code/src/core/task/validateToolResultIds.ts:118): + +```ts +const missingToolUseIds = toolUseBlocks + .filter((toolUse) => !existingToolResultIds.has(toolUse.id)) + .map((toolUse) => toolUse.id) +``` + +Yes, it detects the missing IDs. And at line 220-228: + +```ts +const missingToolResults = stillMissingToolUseIds.map((toolUse) => ({ + type: "tool_result" as const, + tool_use_id: toolUse.id, + content: "Tool execution was interrupted before completion.", +})) +const finalContent = missingToolResults.length > 0 ? [...missingToolResults, ...correctedContent] : correctedContent +``` + +**It injects placeholder tool_results!** So... why does the error still happen? + +#### Step 7: THE REAL BUG — `askApproval` catches `AskIgnoredError` but the tool handler itself ALSO throws it + +Look at the tool handler flow more carefully. The `askApproval` function at [`presentAssistantMessage.ts:494-529`](references/Roo-Code/src/core/assistant-message/presentAssistantMessage.ts:494) calls `cline.ask()`. If `ask()` throws `AskIgnoredError`, it **propagates up through `askApproval`**: + +```ts +const askApproval = async (...) => { + const { response, text, images } = await cline.ask(type, ...) // ← throws AskIgnoredError! + // code below never executes +} +``` + +The `AskIgnoredError` escapes `askApproval`, enters the tool handler (e.g., `readFileTool.handle()`), which catches it through `handleError`: + +```ts +// Inside a tool handler like readFileTool: +try { + const approved = await askApproval("tool", ...) // ← AskIgnoredError thrown here + // never reaches pushToolResult() +} catch (error) { + await handleError("reading file", error) // ← silently returns for AskIgnoredError +} +``` + +After `handleError` silently returns: + +- **No `tool_result` was pushed** +- The tool handler returns normally +- `presentAssistantMessage` moves to the next block + +**But this should be caught by `validateAndFixToolResultIds`...** unless there's a timing issue. + +#### Step 8: THE ACTUAL ROOT CAUSE — The AskIgnoredError is thrown DURING tool approval streaming, which happens DURING the API response stream + +The key insight is **when** this happens: + +1. The API response is still streaming (`didCompleteReadingStream = false`) +2. `presentAssistantMessage` is called to present tool #1 (partial) +3. Tool #1 calls `askApproval(type, partialMessage, progressStatus)` with `partial=true` +4. `ask()` throws `AskIgnoredError("new partial")` for the first partial +5. `handleError` silently ignores it — **no tool_result pushed** +6. `presentAssistantMessage` unlocks at line 933 and returns +7. Stream continues, tool #1 becomes complete (non-partial) +8. `presentAssistantMessage` is called again +9. **But now `cline.currentStreamingContentIndex` has already been incremented at line 957** +10. The complete version of tool #1 is **SKIPPED** — it was "already presented" as partial +11. Tool #2 is presented and executed +12. Tool #2's `tool_result` IS pushed + +So the final user message has: `[tool_result for tool #2]` but NOT `[tool_result for tool #1]`. + +**WAIT** — let me re-read line 940 more carefully: + +```ts +if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) { +``` + +This only advances the index when `!block.partial`. A partial block does NOT advance the index. So tool #1 partial → `AskIgnoredError` → returns WITHOUT advancing index → tool #1 complete → presented again → should work. + +Let me trace more carefully... + +#### Step 8 (Revised): The REAL root cause — `AskIgnoredError` thrown for a NON-PARTIAL tool + +The `AskIgnoredError` can be thrown even for non-partial asks. Look at [`Task.ts:1474-1476`](references/Roo-Code/src/core/task/Task.ts:1474): + +```ts +throw new AskIgnoredError("superseded") +``` + +This happens when `this.lastMessageTs !== askTs` — meaning **another ask was created while this one was pending**. This is the "superseded" case. + +**Scenario for 2 parallel tools:** + +1. Stream completes with 2 tool_use blocks: `[tool_A, tool_B]` +2. `presentAssistantMessage` processes tool_A (complete, non-partial) +3. tool_A calls `askApproval("tool", ...)` → calls `cline.ask("tool", ...)` +4. `ask()` creates a new ClineMessage with `askTs = Date.now()` +5. `ask()` reaches `pWaitFor` at line 1444, waiting for user response +6. **Auto-approval kicks in** at line 1368 → `this.approveAsk()` → sets `askResponse` +7. `pWaitFor` resolves → `ask()` returns → tool_A executes → `pushToolResult()` ✓ +8. `presentAssistantMessage` increments index to tool_B +9. tool_B calls `askApproval("tool", ...)` → calls `cline.ask("tool", ...)` +10. This works normally too. ✓ + +So parallel tools in sequence shouldn't cause the issue with auto-approval. BUT: + +#### Step 8 (Final): The TRUE root cause — Mid-stream crash between assistant save and tool execution + +Let me look at the exception handler at [`Task.ts:3722-3729`](references/Roo-Code/src/core/task/Task.ts:3722): + +```ts +} catch (error) { + // This should never happen since the only thing that can throw an + // error is the attemptApiRequest, which is wrapped in a try catch + // that sends an ask where if noButtonClicked, will clear current + // task and destroy this instance. + return true // Needs to be true so parent loop knows to end task. +} +``` + +And the `presentAssistantMessage` at line 62-64: + +```ts +if (cline.abort) { + throw new Error(`[Task#presentAssistantMessage] task ... aborted`) +} +``` + +**HERE IS THE ACTUAL ROOT CAUSE:** + +1. Assistant message with 2 `tool_use` blocks is saved to history (line 3542) ← **message 12** +2. `this.assistantMessageSavedToHistory = true` (line 3546) +3. `presentAssistantMessage(this)` is called (line 3561) to present partial blocks +4. During tool execution, **`cline.abort` gets set to `true`** (user cancels, or error, or timeout) +5. `presentAssistantMessage` throws at line 63: `throw new Error("...aborted")` +6. This throw propagates up through the tool execution +7. **`pushToolResult` was never called for either tool** +8. The error reaches the `catch` at Task.ts:3722 +9. It returns `true` — task ends +10. **BUT message 12 (assistant with 2 tool_use blocks) is ALREADY in the persistent history** +11. **No user message with tool_results was ever saved as message 13** + +When the user resumes the task: + +- `resumeTaskFromHistory` at Task.ts:2090+ checks the LAST message +- If the last message is the assistant with tool_use, it generates placeholders → **works** +- But if other messages were appended AFTER message 12 before the abort (e.g., error messages, api_req_started), the last message is NOT message 12 +- The resume logic only fixes the last assistant-user pair, not arbitrary positions + +**The corruption is permanent.** + +--- + +## Phase 2: Pattern Analysis + +### Working example + +When `abort` is NOT set during tool execution: + +1. All tools execute normally +2. All `pushToolResult()` calls complete +3. `userMessageContent` has all `tool_result` blocks +4. User message saved with all results → ✓ + +### Broken example (this bug) + +When `abort` IS set during tool execution (e.g., user clicks cancel, network timeout, extension deactivation): + +1. Some tools may have executed, others not +2. `presentAssistantMessage` throws on abort check +3. `userMessageContent` has partial or zero `tool_result` blocks +4. User message is NEVER saved (abort exits the loop) +5. But assistant message with `tool_use` blocks is ALREADY saved → ✗ + +### The key difference + +The **assistant message is saved BEFORE tool execution** (line 3542), but the **user message with tool_results is saved AFTER all tools complete** (line 2651). Any interruption between these two writes creates an orphaned `tool_use`. + +--- + +## Phase 3: Hypothesis + +**Hypothesis:** The root cause is that aborting/cancelling a task between the assistant message save (Task.ts:3542) and the user message save (Task.ts:2651) leaves the API conversation history in an invalid state where an assistant message has `tool_use` blocks without a following `tool_result` message. The `validateAndFixToolResultIds` safety net only runs at write-time for new messages, not as a pre-flight check before API calls, so the corruption is never repaired on retry. + +**Evidence supporting this:** + +1. Error occurs at a fixed position (`messages.12`) — consistent with a single write of assistant message followed by no user message write +2. Two tool_use IDs — consistent with a multi-tool call that was interrupted +3. Task was at 7% progress — early in execution, tools were still being called +4. The error is **permanent** — every retry hits the same corrupted history because no code path repairs it +5. Roo Code explicitly has comments about this risk in the codebase (lines 3401-3404, 1054-1057) + +--- + +## Phase 4: Proposed Fix + +### Fix 1: Pre-flight history validation in `attemptApiRequest` + +At [`Task.ts:4193`](references/Roo-Code/src/core/task/Task.ts:4193), after building `cleanConversationHistory`, add: + +```ts +// Repair orphaned tool_use blocks before sending to API +for (let i = 0; i < cleanConversationHistory.length - 1; i++) { + const msg = cleanConversationHistory[i] + const next = cleanConversationHistory[i + 1] + + if (msg.role !== "assistant") continue + + const content = Array.isArray(msg.content) ? msg.content : [] + const toolUseBlocks = content.filter((b) => b.type === "tool_use") + if (toolUseBlocks.length === 0) continue + + if (next.role !== "user") { + // Insert a synthetic user message with tool_results + const toolResults = toolUseBlocks.map((t) => ({ + type: "tool_result", + tool_use_id: t.id, + content: "Tool execution was interrupted.", + })) + cleanConversationHistory.splice(i + 1, 0, { role: "user", content: toolResults }) + continue + } + + // Check if next user message has all required tool_results + const nextContent = Array.isArray(next.content) ? next.content : [] + const resultIds = new Set(nextContent.filter((b) => b.type === "tool_result").map((b) => b.tool_use_id)) + const missing = toolUseBlocks.filter((t) => !resultIds.has(t.id)) + + if (missing.length > 0) { + const repairs = missing.map((t) => ({ + type: "tool_result", + tool_use_id: t.id, + content: "Tool execution was interrupted.", + })) + next.content = [...repairs, ...nextContent] + } +} +``` + +### Fix 2: Ensure abort saves partial tool_results + +At [`Task.ts:3722`](references/Roo-Code/src/core/task/Task.ts:3722), before returning: + +```ts +} catch (error) { + // Save any accumulated tool_results to prevent orphaned tool_use blocks + if (this.userMessageContent.length > 0) { + await this.flushPendingToolResultsToHistory() + } + return true +} +``` + +### Fix 3: Detect and break the infinite retry loop + +In `attemptApiRequest`'s error handler, detect this specific Anthropic error pattern and auto-repair: + +```ts +if (error.message?.includes("tool_use` ids were found without `tool_result`")) { + await this.repairOrphanedToolUseBlocks() + yield * this.attemptApiRequest(retryAttempt + 1) + return +} +``` + +--- + +## Summary + +| Layer | What happens | File:Line | +| ----------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **Trigger** | User cancels task, or network drops, or abort signal fires | `Task.ts:62-64` | +| **Corruption** | Assistant message (with `tool_use`) already saved, tool execution interrupted before `tool_result` saved | `Task.ts:3542` (save) → `Task.ts:3561` (execute) → abort before `Task.ts:2651` (save results) | +| **Missing guard** | `presentAssistantMessage` silently drops tool_results when `AskIgnoredError` or abort occurs | `presentAssistantMessage.ts:225`, `543` | +| **No recovery** | `validateAndFixToolResultIds` only runs at write-time, not pre-flight | `Task.ts:1016` | +| **Infinite loop** | `attemptApiRequest` retries with same corrupted history | `Task.ts:4337` | +| **No escape** | User must start a new session; no "repair history" option exists | — | diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx deleted file mode 100644 index 8e4913b1f5b0..000000000000 --- a/packages/app/src/components/prompt-input.tsx +++ /dev/null @@ -1,2193 +0,0 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { - createEffect, - on, - Component, - splitProps, - For, - Show, - onCleanup, - createMemo, - createSignal, - createResource, - Switch, - Match, - type ComponentProps, - type JSX, -} from "solid-js" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { - ContentPart, - DEFAULT_PROMPT, - isPromptEqual, - Prompt, - usePrompt, - ImageAttachmentPart, - AgentPart, - FileAttachmentPart, -} from "@/context/prompt" -import { useLayout } from "@/context/layout" -import { useNavigate } from "@solidjs/router" -import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -import { useSync } from "@/context/sync" -import { useComments } from "@/context/comments" -import { Button } from "@opencode-ai/ui/button" -import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Select } from "@opencode-ai/ui/select" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { useProviders } from "@/hooks/use-providers" -import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" -import { usePermission } from "@/context/permission" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" -import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" -import { createPromptAttachments } from "./prompt-input/attachments" -import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" -import { - canNavigateHistoryAtCursor, - navigatePromptHistory, - prependHistoryEntry, - type PromptHistoryComment, - type PromptHistoryEntry, - type PromptHistoryStoredEntry, - promptLength, -} from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" -import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" -import { PromptContextItems } from "./prompt-input/context-items" -import { PromptImageAttachments } from "./prompt-input/image-attachments" -import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptPlaceholder } from "./prompt-input/placeholder" -import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/server-sync" -import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { displayName } from "@/pages/layout/helpers" - -interface PromptInputProps { - class?: string - variant?: "dock" | "new-session" - ref?: (el: HTMLDivElement) => void - newSessionWorktree?: string - onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } - onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void - onAbort?: () => void - onSubmit?: () => void -} - -const EXAMPLES = [ - "prompt.example.1", - "prompt.example.2", - "prompt.example.3", - "prompt.example.4", - "prompt.example.5", - "prompt.example.6", - "prompt.example.7", - "prompt.example.8", - "prompt.example.9", - "prompt.example.10", - "prompt.example.11", - "prompt.example.12", - "prompt.example.13", - "prompt.example.14", - "prompt.example.15", - "prompt.example.16", - "prompt.example.17", - "prompt.example.18", - "prompt.example.19", - "prompt.example.20", - "prompt.example.21", - "prompt.example.22", - "prompt.example.23", - "prompt.example.24", - "prompt.example.25", -] as const - -export const PromptInput: Component = (props) => { - const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() - - const sync = useSync() - const local = useLocal() - const files = useFile() - const prompt = usePrompt() - const layout = useLayout() - const server = useServer() - const comments = useComments() - const dialog = useDialog() - const providers = useProviders() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const settings = useSettings() - const { params, tabs, view } = useSessionLayout() - let editorRef!: HTMLDivElement - let fileInputRef: HTMLInputElement | undefined - let scrollRef!: HTMLDivElement - let slashPopoverRef!: HTMLDivElement - let projectSearchRef: HTMLInputElement | undefined - - const mirror = { input: false } - const inset = 56 - const space = `${inset}px` - - const scrollCursorIntoView = () => { - const container = scrollRef - const selection = window.getSelection() - if (!container || !selection || selection.rangeCount === 0) return - - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return - - const cursor = getCursorPosition(editorRef) - const length = promptLength(prompt.current().filter((part) => part.type !== "image")) - if (cursor >= length) { - container.scrollTop = container.scrollHeight - return - } - - const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() - if (!rect.height) return - - const containerRect = container.getBoundingClientRect() - const top = rect.top - containerRect.top + container.scrollTop - const bottom = rect.bottom - containerRect.top + container.scrollTop - const padding = 12 - - if (top < container.scrollTop + padding) { - container.scrollTop = Math.max(0, top - padding) - return - } - - if (bottom > container.scrollTop + container.clientHeight - inset) { - container.scrollTop = bottom - container.clientHeight + inset - } - } - - const queueScroll = (count = 2) => { - requestAnimationFrame(() => { - scrollCursorIntoView() - if (count > 1) queueScroll(count - 1) - }) - } - - const activeFileTab = createSessionTabs({ - tabs, - pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), - }).activeFileTab - - const commentInReview = (path: string) => { - const sessionID = params.id - if (!sessionID) return false - - const diffs = sync.data.session_diff[sessionID] - if (!diffs) return false - return diffs.some((diff) => diff.file === path) - } - - const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { - if (!item.commentID) return - - const focus = { file: item.path, id: item.commentID } - comments.setActive(focus) - - const queueCommentFocus = (attempts = 6) => { - const schedule = (left: number) => { - requestAnimationFrame(() => { - comments.setFocus({ ...focus }) - if (left <= 0) return - requestAnimationFrame(() => { - const current = comments.focus() - if (!current) return - if (current.file !== focus.file || current.id !== focus.id) return - schedule(left - 1) - }) - }) - } - - schedule(attempts) - } - - const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) - if (wantsReview) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("changes") - tabs().setActive("review") - queueCommentFocus() - return - } - - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - const tab = files.tab(item.path) - void tabs().open(tab) - tabs().setActive(tab) - void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) - } - - const recent = createMemo(() => { - const all = tabs().all() - const active = activeFileTab() - const order = active ? [active, ...all.filter((x) => x !== active)] : all - const seen = new Set() - const paths: string[] = [] - - for (const tab of order) { - const path = files.pathFromTab(tab) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - paths.push(path) - } - - return paths - }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const working = createMemo(() => sync.data.session_working(params.id ?? "")) - const imageAttachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), - ) - - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) - const [picker, setPicker] = createStore({ - projectOpen: false, - projectSearch: "", - }) - - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) - const motion = (value: number) => ({ - opacity: value, - transform: `scale(${0.98 + value * 0.02})`, - filter: `blur(${(1 - value) * 2}px)`, - "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), - }) - const buttons = createMemo(() => motion(buttonsSpring())) - const shell = createMemo(() => motion(1 - buttonsSpring())) - const control = createMemo(() => ({ height: "28px", ...buttons() })) - - const commentCount = createMemo(() => { - if (store.mode === "shell") return 0 - return prompt.context.items().filter((item) => !!item.comment?.trim()).length - }) - const blank = createMemo(() => { - const text = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 - }) - const stopping = createMemo(() => working() && blank()) - const tip = () => { - if (stopping()) { - return ( -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
- ) - } - - return ( -
- {language.t("prompt.action.send")} - -
- ) - } - - const contextItems = createMemo(() => { - const items = prompt.context.items() - if (store.mode !== "shell") return items - return items.filter((item) => !item.comment?.trim()) - }) - - const hasUserPrompt = createMemo(() => { - const sessionID = params.id - if (!sessionID) return false - const messages = sync.data.message[sessionID] - if (!messages) return false - return messages.some((m) => m.role === "user") - }) - - const [history, setHistory] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - const [shellHistory, setShellHistory] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - - const suggest = createMemo(() => !hasUserPrompt()) - - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", - suggest: suggest(), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - if (item.type !== "file") return [] - const comment = item.comment?.trim() - if (!comment) return [] - - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ - start: item.selection.startLine, - end: item.selection.endLine, - } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - - const applyHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file" as const, - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { - const p = entry.prompt - const length = position === "start" ? 0 : promptLength(p) - setStore("applyingHistory", true) - applyHistoryComments(entry.comments) - prompt.set(p, length) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, length) - setStore("applyingHistory", false) - queueScroll() - }) - } - - const getCaretState = () => { - const selection = window.getSelection() - const textLength = promptLength(prompt.current()) - if (!selection || selection.rangeCount === 0) { - return { collapsed: false, cursorPosition: 0, textLength } - } - const anchorNode = selection.anchorNode - if (!anchorNode || !editorRef.contains(anchorNode)) { - return { collapsed: false, cursorPosition: 0, textLength } - } - return { - collapsed: selection.isCollapsed, - cursorPosition: getCursorPosition(editorRef), - textLength, - } - } - - const escBlur = () => platform.platform === "desktop" && platform.os === "macos" - - const pick = () => fileInputRef?.click() - - const setMode = (mode: "normal" | "shell") => { - setStore("mode", mode) - setStore("popover", null) - requestAnimationFrame(() => editorRef?.focus()) - } - - const shellModeKey = "mod+shift+x" - const normalModeKey = "mod+shift+e" - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: store.mode !== "normal", - onSelect: pick, - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: shellModeKey, - disabled: store.mode === "shell", - onSelect: () => setMode("shell"), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: normalModeKey, - disabled: store.mode === "normal", - onSelect: () => setMode("normal"), - }, - ]) - - const closePopover = () => setStore("popover", null) - - const resetHistoryNavigation = (force = false) => { - if (!force && (store.historyIndex < 0 || store.applyingHistory)) return - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } - - const clearEditor = () => { - editorRef.innerHTML = "" - } - - const setEditorText = (text: string) => { - clearEditor() - editorRef.textContent = text - } - - const focusEditorEnd = () => { - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const selection = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - selection?.removeAllRanges() - selection?.addRange(range) - }) - } - - const currentCursor = () => { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null - return getCursorPosition(editorRef) - } - - const restoreFocus = () => { - requestAnimationFrame(() => { - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - editorRef.focus() - setCursorPosition(editorRef, cursor) - queueScroll() - }) - } - - const renderEditorWithCursor = (parts: Prompt) => { - const cursor = currentCursor() - renderEditor(parts) - if (cursor !== null) setCursorPosition(editorRef, cursor) - } - - createEffect(() => { - params.id - if (params.id) return - if (!suggest()) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - - const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - - const handleBlur = () => { - closePopover() - setComposing(false) - } - - const handleCompositionStart = () => { - setComposing(true) - } - - const handleCompositionEnd = () => { - setComposing(false) - requestAnimationFrame(() => { - if (composing()) return - reconcile(prompt.current().filter((part) => part.type !== "image")) - }) - } - - const agentList = createMemo(() => - sync.data.agent - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), - ) - const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) - - const handleAtSelect = (option: AtOption | undefined) => { - if (!option) return - if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) - } - } - - const atKey = (x: AtOption | undefined) => { - if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` - } - - const { - flat: atFlat, - active: atActive, - setActive: setAtActive, - onInput: atOnInput, - onKeyDown: atOnKeyDown, - } = useFilteredList({ - items: async (query) => { - const agents = agentList() - const open = recent() - const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] - const paths = await files.searchFilesAndDirectories(query) - const fileOptions: AtOption[] = paths - .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] - }, - key: atKey, - filterKeys: ["display"], - groupBy: (item) => { - if (item.type === "agent") return "agent" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 - } - return rank(a.category) - rank(b.category) - }, - onSelect: handleAtSelect, - }) - - const slashCommands = createMemo(() => { - const builtin = command.options - .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) - .map((opt) => ({ - id: opt.id, - trigger: opt.slash!, - title: opt.title, - description: opt.description, - keybind: opt.keybind, - type: "builtin" as const, - })) - - const custom = sync.data.command.map((cmd) => ({ - id: `custom.${cmd.name}`, - trigger: cmd.name, - title: cmd.name, - description: cmd.description, - type: "custom" as const, - source: cmd.source, - })) - - return [...custom, ...builtin] - }) - - const handleSlashSelect = (cmd: SlashCommand | undefined) => { - if (!cmd) return - closePopover() - const images = imageAttachments() - - if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` - setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) - focusEditorEnd() - return - } - - clearEditor() - prompt.set([...DEFAULT_PROMPT, ...images], 0) - command.trigger(cmd.id, "slash") - } - - const { - flat: slashFlat, - active: slashActive, - setActive: setSlashActive, - onInput: slashOnInput, - onKeyDown: slashOnKeyDown, - } = useFilteredList({ - items: slashCommands, - key: (x) => x?.id, - filterKeys: ["trigger", "title"], - onSelect: handleSlashSelect, - }) - - const createPill = (part: FileAttachmentPart | AgentPart) => { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) - if (part.type === "agent") pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - return pill - } - - const isNormalizedEditor = () => - Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (!text.includes("\u200B")) return true - if (text !== "\u200B") return false - - const prev = node.previousSibling - const next = node.nextSibling - const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - return !!prevIsBr && !next - } - if (node.nodeType !== Node.ELEMENT_NODE) return false - const el = node as HTMLElement - if (el.dataset.type === "file") return true - if (el.dataset.type === "agent") return true - return el.tagName === "BR" - }) - - const renderEditor = (parts: Prompt) => { - clearEditor() - for (const part of parts) { - if (part.type === "text") { - editorRef.appendChild(createTextFragment(part.content)) - continue - } - if (part.type === "file" || part.type === "agent") { - editorRef.appendChild(createPill(part)) - } - } - - const last = editorRef.lastChild - if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { - editorRef.appendChild(document.createTextNode("\u200B")) - } - } - - // Auto-scroll active command into view when navigating with keyboard - createEffect(() => { - const activeId = slashActive() - if (!activeId || !slashPopoverRef) return - - requestAnimationFrame(() => { - const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - }) - const selectPopoverActive = () => { - if (store.popover === "at") { - const items = atFlat() - if (items.length === 0) return - const active = atActive() - const item = items.find((entry) => atKey(entry) === active) ?? items[0] - handleAtSelect(item) - return - } - - if (store.popover === "slash") { - const items = slashFlat() - if (items.length === 0) return - const active = slashActive() - const item = items.find((entry) => entry.id === active) ?? items[0] - handleSlashSelect(item) - } - } - - const reconcile = (input: Prompt) => { - if (mirror.input) { - mirror.input = false - if (isNormalizedEditor()) return - - renderEditorWithCursor(input) - return - } - - const dom = parseFromDOM() - if (isNormalizedEditor() && isPromptEqual(input, dom)) return - - renderEditorWithCursor(input) - } - - createEffect( - on( - () => prompt.current(), - (parts) => { - if (composing()) return - reconcile(parts.filter((part) => part.type !== "image")) - }, - ), - ) - - const parseFromDOM = (): Prompt => { - const parts: Prompt = [] - let position = 0 - let buffer = "" - - const flushText = () => { - let content = buffer - if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") - if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") - buffer = "" - if (!content) return - parts.push({ type: "text", content, start: position, end: position + content.length }) - position += content.length - } - - const pushFile = (file: HTMLElement) => { - const content = file.textContent ?? "" - parts.push({ - type: "file", - path: file.dataset.path!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const pushAgent = (agent: HTMLElement) => { - const content = agent.textContent ?? "" - parts.push({ - type: "agent", - name: agent.dataset.name!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - buffer += node.textContent ?? "" - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return - - const el = node as HTMLElement - if (el.dataset.type === "file") { - flushText() - pushFile(el) - return - } - if (el.dataset.type === "agent") { - flushText() - pushAgent(el) - return - } - if (el.tagName === "BR") { - buffer += "\n" - return - } - - for (const child of Array.from(el.childNodes)) { - visit(child) - } - } - - const children = Array.from(editorRef.childNodes) - children.forEach((child, index) => { - const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) - visit(child) - if (isBlock && index < children.length - 1) { - buffer += "\n" - } - }) - - flushText() - - if (parts.length === 0) parts.push(...DEFAULT_PROMPT) - return parts - } - - const handleInput = () => { - const rawParts = parseFromDOM() - const images = imageAttachments() - const cursorPosition = getCursorPosition(editorRef) - const rawText = - rawParts.length === 1 && rawParts[0]?.type === "text" - ? rawParts[0].content - : rawParts.map((p) => ("content" in p ? p.content : "")).join("") - const hasNonText = rawParts.some((part) => part.type !== "text") - const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") - const shouldReset = - textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0 - - if (shouldReset) { - closePopover() - resetHistoryNavigation() - if (prompt.dirty()) { - mirror.input = true - prompt.set(DEFAULT_PROMPT, 0) - } - queueScroll() - return - } - - const shellMode = store.mode === "shell" - - if (!shellMode) { - const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) - const slashMatch = rawText.match(/^\/(\S*)$/) - - if (atMatch) { - atOnInput(atMatch[1]) - setStore("popover", "at") - } else if (slashMatch) { - slashOnInput(slashMatch[1]) - setStore("popover", "slash") - } else { - closePopover() - } - } else { - closePopover() - } - - resetHistoryNavigation() - - mirror.input = true - prompt.set([...rawParts, ...images], cursorPosition) - queueScroll() - } - - const addPart = (part: ContentPart) => { - if (part.type === "image") return false - - const selection = window.getSelection() - if (!selection) return false - - if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { - editorRef.focus() - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - setCursorPosition(editorRef, cursor) - } - - if (selection.rangeCount === 0) return false - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return false - - if (part.type === "file" || part.type === "agent") { - const cursorPosition = getCursorPosition(editorRef) - const rawText = prompt - .current() - .map((p) => ("content" in p ? p.content : "")) - .join("") - const textBeforeCursor = rawText.substring(0, cursorPosition) - const atMatch = textBeforeCursor.match(/@(\S*)$/) - const pill = createPill(part) - const gap = document.createTextNode(" ") - - if (atMatch) { - const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(editorRef, range, "start", start) - setRangeEdge(editorRef, range, "end", cursorPosition) - } - - range.deleteContents() - range.insertNode(gap) - range.insertNode(pill) - range.setStartAfter(gap) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - if (part.type === "text") { - const fragment = createTextFragment(part.content) - const last = fragment.lastChild - range.deleteContents() - range.insertNode(fragment) - if (last) { - if (last.nodeType === Node.TEXT_NODE) { - const text = last.textContent ?? "" - if (text === "\u200B") { - range.setStart(last, 0) - } - if (text !== "\u200B") { - range.setStart(last, text.length) - } - } - if (last.nodeType !== Node.TEXT_NODE) { - const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" - const next = last.nextSibling - const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" - if (isBreak && (!next || emptyText)) { - const placeholder = next && emptyText ? next : document.createTextNode("\u200B") - if (!next) last.parentNode?.insertBefore(placeholder, null) - placeholder.textContent = "\u200B" - range.setStart(placeholder, 0) - } else { - range.setStartAfter(last) - } - } - } - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - handleInput() - closePopover() - return true - } - - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const currentHistory = mode === "shell" ? shellHistory : history - const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) - if (next === currentHistory.entries) return - setCurrentHistory("entries", next) - } - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) - } - - for (const item of edit.context) { - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - - setStore("mode", "normal") - setStore("popover", null) - setStore("historyIndex", -1) - setStore("savedPrompt", null) - prompt.set(edit.prompt, promptLength(edit.prompt)) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(edit.prompt)) - queueScroll() - }) - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - const navigateHistory = (direction: "up" | "down") => { - const result = navigatePromptHistory({ - direction, - entries: store.mode === "shell" ? shellHistory.entries : history.entries, - historyIndex: store.historyIndex, - currentPrompt: prompt.current(), - currentComments: historyComments(), - savedPrompt: store.savedPrompt, - }) - if (!result.handled) return false - setStore("historyIndex", result.historyIndex) - setStore("savedPrompt", result.savedPrompt) - applyHistoryPrompt(result.entry, result.cursor) - return true - } - - const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ - editor: () => editorRef, - isDialogActive: () => !!dialog.active, - setDraggingType: (type) => setStore("draggingType", type), - focusEditor: () => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(prompt.current())) - }, - addPart, - readClipboardImage: platform.readClipboardImage, - }) - - const fileAttachmentInput = () => ( - (fileInputRef = el)} - type="file" - multiple - accept={ACCEPTED_FILE_TYPES.join(",")} - class="hidden" - onChange={(e) => { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - ) - - const variants = createMemo(() => ["default", ...local.model.variant.list()]) - // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) - const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) - }) - - const { abort, handleSubmit } = createPromptSubmit({ - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { - event.preventDefault() - if (store.mode !== "normal") return - pick() - return - } - - if (event.key === "Backspace") { - const selection = window.getSelection() - if (selection && selection.isCollapsed) { - const node = selection.anchorNode - const offset = selection.anchorOffset - if (node && node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (/^\u200B+$/.test(text) && offset > 0) { - const range = document.createRange() - range.setStart(node, 0) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - } - } - } - - if (event.key === "!" && store.mode === "normal") { - const cursorPosition = getCursorPosition(editorRef) - if (cursorPosition === 0) { - setStore("mode", "shell") - setStore("popover", null) - event.preventDefault() - return - } - } - - if (event.key === "Escape") { - if (store.popover) { - closePopover() - event.preventDefault() - event.stopPropagation() - return - } - - if (store.mode === "shell") { - setStore("mode", "normal") - event.preventDefault() - event.stopPropagation() - return - } - - if (working()) { - void abort() - event.preventDefault() - event.stopPropagation() - return - } - - if (escBlur()) { - editorRef.blur() - event.preventDefault() - event.stopPropagation() - return - } - } - - if (store.mode === "shell") { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { - setStore("mode", "normal") - event.preventDefault() - return - } - } - - // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input - // and should always insert a newline regardless of composition state - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } - - if (event.key === "Enter" && isImeComposing(event)) { - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - - if (store.popover) { - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (nav || ctrlNav) { - if (store.popover === "at") { - atOnKeyDown(event) - event.preventDefault() - return - } - if (store.popover === "slash") { - slashOnKeyDown(event) - } - event.preventDefault() - return - } - } - - if (ctrl && event.code === "KeyG") { - if (store.popover) { - closePopover() - event.preventDefault() - return - } - if (working()) { - void abort() - event.preventDefault() - } - return - } - - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed } = getCaretState() - if (!collapsed) return - - const cursorPosition = getCursorPosition(editorRef) - const textContent = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - const direction = event.key === "ArrowUp" ? "up" : "down" - if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return - if (navigateHistory(direction)) { - event.preventDefault() - } - return - } - - // Note: Shift+Enter is handled earlier, before IME check - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } - void handleSubmit(event) - } - } - - const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - queryOptions.agents(pathKey(sdk.directory)), - queryOptions.providers(null), - queryOptions.providers(pathKey(sdk.directory)), - ], - })) - - const agentsLoading = () => agentsQuery.isLoading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) - - const [promptReady] = createResource( - () => prompt.ready().promise, - (p) => p, - ) - - const designPlaceholder = () => { - if (store.mode === "shell") return placeholder() - return "Ask anything, / for commands, @ for context..." - } - - const modelControlState = createMemo(() => ({ - loading: providersLoading(), - paid: providers.paid().length > 0, - title: language.t("command.model.choose"), - keybind: command.keybind("model.choose"), - model: local.model, - providerID: local.model.current()?.provider?.id, - modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), - style: control(), - onClose: restoreFocus, - onUnpaidClick: () => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) - }) - }, - })) - - const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) - const projectForDirectory = (directory: string | undefined) => { - if (!directory) return - const key = pathKey(directory) - return projects().find( - (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), - ) - } - const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) - const projectResults = createMemo(() => { - const search = picker.projectSearch.trim().toLowerCase() - if (!search) return projects() - return projects().filter((project) => displayName(project).toLowerCase().includes(search)) - }) - const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0) - const selectProject = (worktree: string) => { - setPicker({ - projectOpen: false, - projectSearch: "", - }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { - restoreFocus() - return - } - layout.projects.open(worktree) - server.projects.touch(worktree) - navigate(`/${base64Encode(worktree)}/session`) - } - const addProject = async () => { - const conn = server.current - if (!conn) return - const select = (result: string | string[] | null) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - selectProject(directory) - } - if (platform.openDirectoryPickerDialog && server.isLocal()) { - select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") })) - return - } - void import("@/components/dialog-select-directory").then((x) => { - dialog.show( - () => , - () => select(null), - ) - }) - } - - const projectPickerState = createMemo(() => ({ - open: picker.projectOpen, - trigger: { - action: "prompt-project", - icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), - class: "max-w-[203px]", - style: control(), - onPress: () => setPicker("projectOpen", true), - }, - search: picker.projectSearch, - searchPlaceholder: language.t("session.new.project.search"), - clearLabel: language.t("common.clear"), - items: projectResults().map((project) => ({ - icon: "folder", - label: displayName(project), - selected: selectedProject()?.worktree === project.worktree, - onSelect: () => selectProject(project.worktree), - })), - action: { - icon: "plus", - label: language.t("session.new.project.add"), - onSelect: () => { - setPicker("projectOpen", false) - void addProject() - }, - }, - onOpenChange: (open) => { - setPicker("projectOpen", open) - if (open) requestAnimationFrame(() => projectSearchRef?.focus()) - }, - onSearchInput: (value) => setPicker("projectSearch", value), - onSearchClear: () => setPicker("projectSearch", ""), - searchRef: (el) => (projectSearchRef = el), - })) - const agentControlState = createMemo(() => ({ - title: language.t("command.agent.cycle"), - keybind: command.keybind("agent.cycle"), - options: agentNames(), - current: local.agent.current()?.name ?? "", - style: control(), - onSelect: (value) => { - local.agent.set(value) - restoreFocus() - }, - })) - const newProjectTriggerState = createMemo(() => ({ - action: "prompt-project", - icon: "folder-add-left", - label: language.t("session.new.project.new"), - class: "max-w-[160px]", - style: control(), - onPress: () => void addProject(), - })) - - return ( -
- {(promptReady(), null)} - (slashPopoverRef = el)} - atFlat={atFlat()} - atActive={atActive() ?? undefined} - atKey={atKey} - setAtActive={setAtActive} - onAtSelect={handleAtSelect} - slashFlat={slashFlat()} - slashActive={slashActive() ?? undefined} - setSlashActive={setSlashActive} - onSlashSelect={handleSlashSelect} - commandKeybind={command.keybind} - t={(key) => language.t(key as Parameters[0])} - /> - - -
- - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action^="prompt-"]')) return - editorRef?.focus() - }} - > -
(scrollRef = el)}> -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={designPlaceholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - inputMode="text" - // @ts-expect-error - autocomplete="off" - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - /> -
- {designPlaceholder()} -
-
-
-
-
- {fileAttachmentInput()} - - - - - - - - - - - -
- - { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - -
- - - -
-
- -
-
0.5 ? "auto" : "none", - }} - > - - - -
-
-
- - - -
-
-
- - {language.t("prompt.mode.shell")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - local.model.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
-
- - -
-
-
- - - - -
- ) -} - -type ComposerPickerItemState = { - icon: IconProps["name"] - label: string - selected?: boolean - onSelect: () => void -} - -type ComposerPickerTriggerState = { - action: string - icon?: IconProps["name"] - label: string - class?: string - style: JSX.CSSProperties | undefined - onPress: () => void -} - -type ComposerPickerState = { - open: boolean - trigger: ComposerPickerTriggerState - search: string - searchPlaceholder: string - clearLabel: string - items: ComposerPickerItemState[] - action: ComposerPickerItemState - listClass?: string - searchRef: (el: HTMLInputElement) => void - onOpenChange: (open: boolean) => void - onSearchInput: (value: string) => void - onSearchClear: () => void -} - -type ComposerAgentControlState = { - title: string - keybind: string - options: string[] - current: string - style: JSX.CSSProperties | undefined - onSelect: (value: string | undefined) => void -} - -type ComposerModelControlState = { - loading: boolean - paid: boolean - title: string - keybind: string - model: ReturnType["model"] - providerID?: string - modelName: string - style: JSX.CSSProperties | undefined - onClose: () => void - onUnpaidClick: () => void -} - -function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) { - const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"]) - return ( - - ) -} - -function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) { - return ( - - ) -} - -function ComposerPicker(props: { state: ComposerPickerState }) { - return ( - - - - event.preventDefault()} - > -
-
- - props.state.onSearchInput(event.currentTarget.value)} - /> - - - -
- {(item) => } -
-
-
- -
- - - - ) -} - -function ComposerAgentControl(props: { state: ComposerAgentControlState }) { - return ( -
-
- -
- - o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - /> - - - -
- -
-
- - - o.value === theme.colorScheme())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - triggerStyle={{ "min-width": "220px" }} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} - - } - > - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - /> - - -
- ) - - const UpdatesSection = () => ( -
-

{language.t("settings.general.section.updates")}

- - - -
- settings.updates.setStartup(checked)} - /> -
-
- - -
- settings.general.setReleaseNotes(checked)} - /> -
-
- - - - -
-
- ) - - const DisplaySection = () => ( - -
-

{language.t("settings.general.section.display")}

- - - -
- -
-
- - - - {language.t("settings.general.row.wayland.title")} - - - - - -
- } - description={language.t("settings.general.row.wayland.description")} - > -
- -
- -
- -
-
- ) - - return ( -
-
-
-

{language.t("settings.tab.general")}

-
-
- -
- - - - - - - - - - - - - - - -
-
- ) -} - -interface SettingsRowProps { - title: string | JSX.Element - description: string | JSX.Element - children: JSX.Element -} - -const SettingsRow: Component = (props) => { - return ( -
-
- {props.title} - {props.description} -
-
{props.children}
-
- ) -} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx deleted file mode 100644 index 035eb3285695..000000000000 --- a/packages/app/src/components/terminal.tsx +++ /dev/null @@ -1,667 +0,0 @@ -import { withAlpha } from "@opencode-ai/ui/theme/color" -import { useTheme } from "@opencode-ai/ui/theme/context" -import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" -import { showToast } from "@/utils/toast" -import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" -import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" -import { SerializeAddon } from "@/addons/serialize" -import { matchKeybind, parseKeybind } from "@/context/command" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -import { terminalFontFamily, useSettings } from "@/context/settings" -import type { LocalPTY } from "@/context/terminal" -import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" -import { terminalWriter } from "@/utils/terminal-writer" -import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" - -const TOGGLE_TERMINAL_ID = "terminal.toggle" -const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" -export interface TerminalProps extends ComponentProps<"div"> { - pty: LocalPTY - autoFocus?: boolean - onSubmit?: () => void - onCleanup?: (pty: Partial & { id: string }) => void - onConnect?: () => void - onConnectError?: (error: unknown) => void -} - -let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined - -const loadGhostty = () => { - if (shared) return shared - shared = import("ghostty-web") - .then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() })) - .catch((err) => { - shared = undefined - throw err - }) - return shared -} - -type TerminalColors = { - background: string - foreground: string - cursor: string - selectionBackground: string -} - -const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { - light: { - background: "#fcfcfc", - foreground: "#211e1e", - cursor: "#211e1e", - selectionBackground: withAlpha("#211e1e", 0.2), - }, - dark: { - background: "#191515", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - selectionBackground: withAlpha("#d4d4d4", 0.25), - }, -} - -const debugTerminal = (...values: unknown[]) => { - if (!import.meta.env.DEV) return - console.debug("[terminal]", ...values) -} - -const useTerminalUiBindings = (input: { - container: HTMLDivElement - term: Term - cleanups: VoidFunction[] - handlePointerDown: () => void - handleLinkClick: (event: MouseEvent) => void -}) => { - const handleCopy = (event: ClipboardEvent) => { - const selection = input.term.getSelection() - if (!selection) return - - const clipboard = event.clipboardData - if (!clipboard) return - - event.preventDefault() - clipboard.setData("text/plain", selection) - } - - const handlePaste = (event: ClipboardEvent) => { - const clipboard = event.clipboardData - const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" - if (!text) return - - event.preventDefault() - event.stopPropagation() - input.term.paste(text) - } - - const handleTextareaFocus = () => { - input.term.options.cursorBlink = true - } - const handleTextareaBlur = () => { - input.term.options.cursorBlink = false - } - - input.container.addEventListener("copy", handleCopy, true) - input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) - - input.container.addEventListener("paste", handlePaste, true) - input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) - - input.container.addEventListener("pointerdown", input.handlePointerDown) - input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) - - input.container.addEventListener("click", input.handleLinkClick, { - capture: true, - }) - input.cleanups.push(() => - input.container.removeEventListener("click", input.handleLinkClick, { - capture: true, - }), - ) - - input.term.textarea?.addEventListener("focus", handleTextareaFocus) - input.term.textarea?.addEventListener("blur", handleTextareaBlur) - input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) - input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) -} - -const persistTerminal = (input: { - term: Term | undefined - addon: SerializeAddon | undefined - cursor: number - id: string - onCleanup?: (pty: Partial & { id: string }) => void -}) => { - if (!input.addon || !input.onCleanup || !input.term) return - const buffer = (() => { - try { - return input.addon.serialize() - } catch { - debugTerminal("failed to serialize terminal buffer") - return "" - } - })() - - input.onCleanup({ - id: input.id, - buffer, - cursor: input.cursor, - rows: input.term.rows, - cols: input.term.cols, - scrollY: input.term.getViewportY(), - }) -} - -export const Terminal = (props: TerminalProps) => { - const platform = usePlatform() - const sdk = useSDK() - const settings = useSettings() - const theme = useTheme() - const language = useLanguage() - const server = useServer() - const directory = sdk.directory - const client = sdk.client - const url = sdk.url - const auth = server.current?.http - const username = auth?.username ?? "opencode" - const password = auth?.password ?? "" - const sameOrigin = new URL(url, location.href).origin === location.origin - let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) - const id = local.pty.id - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" - const restoreSize = - restore && - typeof local.pty.cols === "number" && - Number.isSafeInteger(local.pty.cols) && - local.pty.cols > 0 && - typeof local.pty.rows === "number" && - Number.isSafeInteger(local.pty.rows) && - local.pty.rows > 0 - ? { cols: local.pty.cols, rows: local.pty.rows } - : undefined - const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined - let ws: WebSocket | undefined - let term: Term | undefined - let _ghostty: Ghostty - let serializeAddon: SerializeAddon - let fitAddon: FitAddon - let handleResize: () => void - let fitFrame: number | undefined - let sizeTimer: ReturnType | undefined - let pendingSize: { cols: number; rows: number } | undefined - let lastSize: { cols: number; rows: number } | undefined - let disposed = false - const cleanups: VoidFunction[] = [] - const start = - typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined - let cursor = start ?? 0 - let seek = start !== undefined ? start : restore ? -1 : 0 - let output: ReturnType | undefined - let drop: VoidFunction | undefined - let reconn: ReturnType | undefined - let tries = 0 - - const cleanup = () => { - if (!cleanups.length) return - const fns = cleanups.splice(0).reverse() - for (const fn of fns) { - try { - fn() - } catch (err) { - debugTerminal("cleanup failed", err) - } - } - } - - const pushSize = (cols: number, rows: number) => { - return client.pty - .update({ - ptyID: id, - size: { cols, rows }, - }) - .catch((err) => { - debugTerminal("failed to sync terminal size", err) - }) - } - - const getTerminalColors = (): TerminalColors => { - const mode = theme.mode() === "dark" ? "dark" : "light" - const fallback = DEFAULT_TERMINAL_COLORS[mode] - const currentTheme = theme.themes()[theme.themeId()] - if (!currentTheme) return fallback - const variant = mode === "dark" ? currentTheme.dark : currentTheme.light - if (!variant?.seeds && !variant?.palette) return fallback - const resolved = resolveThemeVariant(variant, mode === "dark") - const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background - const alpha = mode === "dark" ? 0.25 : 0.2 - const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) - const selectionBackground = withAlpha(base, alpha) - return { - background, - foreground: text, - cursor: text, - selectionBackground, - } - } - - const terminalColors = createMemo(getTerminalColors) - - const scheduleFit = () => { - if (disposed) return - if (!fitAddon) return - if (fitFrame !== undefined) return - - fitFrame = requestAnimationFrame(() => { - fitFrame = undefined - if (disposed) return - fitAddon.fit() - }) - } - - const scheduleSize = (cols: number, rows: number) => { - if (disposed) return - if (lastSize?.cols === cols && lastSize?.rows === rows) return - - pendingSize = { cols, rows } - - if (!lastSize) { - lastSize = pendingSize - void pushSize(cols, rows) - return - } - - if (sizeTimer !== undefined) return - sizeTimer = setTimeout(() => { - sizeTimer = undefined - const next = pendingSize - if (!next) return - pendingSize = undefined - if (disposed) return - if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return - lastSize = next - void pushSize(next.cols, next.rows) - }, 100) - } - - createEffect(() => { - const colors = terminalColors() - if (!term) return - setOptionIfSupported(term, "theme", colors) - }) - - createEffect(() => { - const font = terminalFontFamily(settings.appearance.terminalFont()) - if (!term) return - setOptionIfSupported(term, "fontFamily", font) - scheduleFit() - }) - - let zoom = platform.webviewZoom?.() - createEffect(() => { - const next = platform.webviewZoom?.() - if (next === undefined) return - if (next === zoom) return - zoom = next - scheduleFit() - }) - - const focusTerminal = () => { - const t = term - if (!t) return - t.focus() - t.textarea?.focus() - setTimeout(() => t.textarea?.focus(), 0) - } - const handlePointerDown = () => { - const activeElement = document.activeElement - if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) { - activeElement.blur() - } - focusTerminal() - } - - const handleLinkClick = (event: MouseEvent) => { - if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return - if (event.altKey) return - if (event.button !== 0) return - - const t = term - if (!t) return - - const text = getHoveredLinkText(t) - if (!text) return - - event.preventDefault() - event.stopImmediatePropagation() - platform.openLink(text) - } - - onMount(() => { - const run = async () => { - const loaded = await loadGhostty() - if (disposed) return - - const mod = loaded.mod - const g = loaded.ghostty - - const t = new mod.Terminal({ - cursorBlink: true, - cursorStyle: "bar", - cols: restoreSize?.cols, - rows: restoreSize?.rows, - fontSize: 14, - fontFamily: terminalFontFamily(settings.appearance.terminalFont()), - allowTransparency: false, - convertEol: false, - theme: terminalColors(), - scrollback: 10_000, - ghostty: g, - }) - cleanups.push(() => t.dispose()) - if (disposed) { - cleanup() - return - } - _ghostty = g - term = t - output = terminalWriter((data, done) => - t.write(data, () => { - done?.() - }), - ) - - t.attachCustomKeyEventHandler((event) => { - const key = event.key.toLowerCase() - - if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { - document.execCommand("copy") - return true - } - - // allow for toggle terminal keybinds in parent - const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND - const keybinds = parseKeybind(config) - - return matchKeybind(keybinds, event) - }) - - const fit = new mod.FitAddon() - const serializer = new SerializeAddon() - cleanups.push(() => disposeIfDisposable(fit)) - t.loadAddon(serializer) - t.loadAddon(fit) - fitAddon = fit - serializeAddon = serializer - - t.open(container) - useTerminalUiBindings({ - container, - term: t, - cleanups, - handlePointerDown, - handleLinkClick, - }) - - if (local.autoFocus !== false) focusTerminal() - - if (typeof document !== "undefined" && document.fonts) { - void document.fonts.ready.then(scheduleFit) - } - - const onResize = t.onResize((size) => { - scheduleSize(size.cols, size.rows) - }) - cleanups.push(() => disposeIfDisposable(onResize)) - const onData = t.onData((data) => { - if (ws?.readyState === WebSocket.OPEN) ws.send(data) - }) - cleanups.push(() => disposeIfDisposable(onData)) - const onKey = t.onKey((key) => { - if (key.key == "Enter") { - props.onSubmit?.() - } - }) - cleanups.push(() => disposeIfDisposable(onKey)) - - const startResize = () => { - fit.observeResize() - handleResize = scheduleFit - window.addEventListener("resize", handleResize) - cleanups.push(() => window.removeEventListener("resize", handleResize)) - } - - const write = (data: string) => - new Promise((resolve) => { - if (!output) { - resolve() - return - } - output.push(data) - output.flush(resolve) - }) - - if (restore && restoreSize) { - await write(restore) - fit.fit() - scheduleSize(t.cols, t.rows) - if (scrollY !== undefined) t.scrollToLine(scrollY) - startResize() - } else { - fit.fit() - scheduleSize(t.cols, t.rows) - if (restore) { - await write(restore) - if (scrollY !== undefined) t.scrollToLine(scrollY) - } - startResize() - } - - const once = { value: false } - const decoder = new TextDecoder() - - const fail = (err: unknown) => { - if (disposed) return - if (once.value) return - once.value = true - local.onConnectError?.(err) - } - - const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) - .catch((err) => { - debugTerminal("failed to inspect terminal session", err) - return false - }) - - const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) - .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return - throw err - }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) - } - - const retry = (err: unknown) => { - if (disposed) return - if (reconn !== undefined) return - - const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000) - reconn = setTimeout(async () => { - reconn = undefined - if (disposed) return - if (await gone()) { - if (disposed) return - fail(err) - return - } - if (disposed) return - tries += 1 - open() - }, ms) - } - - const open = async () => { - if (disposed) return - drop?.() - - const ticket = await connectToken().catch((err) => { - fail(err) - return undefined - }) - if (once.value) return - if (disposed) return - - const socket = new WebSocket( - terminalWebSocketURL({ - url, - id, - directory, - cursor: seek, - ticket, - sameOrigin, - username, - password, - authToken: server.current?.type === "http" ? server.current.authToken : false, - }), - ) - socket.binaryType = "arraybuffer" - ws = socket - - const handleOpen = () => { - if (disposed) return - tries = 0 - local.onConnect?.() - scheduleSize(t.cols, t.rows) - } - - const handleMessage = (event: MessageEvent) => { - if (disposed) return - if (event.data instanceof ArrayBuffer) { - const bytes = new Uint8Array(event.data) - if (bytes[0] !== 0) return - const json = decoder.decode(bytes.subarray(1)) - try { - const meta = JSON.parse(json) as { cursor?: unknown } - const next = meta?.cursor - if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { - cursor = next - seek = next - } - } catch (err) { - debugTerminal("invalid websocket control frame", err) - } - return - } - - const data = typeof event.data === "string" ? event.data : "" - if (!data) return - output?.push(data) - cursor += data.length - seek = cursor - } - - const handleError = (error: Event) => { - if (disposed) return - debugTerminal("websocket error", error) - } - - const stop = () => { - socket.removeEventListener("open", handleOpen) - socket.removeEventListener("message", handleMessage) - socket.removeEventListener("error", handleError) - socket.removeEventListener("close", handleClose) - if (ws === socket) ws = undefined - if (drop === stop) drop = undefined - if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000) - } - - const handleClose = (event: CloseEvent) => { - if (ws === socket) ws = undefined - if (drop === stop) drop = undefined - socket.removeEventListener("open", handleOpen) - socket.removeEventListener("message", handleMessage) - socket.removeEventListener("error", handleError) - socket.removeEventListener("close", handleClose) - if (disposed) return - if (event.code === 1000) return - retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) - } - - drop = stop - socket.addEventListener("open", handleOpen) - socket.addEventListener("message", handleMessage) - socket.addEventListener("error", handleError) - socket.addEventListener("close", handleClose) - } - - open() - } - - void run().catch((err) => { - if (disposed) return - showToast({ - variant: "error", - title: language.t("terminal.connectionLost.title"), - description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"), - }) - local.onConnectError?.(err) - }) - }) - - onCleanup(() => { - disposed = true - if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) - if (sizeTimer !== undefined) clearTimeout(sizeTimer) - if (reconn !== undefined) clearTimeout(reconn) - drop?.() - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) - - const finalize = () => { - persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) - cleanup() - } - - if (!output) { - finalize() - return - } - - output.flush(finalize) - }) - - return ( -
- ) -} diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts deleted file mode 100644 index 22bd26271529..000000000000 --- a/packages/app/src/context/global-sync/child-store.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" -import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" -import { - DIR_IDLE_TTL_MS, - MAX_DIR_STORES, - type ChildOptions, - type DirState, - type IconCache, - type MetaCache, - type ProjectMeta, - type State, - type VcsCache, -} from "./types" -import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" -import { useQuery } from "@tanstack/solid-query" -import { QueryOptionsApi } from "../server-sync" -import { directoryKey, type DirectoryKey } from "./utils" -import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" - -export function createChildStoreManager(input: { - owner: Owner - isBooting: (directory: string) => boolean - isLoadingSessions: (directory: string) => boolean - onBootstrap: (directory: string) => void - onMcp: (directory: string, setStore: SetStoreFunction) => void - onDispose: (directory: string) => void - translate: (key: string, vars?: Record) => string - queryOptions: QueryOptionsApi - global: { - provider: NormalizedProviderListResponse - } -}) { - const children: Record, SetStoreFunction]> = {} - const vcsCache = new Map() - const metaCache = new Map() - const iconCache = new Map() - const lifecycle = new Map() - const pins = new Map() - const ownerPins = new WeakMap>() - const disposers = new Map void>() - const mcpDirectories = new Set() - const mcpToggles = new Map void>() - - const markKey = (key: DirectoryKey) => { - if (!key) return - lifecycle.set(key, { lastAccessAt: Date.now() }) - runEviction(key) - } - - const mark = (directory: string) => { - const key = directoryKey(directory) - markKey(key) - } - - const pin = (directory: string) => { - const key = directoryKey(directory) - if (!key) return - pins.set(key, (pins.get(key) ?? 0) + 1) - markKey(key) - } - - const unpin = (directory: string) => { - const key = directoryKey(directory) - if (!key) return - const next = (pins.get(key) ?? 0) - 1 - if (next > 0) { - pins.set(key, next) - return - } - pins.delete(key) - runEviction() - } - - const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0 - - const pinForOwner = (directory: string) => { - const current = getOwner() - if (!current) return - if (current === input.owner) return - const key = current as object - const set = ownerPins.get(key) - if (set?.has(directory)) return - if (set) set.add(directory) - if (!set) ownerPins.set(key, new Set([directory])) - pin(directory) - onCleanup(() => { - const set = ownerPins.get(key) - if (set) { - set.delete(directory) - if (set.size === 0) ownerPins.delete(key) - } - unpin(directory) - }) - } - - function disposeDirectory(directory: DirectoryKey) { - const key = directory - if ( - !canDisposeDirectory({ - directory: key, - hasStore: !!children[key], - pinned: pinned(key), - booting: input.isBooting(key), - loadingSessions: input.isLoadingSessions(key), - }) - ) { - return false - } - - vcsCache.delete(key) - metaCache.delete(key) - iconCache.delete(key) - lifecycle.delete(key) - mcpDirectories.delete(key) - mcpToggles.delete(key) - const dispose = disposers.get(key) - if (dispose) { - dispose() - disposers.delete(key) - } - delete children[key] - input.onDispose(key) - return true - } - - function runEviction(skip?: string) { - const stores = Object.keys(children) - if (stores.length === 0) return - const list = pickDirectoriesToEvict({ - stores, - state: lifecycle, - pins: new Set(stores.filter(pinned)), - max: MAX_DIR_STORES, - ttl: DIR_IDLE_TTL_MS, - now: Date.now(), - }).filter((directory) => directory !== skip) - if (list.length === 0) return - for (const directory of list) { - if (!disposeDirectory(directoryKey(directory))) continue - } - } - - function ensureChild(directory: string) { - const key = directoryKey(directory) - if (!key) console.error("No directory provided") - if (!children[key]) { - const vcs = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "vcs", ["vcs.v1"]), - createStore({ value: undefined as VcsInfo | undefined }), - ), - ) - if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) - const vcsStore = vcs[0] - vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) - - const meta = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "project", ["project.v1"]), - createStore({ value: undefined as ProjectMeta | undefined }), - ), - ) - if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) - metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) - - const icon = runWithOwner(input.owner, () => - persisted( - Persist.workspace(directory, "icon", ["icon.v1"]), - createStore({ value: undefined as string | undefined }), - ), - ) - if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) - iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) - - const init = () => - createRoot((dispose) => { - const initialMeta = meta[0].value - const initialIcon = icon[0].value - const [mcpEnabled, setMcpEnabled] = createSignal(false) - - const pathQuery = useQuery(() => input.queryOptions.path(key)) - const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) - const lspQuery = useQuery(() => input.queryOptions.lsp(key)) - const providerQuery = useQuery(() => input.queryOptions.providers(key)) - - const child = createStore({ - project: "", - projectMeta: initialMeta, - icon: initialIcon, - get provider_ready() { - return !providerQuery.isLoading - }, - get provider() { - const EMPTY = { all: new Map(), connected: [], default: {} } - if (providerQuery.isLoading) return EMPTY - if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider - return providerQuery.data ?? EMPTY - }, - config: {}, - get path() { - const EMPTY = { state: "", config: "", worktree: "", directory, home: "" } - if (pathQuery.isLoading) return EMPTY - return pathQuery.data ?? EMPTY - }, - status: "loading" as const, - agent: [], - command: [], - session: [], - sessionTotal: 0, - session_status: {}, - session_working(id: string) { - const type = this.session_status[id]?.type - return (type ?? "idle") !== "idle" - }, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - get mcp_ready() { - return !mcpQuery.isLoading - }, - get mcp() { - return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) - }, - get lsp_ready() { - return !lspQuery.isLoading - }, - get lsp() { - return lspQuery.isLoading ? [] : (lspQuery.data ?? []) - }, - vcs: vcsStore.value, - limit: 5, - message: {}, - part: {}, - part_text_accum_delta: {}, - }) - children[key] = child - disposers.set(key, dispose) - mcpToggles.set(key, setMcpEnabled) - - const onPersistedInit = (init: Promise | string | null, run: () => void) => { - if (!(init instanceof Promise)) return - void init.then(() => { - if (children[key] !== child) return - run() - }) - } - - onPersistedInit(vcs[2], () => { - const cached = vcsStore.value - if (!cached?.branch) return - child[1]("vcs", (value) => value ?? cached) - }) - - onPersistedInit(meta[2], () => { - if (child[0].projectMeta !== initialMeta) return - child[1]("projectMeta", meta[0].value) - }) - - onPersistedInit(icon[2], () => { - if (child[0].icon !== initialIcon) return - child[1]("icon", icon[0].value) - }) - }) - - runWithOwner(input.owner, init) - } - markKey(key) - const childStore = children[key] - if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) - return childStore - } - - function child(directory: string, options: ChildOptions = {}) { - const key = directoryKey(directory) - const childStore = ensureChild(directory) - pinForOwner(key) - if (options.mcp) enableMcp(directory, key, childStore) - const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap && childStore[0].status === "loading") { - input.onBootstrap(directory) - } - return childStore - } - - function peek(directory: string, options: ChildOptions = {}) { - const key = directoryKey(directory) - const childStore = ensureChild(directory) - if (options.mcp) enableMcp(directory, key, childStore) - const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap && childStore[0].status === "loading") { - input.onBootstrap(directory) - } - return childStore - } - - function enableMcp(directory: string, key: DirectoryKey, childStore: [Store, SetStoreFunction]) { - if (mcpDirectories.has(key)) return - mcpDirectories.add(key) - mcpToggles.get(key)?.(true) - if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) - } - - function disableMcp(directory: string) { - const key = directoryKey(directory) - if (!mcpDirectories.delete(key)) return - mcpToggles.get(key)?.(false) - } - - function projectMeta(directory: string, patch: ProjectMeta) { - const key = directoryKey(directory) - const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(key) - if (!cached) return - const previous = store.projectMeta ?? {} - const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon - const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands - const next = { - ...previous, - ...patch, - icon, - commands, - } - cached.setStore("value", next) - setStore("projectMeta", next) - } - - function projectIcon(directory: string, value: string | undefined) { - const key = directoryKey(directory) - const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(key) - if (!cached) return - if (store.icon === value) return - cached.setStore("value", value) - setStore("icon", value) - } - - return { - children, - ensureChild, - child, - peek, - projectMeta, - projectIcon, - mark, - pin, - unpin, - pinned, - mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), - disableMcp, - disposeDirectory, - runEviction, - vcsCache, - metaCache, - iconCache, - } -} diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f02ac5c7be43..3d8080a70eda 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -75,6 +75,7 @@ const baseState = (input: Partial = {}) => todo: {}, permission: {}, question: {}, + steer_queue: {}, mcp: {}, lsp: [], vcs: undefined, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts deleted file mode 100644 index 5b72d37f9df4..000000000000 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Binary } from "@opencode-ai/core/util/binary" -import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import type { - Message, - Part, - PermissionRequest, - Project, - QuestionRequest, - Session, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" -import type { State, VcsCache } from "./types" -import { trimSessions } from "./session-trim" -import { dropSessionCaches } from "./session-cache" -import { diffs as list, message as clean } from "@/utils/diffs" - -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) - -export function applyGlobalEvent(input: { - event: { type: string; properties?: unknown } - project: Project[] - setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void - refresh: () => void -}) { - if (input.event.type === "global.disposed" || input.event.type === "server.connected") { - input.refresh() - return - } - - if (input.event.type !== "project.updated") return - const properties = input.event.properties as Project - const result = Binary.search(input.project, properties.id, (s) => s.id) - if (result.found) { - input.setGlobalProject( - produce((draft) => { - draft[result.index] = { ...draft[result.index], ...properties } - }), - ) - return - } - input.setGlobalProject( - produce((draft) => { - draft.splice(result.index, 0, properties) - }), - ) -} - -function cleanupSessionCaches( - setStore: SetStoreFunction, - sessionID: string, - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { - if (!sessionID) return - setSessionTodo?.(sessionID, undefined) - setStore( - produce((draft) => { - dropSessionCaches(draft, [sessionID]) - }), - ) -} - -export function cleanupDroppedSessionCaches( - store: Store, - setStore: SetStoreFunction, - next: Session[], - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { - const keep = new Set(next.map((item) => item.id)) - const stale = [ - ...Object.keys(store.message), - ...Object.keys(store.session_diff), - ...Object.keys(store.todo), - ...Object.keys(store.permission), - ...Object.keys(store.question), - ...Object.keys(store.session_status), - ...Object.values(store.part) - .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) - .filter((sessionID): sessionID is string => !!sessionID), - ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) - if (stale.length === 0) return - for (const sessionID of stale) { - setSessionTodo?.(sessionID, undefined) - } - setStore( - produce((draft) => { - dropSessionCaches(draft, stale) - }), - ) -} - -export function applyDirectoryEvent(input: { - event: { type: string; properties?: unknown } - store: Store - setStore: SetStoreFunction - push: (directory: string) => void - directory: string - loadLsp: () => void - vcsCache?: VcsCache - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void -}) { - const event = input.event - switch (event.type) { - case "server.instance.disposed": { - input.push(input.directory) - return - } - case "session.created": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) - if (result.found) { - input.setStore("session", result.index, reconcile(info)) - break - } - const next = input.store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) - input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) - if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) - break - } - case "session.updated": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) - if (info.time.archived) { - if (input.store.session[result.index]!.time.archived === info.time.archived) break - if (result.found) { - input.setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break - input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - if (result.found) { - input.setStore("session", result.index, reconcile(info)) - break - } - const next = input.store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) - input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) - break - } - case "session.deleted": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) - if (result.found) { - input.setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break - input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) - break - } - case "todo.updated": { - const props = event.properties as { sessionID: string; todos: Todo[] } - input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - input.setSessionTodo?.(props.sessionID, props.todos) - break - } - case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } - input.setStore("session_status", props.sessionID, reconcile(props.status)) - break - } - case "message.updated": { - const info = clean((event.properties as { info: Message }).info) - const messages = input.store.message[info.sessionID] - if (!messages) { - input.setStore("message", info.sessionID, [info]) - break - } - const result = Binary.search(messages, info.id, (m) => m.id) - if (result.found) { - input.setStore("message", info.sessionID, result.index, reconcile(info)) - break - } - input.setStore( - "message", - info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, info) - }), - ) - break - } - case "message.removed": { - const props = event.properties as { sessionID: string; messageID: string } - input.setStore( - produce((draft) => { - const messages = draft.message[props.sessionID] - if (messages) { - const result = Binary.search(messages, props.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - const parts = draft.part[props.messageID] - if (parts) { - for (const part of parts) { - delete draft.part_text_accum_delta[part.id] - } - } - delete draft.part[props.messageID] - }), - ) - break - } - case "message.part.updated": { - const part = (event.properties as { part: Part }).part - if (SKIP_PARTS.has(part.type)) break - input.setStore( - produce((draft) => { - delete draft.part_text_accum_delta[part.id] - }), - ) - const parts = input.store.part[part.messageID] - if (!parts) { - input.setStore("part", part.messageID, [part]) - break - } - const result = Binary.search(parts, part.id, (p) => p.id) - if (result.found) { - input.setStore("part", part.messageID, result.index, reconcile(part)) - break - } - input.setStore( - "part", - part.messageID, - produce((draft) => { - draft.splice(result.index, 0, part) - }), - ) - break - } - case "message.part.removed": { - const props = event.properties as { messageID: string; partID: string } - input.setStore( - produce((draft) => { - delete draft.part_text_accum_delta[props.partID] - }), - ) - const parts = input.store.part[props.messageID] - if (!parts) break - const result = Binary.search(parts, props.partID, (p) => p.id) - if (result.found) { - input.setStore( - produce((draft) => { - const list = draft.part[props.messageID] - if (!list) return - const next = Binary.search(list, props.partID, (p) => p.id) - if (!next.found) return - list.splice(next.index, 1) - if (list.length === 0) delete draft.part[props.messageID] - }), - ) - } - break - } - case "message.part.delta": { - const props = event.properties as { messageID: string; partID: string; field: string; delta: string } - const parts = input.store.part[props.messageID] - if (!parts) break - const result = Binary.search(parts, props.partID, (p) => p.id) - if (!result.found) break - input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) - input.setStore( - "part", - props.messageID, - produce((draft) => { - const part = draft[result.index] - const field = props.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + props.delta - }), - ) - break - } - case "vcs.branch.updated": { - const props = event.properties as { branch?: string } - if (input.store.vcs?.branch === props.branch) break - const next = { ...input.store.vcs, branch: props.branch } - input.setStore("vcs", next) - if (input.vcsCache) input.vcsCache.setStore("value", next) - break - } - case "permission.asked": { - const permission = event.properties as PermissionRequest - const permissions = input.store.permission[permission.sessionID] - if (!permissions) { - input.setStore("permission", permission.sessionID, [permission]) - break - } - const result = Binary.search(permissions, permission.id, (p) => p.id) - if (result.found) { - input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) - break - } - input.setStore( - "permission", - permission.sessionID, - produce((draft) => { - draft.splice(result.index, 0, permission) - }), - ) - break - } - case "permission.replied": { - const props = event.properties as { sessionID: string; requestID: string } - const permissions = input.store.permission[props.sessionID] - if (!permissions) break - const result = Binary.search(permissions, props.requestID, (p) => p.id) - if (!result.found) break - input.setStore( - "permission", - props.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - case "question.asked": { - const question = event.properties as QuestionRequest - const questions = input.store.question[question.sessionID] - if (!questions) { - input.setStore("question", question.sessionID, [question]) - break - } - const result = Binary.search(questions, question.id, (q) => q.id) - if (result.found) { - input.setStore("question", question.sessionID, result.index, reconcile(question)) - break - } - input.setStore( - "question", - question.sessionID, - produce((draft) => { - draft.splice(result.index, 0, question) - }), - ) - break - } - case "question.replied": - case "question.rejected": { - const props = event.properties as { sessionID: string; requestID: string } - const questions = input.store.question[props.sessionID] - if (!questions) break - const result = Binary.search(questions, props.requestID, (q) => q.id) - if (!result.found) break - input.setStore( - "question", - props.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - case "lsp.updated": { - input.loadLsp() - break - } - } -} diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts deleted file mode 100644 index 8db24b904b29..000000000000 --- a/packages/app/src/context/global-sync/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - Agent, - Command, - Config, - LspStatus, - McpStatus, - Message, - Part, - Path, - PermissionRequest, - QuestionRequest, - Session, - SessionStatus, - SnapshotFileDiff, - Todo, - VcsInfo, -} from "@opencode-ai/sdk/v2/client" -import { NormalizedProviderListResponse } from "@opencode-ai/ui/context" -import type { Accessor } from "solid-js" -import type { SetStoreFunction, Store } from "solid-js/store" - -export type ProjectMeta = { - name?: string - icon?: { - override?: string - color?: string - } - commands?: { - start?: string - } -} - -export type State = { - status: "loading" | "partial" | "complete" - agent: Agent[] - command: Command[] - project: string - projectMeta: ProjectMeta | undefined - icon: string | undefined - provider_ready: boolean - provider: NormalizedProviderListResponse - config: Config - path: Path - session: Session[] - sessionTotal: number - session_status: { - [sessionID: string]: SessionStatus - } - session_working(id: string): boolean - session_diff: { - [sessionID: string]: SnapshotFileDiff[] - } - todo: { - [sessionID: string]: Todo[] - } - permission: { - [sessionID: string]: PermissionRequest[] - } - question: { - [sessionID: string]: QuestionRequest[] - } - mcp_ready: boolean - mcp: { - [name: string]: McpStatus - } - lsp_ready: boolean - lsp: LspStatus[] - vcs: VcsInfo | undefined - limit: number - message: { - [sessionID: string]: Message[] - } - part: { - [messageID: string]: Part[] - } - part_text_accum_delta: { - [partID: string]: string - } -} - -export type VcsCache = { - store: Store<{ value: VcsInfo | undefined }> - setStore: SetStoreFunction<{ value: VcsInfo | undefined }> - ready: Accessor -} - -export type MetaCache = { - store: Store<{ value: ProjectMeta | undefined }> - setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> - ready: Accessor -} - -export type IconCache = { - store: Store<{ value: string | undefined }> - setStore: SetStoreFunction<{ value: string | undefined }> - ready: Accessor -} - -export type ChildOptions = { - bootstrap?: boolean - mcp?: boolean -} - -export type DirState = { - lastAccessAt: number -} - -export type EvictPlan = { - stores: string[] - state: Map - pins: Set - max: number - ttl: number - now: number -} - -export type DisposeCheck = { - directory: string - hasStore: boolean - pinned: boolean - booting: boolean - loadingSessions: boolean -} - -export type RootLoadArgs = { - directory: string - limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> -} - -export type RootLoadResult = { - data?: Session[] - limit: number - limited: boolean -} - -export const MAX_DIR_STORES = 30 -export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 -export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 -export const SESSION_RECENT_LIMIT = 50 diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx deleted file mode 100644 index cad0c60553e1..000000000000 --- a/packages/app/src/context/settings.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { createStore, reconcile } from "solid-js/store" -import { createEffect, createMemo } from "solid-js" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { persisted } from "@/utils/persist" - -export interface NotificationSettings { - agent: boolean - permissions: boolean - errors: boolean -} - -export interface SoundSettings { - agentEnabled: boolean - agent: string - permissionsEnabled: boolean - permissions: string - errorsEnabled: boolean - errors: string -} - -export interface Settings { - general: { - autoSave: boolean - releaseNotes: boolean - followup: "queue" | "steer" - showFileTree: boolean - showNavigation: boolean - showSearch: boolean - showStatus: boolean - showTerminal: boolean - showReasoningSummaries: boolean - shellToolPartsExpanded: boolean - editToolPartsExpanded: boolean - showSessionProgressBar: boolean - showCustomAgents: boolean - newLayoutDesigns?: boolean - } - updates: { - startup: boolean - } - appearance: { - fontSize: number - mono: string - sans: string - terminal: string - } - keybinds: Record - permissions: { - autoApprove: boolean - } - notifications: NotificationSettings - sounds: SoundSettings -} - -export const monoDefault = "System Mono" -export const sansDefault = "System Sans" -export const terminalDefault = "JetBrainsMono Nerd Font Mono" -export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" - -const monoFallback = - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' -const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' -const terminalFallback = - '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' - -const monoBase = monoFallback -const sansBase = sansFallback -const terminalBase = terminalFallback - -function input(font: string | undefined) { - return font ?? "" -} - -function family(font: string) { - if (/^[\w-]+$/.test(font)) return font - return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` -} - -function stack(font: string | undefined, base: string) { - const value = font?.trim() ?? "" - if (!value) return base - return `${family(value)}, ${base}` -} - -export function monoInput(font: string | undefined) { - return input(font) -} - -export function sansInput(font: string | undefined) { - return input(font) -} - -export function monoFontFamily(font: string | undefined) { - return stack(font, monoBase) -} - -export function sansFontFamily(font: string | undefined) { - return stack(font, sansBase) -} - -export function terminalInput(font: string | undefined) { - return input(font) -} - -export function terminalFontFamily(font: string | undefined) { - return stack(font, terminalBase) -} - -const defaultSettings: Settings = { - general: { - autoSave: true, - releaseNotes: true, - followup: "steer", - showFileTree: false, - showNavigation: false, - showSearch: false, - showStatus: false, - showTerminal: false, - showReasoningSummaries: false, - shellToolPartsExpanded: false, - editToolPartsExpanded: false, - showSessionProgressBar: true, - showCustomAgents: false, - }, - updates: { - startup: true, - }, - appearance: { - fontSize: 14, - mono: "", - sans: "", - terminal: "", - }, - keybinds: {}, - permissions: { - autoApprove: false, - }, - notifications: { - agent: true, - permissions: true, - errors: false, - }, - sounds: { - agentEnabled: true, - agent: "staplebops-01", - permissionsEnabled: true, - permissions: "staplebops-02", - errorsEnabled: true, - errors: "nope-03", - }, -} - -function withFallback(read: () => T | undefined, fallback: T) { - return createMemo(() => read() ?? fallback) -} - -export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ - name: "Settings", - init: () => { - const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) - - createEffect(() => { - console.log("settings", { ready: ready() }) - }) - - createEffect(() => { - if (typeof document === "undefined") return - const root = document.documentElement - root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono)) - root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans)) - }) - - createEffect(() => { - if (store.general?.followup !== "queue") return - setStore("general", "followup", "steer") - }) - - return { - ready, - get current() { - return store - }, - general: { - autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), - setAutoSave(value: boolean) { - setStore("general", "autoSave", value) - }, - releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), - setReleaseNotes(value: boolean) { - setStore("general", "releaseNotes", value) - }, - followup: withFallback( - () => (store.general?.followup === "queue" ? "steer" : store.general?.followup), - defaultSettings.general.followup, - ), - setFollowup(value: "queue" | "steer") { - setStore("general", "followup", value === "queue" ? "steer" : value) - }, - showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree), - setShowFileTree(value: boolean) { - setStore("general", "showFileTree", value) - }, - showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation), - setShowNavigation(value: boolean) { - setStore("general", "showNavigation", value) - }, - showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch), - setShowSearch(value: boolean) { - setStore("general", "showSearch", value) - }, - showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus), - setShowStatus(value: boolean) { - setStore("general", "showStatus", value) - }, - showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), - setShowTerminal(value: boolean) { - setStore("general", "showTerminal", value) - }, - showReasoningSummaries: withFallback( - () => store.general?.showReasoningSummaries, - defaultSettings.general.showReasoningSummaries, - ), - setShowReasoningSummaries(value: boolean) { - setStore("general", "showReasoningSummaries", value) - }, - shellToolPartsExpanded: withFallback( - () => store.general?.shellToolPartsExpanded, - defaultSettings.general.shellToolPartsExpanded, - ), - setShellToolPartsExpanded(value: boolean) { - setStore("general", "shellToolPartsExpanded", value) - }, - editToolPartsExpanded: withFallback( - () => store.general?.editToolPartsExpanded, - defaultSettings.general.editToolPartsExpanded, - ), - setEditToolPartsExpanded(value: boolean) { - setStore("general", "editToolPartsExpanded", value) - }, - showSessionProgressBar: withFallback( - () => store.general?.showSessionProgressBar, - defaultSettings.general.showSessionProgressBar, - ), - setShowSessionProgressBar(value: boolean) { - setStore("general", "showSessionProgressBar", value) - }, - showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents), - setShowCustomAgents(value: boolean) { - setStore("general", "showCustomAgents", value) - }, - newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault), - setNewLayoutDesigns(value: boolean) { - setStore("general", "newLayoutDesigns", value) - }, - }, - updates: { - startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), - setStartup(value: boolean) { - setStore("updates", "startup", value) - }, - }, - appearance: { - fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), - setFontSize(value: number) { - setStore("appearance", "fontSize", value) - }, - font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono), - setFont(value: string) { - setStore("appearance", "mono", value.trim() ? value : "") - }, - uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans), - setUIFont(value: string) { - setStore("appearance", "sans", value.trim() ? value : "") - }, - terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal), - setTerminalFont(value: string) { - setStore("appearance", "terminal", value.trim() ? value : "") - }, - }, - keybinds: { - get: (action: string) => store.keybinds?.[action], - set(action: string, keybind: string) { - setStore("keybinds", action, keybind) - }, - reset(action: string) { - setStore("keybinds", (current) => { - if (!Object.prototype.hasOwnProperty.call(current, action)) return current - const next = { ...current } - delete next[action] - return next - }) - }, - resetAll() { - setStore("keybinds", reconcile({})) - }, - }, - permissions: { - autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), - setAutoApprove(value: boolean) { - setStore("permissions", "autoApprove", value) - }, - }, - notifications: { - agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), - setAgent(value: boolean) { - setStore("notifications", "agent", value) - }, - permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions), - setPermissions(value: boolean) { - setStore("notifications", "permissions", value) - }, - errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), - setErrors(value: boolean) { - setStore("notifications", "errors", value) - }, - }, - sounds: { - agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), - setAgentEnabled(value: boolean) { - setStore("sounds", "agentEnabled", value) - }, - agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), - setAgent(value: string) { - setStore("sounds", "agent", value) - }, - permissionsEnabled: withFallback( - () => store.sounds?.permissionsEnabled, - defaultSettings.sounds.permissionsEnabled, - ), - setPermissionsEnabled(value: boolean) { - setStore("sounds", "permissionsEnabled", value) - }, - permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), - setPermissions(value: string) { - setStore("sounds", "permissions", value) - }, - errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), - setErrorsEnabled(value: boolean) { - setStore("sounds", "errorsEnabled", value) - }, - errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), - setErrors(value: string) { - setStore("sounds", "errors", value) - }, - }, - } - }, -}) diff --git a/packages/app/src/index.css b/packages/app/src/index.css deleted file mode 100644 index 88f28c38727c..000000000000 --- a/packages/app/src/index.css +++ /dev/null @@ -1,173 +0,0 @@ -@import "@opencode-ai/ui/styles/tailwind"; -@import "@opencode-ai/ui/v2/styles/tailwind.css"; - -@font-face { - font-family: "JetBrainsMono Nerd Font Mono"; - src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: "Inter"; - src: url("/assets/Inter.ttf") format("truetype"); - font-weight: 100 900; - font-style: normal; -} - -@layer components { - @keyframes session-progress-whip { - 0% { - clip-path: inset(0 100% 0 0 round 999px); - animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); - } - - 48% { - clip-path: inset(0 0 0 0 round 999px); - animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); - } - - 100% { - clip-path: inset(0 0 0 100% round 999px); - } - } - - [data-component="session-progress"] { - position: absolute; - inset: 0 0 auto; - height: 2px; - overflow: hidden; - pointer-events: none; - opacity: 1; - transition: opacity 220ms ease-out; - } - - [data-component="session-progress"][data-state="hiding"] { - opacity: 0; - } - - [data-component="session-progress-bar"] { - width: 100%; - height: 100%; - border-radius: 999px; - clip-path: inset(0 100% 0 0 round 999px); - will-change: clip-path; - } - - [data-component="getting-started"] { - container-type: inline-size; - container-name: getting-started; - } - - [data-component="dropdown-menu-content"].desktop-app-menu, - [data-component="dropdown-menu-sub-content"].desktop-app-menu { - min-width: 160px; - padding: 2px; - } - - [data-component="dropdown-menu-content"].desktop-app-menu { - width: 160px; - } - - [data-component="dropdown-menu-sub-content"].desktop-app-menu { - width: max-content; - min-width: 240px; - max-width: min(320px, calc(100vw - 24px)); - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] { - display: flex; - align-items: center; - height: 28px; - padding: 0 12px; - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-medium); - line-height: 1; - color: var(--text-weak); - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"], - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] { - min-height: 28px; - padding: 0 12px; - gap: 8px; - font-weight: var(--font-weight-regular); - line-height: 1; - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] { - white-space: nowrap; - } - - [data-slot="desktop-app-menu-keybind"] { - margin-left: auto; - color: var(--text-weak); - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-regular); - white-space: nowrap; - } - - [data-slot="desktop-app-menu-chevron"] { - display: flex; - margin-left: auto; - color: var(--icon-base); - } - - [data-component="getting-started-actions"] { - display: flex; - flex-direction: column; - gap: 0.75rem; /* gap-3 */ - } - - [data-component="getting-started-actions"] > [data-component="button"] { - width: 100%; - } - - @container getting-started (min-width: 17rem) { - [data-component="getting-started-actions"] { - flex-direction: row; - align-items: center; - } - - [data-component="getting-started-actions"] > [data-component="button"] { - width: auto; - } - } - - [data-slot="titlebar-update-loader"] { - display: block; - flex-shrink: 0; - margin: 1px; - width: 12px; - height: 12px; - border-radius: 9999px; - border: 2px solid color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); - border-top-color: var(--v2-icon-icon-accent); - animation: titlebar-update-loader-spin 0.67s linear infinite; - transform-origin: 50% 50%; - } - - @media (prefers-reduced-motion: reduce) { - [data-slot="titlebar-update-loader"] { - animation: none; - } - } - - @keyframes titlebar-update-loader-spin { - to { - transform: rotate(360deg); - } - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } -} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx deleted file mode 100644 index e7bc61ce0a82..000000000000 --- a/packages/app/src/pages/home.tsx +++ /dev/null @@ -1,1121 +0,0 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createStore } from "solid-js/store" -import { useQuery } from "@tanstack/solid-query" -import { Button } from "@opencode-ai/ui/button" -import { Logo } from "@opencode-ai/ui/logo" -import { Spinner } from "@opencode-ai/ui/spinner" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator" -import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" -import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { Icon } from "@opencode-ai/ui/icon" -import { usePlatform } from "@/context/platform" -import { DateTime } from "luxon" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { ServerConnection, useServer } from "@/context/server" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { useNotification } from "@/context/notification" -import { usePermission } from "@/context/permission" -import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers" -import { sessionTitle } from "@/utils/session-title" -import { pathKey } from "@/utils/path-key" -import { messageAgentColor } from "@/utils/agent" -import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" -import { useGlobal } from "@/context/global" -import { useCommand } from "@/context/command" -import { useSettings } from "@/context/settings" -import { ServerHealthIndicator } from "@/components/server/server-row" - -const HOME_SESSION_LIMIT = 15 -const HOME_ROW_LAYOUT = - "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" -const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` -const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover` -const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" -const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` -const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" - -type HomeSessionRecord = { - session: Session - project: LocalProject - projectName: string -} - -type HomeSessionGroup = { - id: "today" | "yesterday" | "older" - title: string - sessions: HomeSessionRecord[] -} - -const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" -const HOME_SEARCH_RESULT_ROW = - "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" -const HOME_SEARCH_RESULT_TITLE = - "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]" -const HOME_SEARCH_RESULT_META = - "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]" - -function buildHomeSessionRecords(input: { - sync: ReturnType - projectDirectories: () => string[] - projects: () => LocalProject[] - projectByID: () => Map -}) { - return [ - ...new Map( - input - .projectDirectories() - .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now())) - .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const), - ).values(), - ] - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .flatMap((session) => { - const project = projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] - return { - session, - project, - projectName: displayName(project), - } - }) -} - -function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { - return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) -} - -function homeSessionSearchKey(record: HomeSessionRecord) { - return `${pathKey(record.session.directory)}:${record.session.id}` -} - -export default function Home() { - const settings = useSettings() - return ( - }> - - - ) -} - -function HomeDesign() { - const sync = useServerSync() - const layout = useLayout() - const platform = usePlatform() - const dialog = useDialog() - const navigate = useNavigate() - const server = useServer() - const language = useLanguage() - const global = useGlobal() - const command = useCommand() - const notification = useNotification() - let focusSessionSearch: (() => void) | undefined - const [state, setState] = createStore({ - search: "", - project: undefined as string | undefined, - searchFocused: false, - }) - - const projects = createMemo(() => layout.projects.list()) - const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project)) - const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] - const projectDirectories = createMemo(() => { - const project = selectedProject() - if (!project) return projects().flatMap(directories) - return directories(project) - }) - const search = createMemo(() => state.search.trim()) - const sessionLoad = useQuery(() => ({ - queryKey: ["home", "sessions", ...projectDirectories()] as const, - queryFn: async () => { - await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory))) - return null - }, - })) - - const projectByID = createMemo( - () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) - const allRecords = createMemo(() => - buildHomeSessionRecords({ - sync, - projectDirectories, - projects, - projectByID, - }), - ) - const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) - const searchResults = createMemo(() => { - const query = search().toLowerCase() - if (!query) return [] - return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) - }) - const searchOpen = createMemo(() => state.searchFocused && search().length > 0) - const groups = createMemo(() => groupSessions(records(), language)) - - function closeSearch() { - setState("search", "") - setState("searchFocused", false) - } - - function selectSearchSession(session: Session) { - openSession(session) - closeSearch() - } - - command.register("home", () => [ - { - id: "home.sessions.search.focus", - title: language.t("home.sessions.search.placeholder"), - keybind: "mod+f", - hidden: true, - onSelect: () => focusSessionSearch?.(), - }, - ]) - - function selectProject(directory: string) { - if (!projects().some((project) => project.worktree === directory)) return - setState("project", state.project === directory ? undefined : directory) - } - - function addProject(conn: ServerConnection.Any, directory: string) { - const server = global.createServerCtx(conn) - server.projects.open(directory) - server.projects.touch(directory) - setState("project", directory) - } - - function openNewSession() { - const project = selectedProject() - if (!project) { - const conn = server.current - if (conn) void chooseProject(conn) - return - } - layout.projects.open(project.worktree) - server.projects.touch(project.worktree) - navigate(`/${base64Encode(project.worktree)}/session`) - } - - function openProjectNewSession(directory: string) { - layout.projects.open(directory) - server.projects.touch(directory) - navigate(`/${base64Encode(directory)}/session`) - } - - function editProject(project: LocalProject) { - void import("@/components/dialog-edit-project").then((x) => { - dialog.show(() => ) - }) - } - - function unseenCount(project: LocalProject) { - return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) - } - - function clearNotifications(project: LocalProject) { - directories(project) - .filter((directory) => notification.project.unseenCount(directory) > 0) - .forEach((directory) => notification.project.markViewed(directory)) - } - - function openSession(session: Session) { - const project = projectForSession(session, projects(), projectByID()) - layout.projects.open(project?.worktree ?? session.directory) - server.projects.touch(project?.worktree ?? session.directory) - navigate(`/${base64Encode(session.directory)}/session/${session.id}`) - } - - async function chooseProject(conn: ServerConnection.Any) { - function resolve(result: string | string[] | null) { - if (Array.isArray(result)) { - result.forEach((r) => addProject(conn, r)) - if (result[0]) setState("project", result[0]) - return - } - if (result) addProject(conn, result) - } - - const server = global.createServerCtx(conn) - - if (platform.openDirectoryPickerDialog && server.isLocal) { - const result = await platform.openDirectoryPickerDialog?.({ - title: language.t("command.project.open"), - multiple: true, - }) - resolve(result) - return - } - - dialog.show( - () => , - () => resolve(null), - ) - } - - function openSettings() { - void import("@/components/settings-v2").then((x) => { - dialog.show(() => ) - }) - } - - return ( -
-
- void chooseProject(conn)} - editProject={editProject} - closeProject={(directory) => { - layout.projects.close(directory) - if (state.project === directory) setState("project", undefined) - }} - clearNotifications={clearNotifications} - unseenCount={unseenCount} - openSettings={openSettings} - openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} - language={language} - /> - -
- { - focusSessionSearch = focus - }} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} - /> -
-
- } - > - 0} - fallback={ -
- -
- } - > - - {(group, index) => ( -
- -
- - {(record) => } - -
-
- )} -
-
-
-
-
-
-
-
- ) -} - -function HomeProjectColumn(props: { - projects: LocalProject[] - selected?: string - selectProject: (directory: string) => void - openNewSession: (directory: string) => void - chooseProject: (server: ServerConnection.Any) => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - unseenCount: (project: LocalProject) => number - openSettings: () => void - openHelp: () => void - language: ReturnType -}) { - const global = useGlobal() - return ( - - ) -} - -function HomeProjectList(props: { - projects: LocalProject[] - selected?: string - selectProject: (directory: string) => void - openNewSession: (directory: string) => void - chooseProject: () => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - unseenCount: (project: LocalProject) => number - language: ReturnType -}) { - return ( - 0} - fallback={ - - } - > -
- - {(project) => ( - - )} - -
-
- ) -} - -function HomeProjectRow(props: { - project: LocalProject - selected: boolean - unseenCount: number - selectProject: (directory: string) => void - openNewSession: (directory: string) => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - language: ReturnType -}) { - const [state, setState] = createStore({ menuOpen: false }) - return ( -
- -
- } - aria-label={props.language.t("command.session.new")} - onClick={() => props.openNewSession(props.project.worktree)} - /> - setState("menuOpen", open)} - > - } - aria-label={props.language.t("common.moreOptions")} - /> - - - props.openNewSession(props.project.worktree)}> - {props.language.t("command.session.new")} - - props.editProject(props.project)}> - {props.language.t("common.edit")} - - props.clearNotifications(props.project)}> - {props.language.t("sidebar.project.clearNotifications")} - - - props.closeProject(props.project.worktree)}> - {props.language.t("common.close")} - - - - -
-
- ) -} - -function HomeProjectAvatar(props: { project: LocalProject }) { - const name = createMemo(() => displayName(props.project)) - return ( - - ) -} - -function HomeSessionSearch(props: { - value: string - placeholder: string - open: boolean - loading: boolean - results: HomeSessionRecord[] - noResultsLabel: string - bindFocus: (focus: () => void) => void - onInput: (value: string) => void - onFocus: () => void - onClose: () => void - onSelect: (session: Session) => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ active: "" }) - let root: HTMLDivElement | undefined - let input: HTMLInputElement | undefined - let listRef: HTMLDivElement | undefined - - const focusInput = () => { - input?.focus() - props.onFocus() - } - - onMount(() => { - props.bindFocus(focusInput) - }) - - const syncActive = (results: HomeSessionRecord[]) => { - if (results.length === 0) { - setStore("active", "") - return - } - if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { - setStore("active", homeSessionSearchKey(results[0])) - } - } - - createEffect(() => syncActive(props.results)) - - createEffect( - on( - () => props.value, - () => syncActive(props.results), - ), - ) - - const scrollActiveIntoView = () => { - const key = store.active - if (!key || !listRef) return - const element = listRef.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "nearest" }) - } - - const moveActive = (delta: number) => { - const results = props.results - if (results.length === 0) return - const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) - const start = index === -1 ? 0 : index - const next = (start + delta + results.length) % results.length - setStore("active", homeSessionSearchKey(results[next])) - scrollActiveIntoView() - } - - const selectActive = () => { - const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) - if (!record) return - props.onSelect(record.session) - } - - onCleanup( - makeEventListener(document, "pointerdown", (event) => { - if (!props.open) return - const target = event.target - if (!(target instanceof Node)) return - if (root?.contains(target)) return - props.onClose() - }), - ) - - return ( -
-
- -
-
-
- - -
- } - > - 0} - fallback={ -

- {props.noResultsLabel} -

- } - > -
-

- {language.t("home.sessions.search.sessions")} -

-
- - {(record) => ( - setStore("active", homeSessionSearchKey(record))} - onSelect={(session) => props.onSelect(session)} - /> - )} - -
-
-
- -
-
-
- - -
-
- ) -} - -function HomeSessionSearchResultRow(props: { - record: HomeSessionRecord - selected: boolean - onHighlight: () => void - onSelect: (session: Session) => void -}) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) - - const key = () => homeSessionSearchKey(props.record) - - return ( -
- } - > -
- - - - - -
- - -
- - 0}> -
- - -
- -
- - {title()} - - - {props.record.projectName} - -
- - ) -} - -function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { - const language = useLanguage() - return ( -
- - - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - -
- ) -} - -function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) - - return ( -
- } - > -
- - - - - -
- - -
- - 0}> -
- - -
- - - {title()} - - - - {props.record.projectName} - - - - ) -} - -function HomeSessionSkeleton(props: { label: string }) { - return ( -
-
- -
- - ) -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -function LegacyHome() { - const sync = useServerSync() - const platform = usePlatform() - const dialog = useDialog() - const navigate = useNavigate() - const global = useGlobal() - const server = useServer() - const language = useLanguage() - const homedir = createMemo(() => sync.data.path.home) - const recent = createMemo(() => { - return sync.data.project - .slice() - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5) - }) - - const serverDotClass = createMemo(() => { - const healthy = global.servers.health[server.key]?.healthy - if (healthy === true) return "bg-icon-success-base" - if (healthy === false) return "bg-icon-critical-base" - return "bg-border-weak-base" - }) - - function openProject(server: ServerConnection.Any, directory: string) { - const serverCtx = global.createServerCtx(server) - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - navigate(`/${base64Encode(directory)}`) - } - - async function chooseProject() { - const s = server.current - if (!s) return - - const resolve = (result: string | string[] | null) => { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(s, directory) - } - } else if (result) { - openProject(s, result) - } - } - - if (platform.openDirectoryPickerDialog && server.isLocal()) { - const result = await platform.openDirectoryPickerDialog?.({ - title: language.t("command.project.open"), - multiple: true, - }) - resolve(result) - } else { - dialog.show( - () => , - () => resolve(null), - ) - } - } - - return ( -
- - - - 0}> -
-
-
{language.t("home.recentProjects")}
- -
-
    - - {(project) => ( - - )} - -
-
-
- -
-
{language.t("common.loading")}
- -
-
- -
- -
-
{language.t("home.empty.title")}
-
{language.t("home.empty.description")}
-
- -
-
-
-
- ) -} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx deleted file mode 100644 index baeda1f9a61b..000000000000 --- a/packages/app/src/pages/session.tsx +++ /dev/null @@ -1,1856 +0,0 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" -import { - batch, - onCleanup, - Show, - Match, - Switch, - createMemo, - createEffect, - createComputed, - on, - onMount, - untrack, - createResource, -} from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createMediaQuery } from "@solid-primitives/media" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { debounce } from "@solid-primitives/scheduled" -import { useLocal } from "@/context/local" -import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" -import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { Select } from "@opencode-ai/ui/select" -import { Tabs } from "@opencode-ai/ui/tabs" -import { createAutoScroll } from "@opencode-ai/ui/hooks" -import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" -import { Button } from "@opencode-ai/ui/button" -import { showToast } from "@/utils/toast" -import { checksum } from "@opencode-ai/core/util/encode" -import { useLocation, useSearchParams } from "@solidjs/router" -import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session" -import { useComments } from "@/context/comments" -import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" -import { useTerminal } from "@/context/terminal" -import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" -import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { - createOpenReviewFile, - createSessionTabs, - createSizing, - focusTerminalById, - shouldFocusTerminalOnKeyDown, -} from "@/pages/session/helpers" -import { MessageTimeline } from "@/pages/session/message-timeline" -import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" -import { useSessionLayout } from "@/pages/session/session-layout" -import { syncSessionModel } from "@/pages/session/session-model-helpers" -import { SessionSidePanel } from "@/pages/session/session-side-panel" -import { TerminalPanel } from "@/pages/session/terminal-panel" -import { useSessionCommands } from "@/pages/session/use-session-commands" -import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" -import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout" -import { Identifier } from "@/utils/id" -import { diffs as list } from "@/utils/diffs" -import { Persist, persisted } from "@/utils/persist" -import { extractPromptFromParts } from "@/utils/prompt" -import { same } from "@/utils/same" -import { formatServerError } from "@/utils/server-errors" -import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" - -const emptyUserMessages: UserMessage[] = [] -type FollowupItem = FollowupDraft & { id: string } -type FollowupEdit = Pick -const emptyFollowups: FollowupItem[] = [] - -type ChangeMode = "git" | "branch" | "turn" -type VcsMode = "git" | "branch" - -type SessionHistoryWindowInput = { - sessionID: () => string | undefined - loaded: () => number - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined -} - -function createSessionHistoryLoader(input: SessionHistoryWindowInput) { - const historyScrollThreshold = 200 - let shiftFrame: number | undefined - - const [state, setState] = createStore({ - shift: false, - }) - - const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, { - equals: same, - }) - - const cancelShiftReset = () => { - if (shiftFrame === undefined) return - cancelAnimationFrame(shiftFrame) - shiftFrame = undefined - } - - const scheduleShiftReset = () => { - cancelShiftReset() - shiftFrame = requestAnimationFrame(() => { - shiftFrame = undefined - setState("shift", false) - }) - } - - const fetchOlderMessages = async () => { - const id = input.sessionID() - if (!id) return - if (!input.historyMore() || input.historyLoading()) return - - // TODO(session-timeline): switch this to core cursor-based part pagination when that API lands. - const beforeVisible = input.visibleUserMessages().length - let loaded = input.loaded() - let growth = 0 - - cancelShiftReset() - setState("shift", true) - - while (true) { - await input.loadMore(id) - if (input.sessionID() !== id) return - - const nextLoaded = input.loaded() - const raw = nextLoaded - loaded - loaded = nextLoaded - growth = input.visibleUserMessages().length - beforeVisible - - if (growth > 0) break - if (raw <= 0) break - if (!input.historyMore()) break - } - - if (growth > 0) { - scheduleShiftReset() - return - } - - setState("shift", false) - } - - const loadAndReveal = () => fetchOlderMessages() - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollTop >= historyScrollThreshold) return - - void fetchOlderMessages() - } - - createEffect( - on( - input.sessionID, - () => { - cancelShiftReset() - setState({ shift: false }) - }, - { defer: true }, - ), - ) - - onCleanup(cancelShiftReset) - - return { - userMessages, - shift: () => state.shift, - loadAndReveal, - onScrollerScroll, - } -} - -export default function Page() { - const serverSync = useServerSync() - const layout = useLayout() - const local = useLocal() - const file = useFile() - const sync = useSync() - const queryClient = useQueryClient() - const dialog = useDialog() - const language = useLanguage() - const sdk = useSDK() - const settings = useSettings() - const prompt = usePrompt() - const comments = useComments() - const terminal = useTerminal() - const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() - const location = useLocation() - const { params, sessionKey, tabs, view } = useSessionLayout() - const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) - - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - if (params.id) return - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) - }) - - const [ui, setUi] = createStore({ - pendingMessage: undefined as string | undefined, - reviewSnap: false, - scrollGesture: 0, - scroll: { - overflow: false, - bottom: true, - jump: false, - }, - }) - - const composer = createSessionComposerState() - - const workspaceKey = createMemo(() => params.dir ?? "") - const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) - - createEffect( - on( - () => params.id, - (id, prev) => { - if (!id) return - if (prev) return - - const pending = layout.handoff.tabs() - if (!pending) return - if (Date.now() - pending.at > 60_000) { - layout.handoff.clearTabs() - return - } - - if (pending.id !== id) return - layout.handoff.clearTabs() - if (pending.dir !== (params.dir ?? "")) return - - const from = workspaceTabs().tabs() - if (from.all.length === 0 && !from.active) return - - const current = tabs().tabs() - if (current.all.length > 0 || current.active) return - - const all = normalizeTabs(from.all) - const active = from.active ? normalizeTab(from.active) : undefined - tabs().setAll(all) - tabs().setActive(active && all.includes(active) ? active : all[0]) - - workspaceTabs().setAll([]) - workspaceTabs().setActive(undefined) - }, - { defer: true }, - ), - ) - - const isDesktop = createMediaQuery("(min-width: 768px)") - const size = createSizing() - const isV2NewSessionPage = () => - shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id }) - const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage()) - const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage()) - const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) - const sessionPanelWidth = createMemo(() => { - if (!desktopSidePanelOpen()) return "100%" - if (desktopReviewOpen()) return `${layout.session.width()}px` - return `calc(100% - ${layout.fileTree.width()}px)` - }) - const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) - - function normalizeTab(tab: string) { - if (!tab.startsWith("file://")) return tab - return file.tab(tab) - } - - function normalizeTabs(list: string[]) { - const seen = new Set() - const next: string[] = [] - for (const item of list) { - const value = normalizeTab(item) - if (seen.has(value)) continue - seen.add(value) - next.push(value) - } - return next - } - - const openReviewPanel = () => { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - } - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) - const canReview = createMemo(() => !!sync.project) - const reviewTab = createMemo(() => isDesktop()) - const tabState = createSessionTabs({ - tabs, - pathFromTab: file.pathFromTab, - normalizeTab, - review: reviewTab, - hasReview: canReview, - }) - const activeTab = tabState.activeTab - const activeFileTab = tabState.activeFileTab - const revertMessageID = createMemo(() => info()?.revert?.messageID) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const messagesReady = createMemo(() => { - const id = params.id - if (!id) return true - return sync.data.message[id] !== undefined - }) - const historyMore = createMemo(() => { - const id = params.id - if (!id) return false - return sync.session.history.more(id) - }) - const historyLoading = createMemo(() => { - const id = params.id - if (!id) return false - return sync.session.history.loading(id) - }) - const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], - emptyUserMessages, - { equals: same }, - ) - const visibleUserMessages = createMemo( - () => { - const revert = revertMessageID() - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }, - emptyUserMessages, - { - equals: same, - }, - ) - const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) - - createEffect(() => { - const tab = activeFileTab() - if (!tab) return - - const path = file.pathFromTab(tab) - if (path) void file.load(path) - }) - - createEffect( - on( - () => lastUserMessage()?.id, - () => { - const msg = lastUserMessage() - if (!msg) return - syncSessionModel(local, msg) - }, - ), - ) - - createEffect( - on( - () => ({ dir: params.dir, id: params.id }), - (next, prev) => { - if (!prev) return - if (next.dir === prev.dir && next.id === prev.id) return - if (prev.id && !next.id) local.session.reset() - }, - { defer: true }, - ), - ) - - const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, - newSessionWorktree: "main", - deferRender: false, - }) - - const [followup, setFollowup] = persisted( - Persist.workspace(sdk.directory, "followup", ["followup.v1"]), - createStore<{ - items: Record - failed: Record - paused: Record - edit: Record - }>({ - items: {}, - failed: {}, - paused: {}, - edit: {}, - }), - ) - - createComputed((prev) => { - const key = sessionKey() - if (key !== prev) { - setStore("deferRender", true) - requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) - }) - } - return key - }, sessionKey()) - - let reviewFrame: number | undefined - let refreshFrame: number | undefined - let refreshTimer: number | undefined - let todoFrame: number | undefined - let todoTimer: number | undefined - let diffFrame: number | undefined - let diffTimer: number | undefined - - createComputed((prev) => { - const open = desktopReviewOpen() - if (prev === undefined || prev === open) return open - - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - setUi("reviewSnap", true) - reviewFrame = requestAnimationFrame(() => { - reviewFrame = undefined - setUi("reviewSnap", false) - }) - return open - }, desktopReviewOpen()) - - const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) - const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") - const changesOptions = createMemo(() => { - const list: ChangeMode[] = [] - if (sync.project?.vcs === "git") list.push("git") - if ( - sync.project?.vcs === "git" && - sync.data.vcs?.branch && - sync.data.vcs?.default_branch && - sync.data.vcs.branch !== sync.data.vcs.default_branch - ) { - list.push("branch") - } - list.push("turn") - return list - }) - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes", - ) - const vcsMode = createMemo(() => { - if (store.changes === "git" || store.changes === "branch") return store.changes - }) - const vcsKey = createMemo( - () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const, - ) - const vcsQuery = createQuery(() => { - const mode = vcsMode() - const enabled = wantsReview() && sync.project?.vcs === "git" - - return { - queryKey: [...vcsKey(), mode] as const, - enabled, - queryFn: mode - ? () => - sdk.client.vcs - .diff({ mode }) - .then((result) => list(result.data)) - .catch((error) => { - console.debug("[session-review] failed to load vcs diff", { mode, error }) - return [] - }) - : skipToken, - } - }) - const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) - const reviewDiffs = () => { - if (store.changes === "git" || store.changes === "branch") - // avoids suspense - return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] - return turnDiffs() - } - const reviewCount = () => reviewDiffs().length - const hasReview = () => reviewCount() > 0 - const reviewReady = () => { - if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending - return true - } - - const newSessionWorktree = createMemo(() => { - if (store.newSessionWorktree === "create") return "create" - const project = sync.project - if (project && sdk.directory !== project.worktree) return sdk.directory - return "main" - }) - - const setActiveMessage = (message: UserMessage | undefined) => { - messageMark = scrollMark - setStore("messageId", message?.id) - } - - const anchor = (id: string) => `message-${id}` - - const cursor = () => { - const root = scroller - if (!root) return store.messageId - - const box = root.getBoundingClientRect() - const line = box.top + 100 - const list = [...root.querySelectorAll("[data-message-id]")] - .map((el) => { - const id = el.dataset.messageId - if (!id) return - - const rect = el.getBoundingClientRect() - return { id, top: rect.top, bottom: rect.bottom } - }) - .filter((item): item is { id: string; top: number; bottom: number } => !!item) - - const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) - const hit = shown.find((item) => item.top <= line && item.bottom >= line) - if (hit) return hit.id - - const near = [...shown].sort((a, b) => { - const da = Math.abs(a.top - line) - const db = Math.abs(b.top - line) - if (da !== db) return da - db - return a.top - b.top - })[0] - if (near) return near.id - - return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId - } - - function navigateMessageByOffset(offset: number) { - const msgs = visibleUserMessages() - if (msgs.length === 0) return - - const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() - const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length - const currentIndex = base === -1 ? msgs.length : base - const targetIndex = currentIndex + offset - if (targetIndex < 0 || targetIndex > msgs.length) return - - if (targetIndex === msgs.length) { - resumeScroll() - return - } - - autoScroll.pause() - scrollToMessage(msgs[targetIndex], "auto") - } - - function upsert(next: Project) { - const list = serverSync.data.project - sync.set("project", next.id) - const idx = list.findIndex((item) => item.id === next.id) - if (idx >= 0) { - serverSync.set( - "project", - list.map((item, i) => (i === idx ? { ...item, ...next } : item)), - ) - return - } - const at = list.findIndex((item) => item.id > next.id) - if (at >= 0) { - serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)]) - return - } - serverSync.set("project", [...list, next]) - } - - const gitMutation = useMutation(() => ({ - mutationFn: () => sdk.client.project.initGit(), - onSuccess: (x) => { - if (!x.data) return - upsert(x.data) - }, - onError: (err) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - }, - })) - - function initGit() { - if (gitMutation.isPending) return - gitMutation.mutate() - } - - let inputRef!: HTMLDivElement - let promptDock: HTMLDivElement | undefined - let dockHeight = 0 - let scroller: HTMLDivElement | undefined - let content: HTMLDivElement | undefined - let revealMessage = (_id: string) => {} - let scrollMark = 0 - let messageMark = 0 - - const scrollGestureWindowMs = 250 - - const markScrollGesture = (target?: EventTarget | null) => { - const root = scroller - if (!root) return - - const el = target instanceof Element ? target : undefined - const nested = el?.closest("[data-scrollable]") - if (nested && nested !== root) return - - setUi("scrollGesture", Date.now()) - } - - const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - - const [sessionSync] = createResource( - () => [sdk.directory, params.id] as const, - ([directory, id]) => { - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - refreshFrame = undefined - refreshTimer = undefined - if (!id) return - - const cached = untrack(() => sync.data.message[id] !== undefined) - const stale = !cached - ? false - : (() => { - const info = getSessionPrefetch(directory, id) - if (!info) return true - return Date.now() - info.at > SESSION_PREFETCH_TTL - })() - - refreshFrame = requestAnimationFrame(() => { - refreshFrame = undefined - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - if (params.id !== id) return - untrack(() => { - if (stale) void sync.session.sync(id, { force: true }) - }) - }, 0) - }) - - return sync.session.sync(id) - }, - ) - - createEffect( - on( - () => { - const id = params.id - return [ - sdk.directory, - id, - id ? (sync.data.session_status[id]?.type ?? "idle") : "idle", - id ? composer.blocked() : false, - ] as const - }, - ([dir, id, status, blocked]) => { - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) - todoFrame = undefined - todoTimer = undefined - if (!id) return - if (status === "idle" && !blocked) return - const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined) - - todoFrame = requestAnimationFrame(() => { - todoFrame = undefined - todoTimer = window.setTimeout(() => { - todoTimer = undefined - if (sdk.directory !== dir || params.id !== id) return - untrack(() => { - void sync.session.todo(id, cached ? { force: true } : undefined) - }) - }, 0) - }) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => visibleUserMessages().at(-1)?.id, - (lastId, prevLastId) => { - if (lastId && prevLastId && lastId > prevLastId) { - setStore("messageId", undefined) - } - }, - { defer: true }, - ), - ) - - createEffect( - on( - sessionKey, - () => { - setStore("messageId", undefined) - setStore("changes", "git") - setUi("pendingMessage", undefined) - }, - { defer: true }, - ), - ) - - const stopVcs = sdk.event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return - const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) - : undefined - const file = typeof props?.file === "string" ? props.file : undefined - if (!file || file.startsWith(".git/")) return - refreshVcs() - }) - onCleanup(stopVcs) - - createEffect( - on( - () => params.dir, - (dir) => { - if (!dir) return - setStore("newSessionWorktree", "main") - }, - { defer: true }, - ), - ) - - const selectionPreview = (path: string, selection: FileSelection) => { - const content = file.get(path)?.content?.content - if (!content) return undefined - return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) - } - - const addCommentToContext = (input: { - file: string - selection: SelectedLineRange - comment: string - preview?: string - origin?: "review" | "file" - }) => { - const selection = selectionFromLines(input.selection) - const preview = input.preview ?? selectionPreview(input.file, selection) - const saved = comments.add({ - file: input.file, - selection: input.selection, - comment: input.comment, - }) - prompt.context.add({ - type: "file", - path: input.file, - selection, - comment: input.comment, - commentID: saved.id, - commentOrigin: input.origin, - preview, - }) - } - - const updateCommentInContext = (input: { - id: string - file: string - selection: SelectedLineRange - comment: string - preview?: string - }) => { - comments.update(input.file, input.id, input.comment) - prompt.context.updateComment(input.file, input.id, { - comment: input.comment, - ...(input.preview ? { preview: input.preview } : {}), - }) - } - - const removeCommentFromContext = (input: { id: string; file: string }) => { - comments.remove(input.file, input.id) - prompt.context.removeComment(input.file, input.id) - } - - const reviewCommentActions = createMemo(() => ({ - moreLabel: language.t("common.moreOptions"), - editLabel: language.t("common.edit"), - deleteLabel: language.t("common.delete"), - saveLabel: language.t("common.save"), - })) - - const isEditableTarget = (target: EventTarget | null | undefined) => { - if (!(target instanceof HTMLElement)) return false - return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable - } - - const deepActiveElement = () => { - let current: Element | null = document.activeElement - while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { - current = current.shadowRoot.activeElement - } - return current instanceof HTMLElement ? current : undefined - } - - const handleKeyDown = (event: KeyboardEvent) => { - const path = event.composedPath() - const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) - const activeElement = deepActiveElement() - - const protectedTarget = path.some( - (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, - ) - if (protectedTarget || isEditableTarget(target)) return - - if (activeElement) { - const isProtected = activeElement.closest("[data-prevent-autofocus]") - const isInput = isEditableTarget(activeElement) - if (isProtected || isInput) return - } - if (dialog.active) return - - if (activeElement === inputRef) { - if (event.key === "Escape") inputRef?.blur() - return - } - - // Prefer the open terminal over the composer when it can take focus - if (view().terminal.opened()) { - const id = terminal.active() - if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return - } - - // Only treat explicit scroll keys as potential "user scroll" gestures. - if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { - markScrollGesture() - return - } - - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - if (composer.blocked() || isChildSession()) return - inputRef?.focus() - } - } - - createEffect(() => { - const list = changesOptions() - if (list.includes(store.changes)) return - const next = list[0] - if (!next) return - setStore("changes", next) - }) - - createEffect( - on( - () => sync.data.session_status[params.id ?? ""]?.type, - (next, prev) => { - if (next !== "idle" || prev === undefined || prev === "idle") return - refreshVcs() - }, - { defer: true }, - ), - ) - - const fileTreeTab = () => layout.fileTree.tab() - const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) - - const [tree, setTree] = createStore({ - reviewScroll: undefined as HTMLDivElement | undefined, - pendingDiff: undefined as string | undefined, - activeDiff: undefined as string | undefined, - }) - - createEffect( - on( - sessionKey, - () => { - setTree({ - reviewScroll: undefined, - pendingDiff: undefined, - activeDiff: undefined, - }) - }, - { defer: true }, - ), - ) - - const showAllFiles = () => { - if (fileTreeTab() !== "changes") return - setFileTreeTab("all") - } - - const focusInput = () => { - if (isChildSession()) return - inputRef?.focus() - } - - useSessionCommands({ - navigateMessageByOffset, - setActiveMessage, - focusInput, - review: reviewTab, - }) - - const openReviewFile = createOpenReviewFile({ - showAllFiles, - tabForPath: file.tab, - openTab: tabs().open, - setActive: tabs().setActive, - loadFile: file.load, - }) - - const changesTitle = () => { - if (!canReview()) { - return null - } - - const label = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - - return ( -