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 f523671ec9d8..000000000000 --- a/packages/app/src/components/prompt-input.tsx +++ /dev/null @@ -1,1595 +0,0 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" -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 { useSDK } from "@/context/sdk" -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 } 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 { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -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 { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" -import { promptEnabled, promptProbe } from "@/testing/prompt" -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" - -interface PromptInputProps { - class?: string - 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 - -const NON_EMPTY_TEXT = /[^\s\u200B]/ - -export const PromptInput: Component = (props) => { - const sdk = useSDK() - const sync = useSync() - const local = useLocal() - const files = useFile() - const prompt = usePrompt() - const layout = useLayout() - const comments = useComments() - const dialog = useDialog() - const providers = useProviders() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const { params, tabs, view } = useSessionLayout() - let editorRef!: HTMLDivElement - let fileInputRef: HTMLInputElement | undefined - let scrollRef!: HTMLDivElement - let slashPopoverRef!: HTMLDivElement - - 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) - tabs().open(tab) - tabs().setActive(tab) - 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 status = createMemo( - () => - sync.data.session_status[params.id ?? ""] ?? { - type: "idle", - }, - ) - const working = createMemo(() => status()?.type !== "idle") - const tip = () => { - if (working()) { - return ( -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
- ) - } - - return ( -
- {language.t("prompt.action.send")} - -
- ) - } - 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 - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) - const motion = (value: number) => ({ - opacity: value, - transform: `scale(${0.95 + value * 0.05})`, - 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 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() ? 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 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 })) - 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 - promptProbe.select(cmd.id) - closePopover() - - if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` - setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - focusEditorEnd() - return - } - - clearEditor() - prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 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" }) - }) - }) - - if (promptEnabled()) { - createEffect(() => { - promptProbe.set({ - popover: store.popover, - slash: { - active: slashActive() ?? null, - ids: slashFlat().map((cmd) => cmd.id), - }, - }) - }) - - onCleanup(() => promptProbe.clear()) - } - - 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 shouldReset = !NON_EMPTY_TEXT.test(rawText) && !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 variants = createMemo(() => ["default", ...local.model.variant.list()]) - const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) - }) - const acceptLabel = createMemo(() => - language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"), - ) - const toggleAccept = () => { - if (!params.id) { - permission.toggleAutoAcceptDirectory(sdk.directory) - return - } - - permission.toggleAutoAccept(params.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()) { - 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()) { - 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 - } - handleSubmit(event) - } - } - - return ( -
- (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-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', - ) - ) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={placeholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - style={{ "padding-bottom": space }} - /> - -
- {placeholder()} -
-
-
- - - - - -
-
-
- {language.t("prompt.mode.shell")} -
-
-
-
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} - 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" - /> - -
- - - -
-
-
- - -
- ) -} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts deleted file mode 100644 index fa9930f6839a..000000000000 --- a/packages/app/src/components/prompt-input/attachments.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { onCleanup, onMount } from "solid-js" -import { showToast } from "@opencode-ai/ui/toast" -import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" -import { useLanguage } from "@/context/language" -import { uuid } from "@/utils/uuid" -import { getCursorPosition } from "./editor-dom" -import { attachmentMime } from "./files" -import { normalizePaste, pasteMode } from "./paste" - -function dataUrl(file: File, mime: string) { - return new Promise((resolve) => { - const reader = new FileReader() - reader.addEventListener("error", () => resolve("")) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - const idx = value.indexOf(",") - if (idx === -1) { - resolve(value) - return - } - resolve(`data:${mime};base64,${value.slice(idx + 1)}`) - }) - reader.readAsDataURL(file) - }) -} - -type PromptAttachmentsInput = { - editor: () => HTMLDivElement | undefined - isDialogActive: () => boolean - setDraggingType: (type: "image" | "@mention" | null) => void - focusEditor: () => void - addPart: (part: ContentPart) => boolean - readClipboardImage?: () => Promise -} - -export function createPromptAttachments(input: PromptAttachmentsInput) { - const prompt = usePrompt() - const language = useLanguage() - - const warn = () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - } - - const add = async (file: File, toast = true) => { - const mime = await attachmentMime(file) - if (!mime) { - if (toast) warn() - return false - } - - const editor = input.editor() - if (!editor) return false - - const url = await dataUrl(file, mime) - if (!url) return false - - const attachment: ImageAttachmentPart = { - type: "image", - id: uuid(), - filename: file.name, - mime, - dataUrl: url, - } - const cursor = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursor) - return true - } - - const addAttachment = (file: File) => add(file) - - const addAttachments = async (files: File[], toast = true) => { - let found = false - - for (const file of files) { - const ok = await add(file, false) - if (ok) found = true - } - - if (!found && files.length > 0 && toast) warn() - return found - } - - const removeAttachment = (id: string) => { - const current = prompt.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - if (!clipboardData) return - - event.preventDefault() - event.stopPropagation() - - const files = Array.from(clipboardData.items).flatMap((item) => { - if (item.kind !== "file") return [] - const file = item.getAsFile() - return file ? [file] : [] - }) - - if (files.length > 0) { - await addAttachments(files) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - - // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images - if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file) { - await addAttachment(file) - return - } - } - - if (!plainText) return - - const text = normalizePaste(plainText) - - const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) - } - - if (pasteMode(text) === "manual") { - put() - return - } - - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) - if (inserted) return - - put() - } - - const handleGlobalDragOver = (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - const hasText = event.dataTransfer?.types.includes("text/plain") - if (hasFiles) { - input.setDraggingType("image") - } else if (hasText) { - input.setDraggingType("@mention") - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (input.isDialogActive()) return - if (!event.relatedTarget) { - input.setDraggingType(null) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - input.setDraggingType(null) - - const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) - input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) - return - } - - const dropped = event.dataTransfer?.files - if (!dropped) return - - await addAttachments(Array.from(dropped)) - } - - onMount(() => { - document.addEventListener("dragover", handleGlobalDragOver) - document.addEventListener("dragleave", handleGlobalDragLeave) - document.addEventListener("drop", handleGlobalDrop) - }) - - onCleanup(() => { - document.removeEventListener("dragover", handleGlobalDragOver) - document.removeEventListener("dragleave", handleGlobalDragLeave) - document.removeEventListener("drop", handleGlobalDrop) - }) - - return { - addAttachment, - addAttachments, - removeAttachment, - handlePaste, - } -} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx deleted file mode 100644 index 4d90930a0e0c..000000000000 --- a/packages/app/src/components/session/session-context-tab.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" -import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" -import { same } from "@/utils/same" -import { Icon } from "@opencode-ai/ui/icon" -import { Accordion } from "@opencode-ai/ui/accordion" -import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" -import { File } from "@opencode-ai/ui/file" -import { Markdown } from "@opencode-ai/ui/markdown" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" -import { useLanguage } from "@/context/language" -import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContextMetrics } from "./session-context-metrics" -import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" -import { createSessionContextFormatter } from "./session-context-format" - -const BREAKDOWN_COLOR: Record = { - system: "var(--syntax-info)", - user: "var(--syntax-success)", - assistant: "var(--syntax-property)", - tool: "var(--syntax-warning)", - other: "var(--syntax-comment)", -} - -function Stat(props: { label: string; value: JSX.Element }) { - return ( -
-
{props.label}
-
{props.value}
-
- ) -} - -function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { - const file = createMemo(() => { - const parts = props.getParts(props.message.id) - const contents = JSON.stringify({ message: props.message, parts }, null, 2) - return { - name: `${props.message.role}-${props.message.id}.json`, - contents, - cacheKey: checksum(contents), - } - }) - - return ( - requestAnimationFrame(props.onRendered)} - /> - ) -} - -function RawMessage(props: { - message: Message - getParts: (id: string) => Part[] - onRendered: () => void - time: (value: number | undefined) => string -}) { - return ( - - - -
-
- {props.message.role} • {props.message.id} -
-
-
{props.time(props.message.time.created)}
- -
-
-
-
- -
- -
-
-
- ) -} - -const emptyMessages: Message[] = [] -const emptyUserMessages: UserMessage[] = [] - -export function SessionContextTab() { - const sync = useSync() - const language = useLanguage() - const { params, view } = useSessionLayout() - - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - - const messages = createMemo( - () => { - const id = params.id - if (!id) return emptyMessages - return (sync.data.message[id] ?? []) as Message[] - }, - emptyMessages, - { equals: same }, - ) - - const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], - emptyUserMessages, - { equals: same }, - ) - - const visibleUserMessages = createMemo( - () => { - const revert = info()?.revert?.messageID - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }, - emptyUserMessages, - { equals: same }, - ) - - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - - const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) - const ctx = createMemo(() => metrics().context) - const formatter = createMemo(() => createSessionContextFormatter(language.intl())) - - const cost = createMemo(() => { - return usd().format(metrics().totalCost) - }) - - const counts = createMemo(() => { - const all = messages() - const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) - const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) - return { - all: all.length, - user, - assistant, - } - }) - - const systemPrompt = createMemo(() => { - const msg = findLast(visibleUserMessages(), (m) => !!m.system) - const system = msg?.system - if (!system) return - const trimmed = system.trim() - if (!trimmed) return - return trimmed - }) - - const providerLabel = createMemo(() => { - const c = ctx() - if (!c) return "—" - return c.providerLabel - }) - - const modelLabel = createMemo(() => { - const c = ctx() - if (!c) return "—" - return c.modelLabel - }) - - const breakdown = createMemo( - on( - () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], - () => { - const c = ctx() - if (!c?.input) return [] - return estimateSessionContextBreakdown({ - messages: messages(), - parts: sync.data.part as Record, - input: c.input, - systemPrompt: systemPrompt(), - }) - }, - ), - ) - - const breakdownLabel = (key: SessionContextBreakdownKey) => { - if (key === "system") return language.t("context.breakdown.system") - if (key === "user") return language.t("context.breakdown.user") - if (key === "assistant") return language.t("context.breakdown.assistant") - if (key === "tool") return language.t("context.breakdown.tool") - return language.t("context.breakdown.other") - } - - const stats = [ - { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, - { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, - { label: "context.stats.provider", value: providerLabel }, - { label: "context.stats.model", value: modelLabel }, - { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, - { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, - { - label: "context.stats.cacheTokens", - value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, - }, - { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, - { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, - { label: "context.stats.totalCost", value: cost }, - { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, - { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, - ] satisfies { label: string; value: () => JSX.Element }[] - - let scroll: HTMLDivElement | undefined - let frame: number | undefined - let pending: { x: number; y: number } | undefined - const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[] - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = view().scroll("context") - if (!s) return - - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return - - frame = requestAnimationFrame(() => { - frame = undefined - - const next = pending - pending = undefined - if (!next) return - - view().setScroll("context", next) - }) - } - - createEffect( - on( - () => messages().length, - () => { - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) - }) - - return ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > -
-
- - {(stat) => [0])} value={stat.value()} />} - -
- - 0}> -
-
{language.t("context.breakdown.title")}
-
- - {(segment) => ( -
- )} - -
-
- - {(segment) => ( -
-
-
{breakdownLabel(segment.key)}
-
{segment.percent.toLocaleString(language.intl())}%
-
- )} - -
- -
- - - - {(prompt) => ( -
-
{language.t("context.systemPrompt.title")}
-
- -
-
- )} -
- -
-
{language.t("context.rawMessages.title")}
- - - {(message) => ( - - )} - - -
-
- - ) -} diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx deleted file mode 100644 index e4ef36393627..000000000000 --- a/packages/app/src/components/session/session-new-view.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Show, createMemo } from "solid-js" -import { DateTime } from "luxon" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" -import { useLanguage } from "@/context/language" -import { Icon } from "@opencode-ai/ui/icon" -import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/util/path" - -const MAIN_WORKTREE = "main" -const CREATE_WORKTREE = "create" -const ROOT_CLASS = "size-full flex flex-col" - -interface NewSessionViewProps { - worktree: string -} - -export function NewSessionView(props: NewSessionViewProps) { - const sync = useSync() - const sdk = useSDK() - const language = useLanguage() - - const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) - const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) - const current = createMemo(() => { - const selection = props.worktree - if (options().includes(selection)) return selection - return MAIN_WORKTREE - }) - const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory) - const isWorktree = createMemo(() => { - const project = sync.project - if (!project) return false - return sdk.directory !== project.worktree - }) - - const label = (value: string) => { - if (value === MAIN_WORKTREE) { - if (isWorktree()) return language.t("session.new.worktree.main") - const branch = sync.data.vcs?.branch - if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) - return language.t("session.new.worktree.main") - } - - if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") - - return getFilename(value) - } - - return ( -
-
-
-
-
- -
{language.t("session.new.title")}
-
-
-
-
- {getDirectory(projectRoot())} - {getFilename(projectRoot())} -
-
-
- -
- {label(current())} -
-
- - {(project) => ( -
-
- {language.t("session.new.lastModified")}  - - {DateTime.fromMillis(project().time.updated ?? project().time.created) - .setLocale(language.intl()) - .toRelative()} - -
-
- )} -
-
-
-
-
- ) -} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx deleted file mode 100644 index f4b8198e7e75..000000000000 --- a/packages/app/src/components/settings-general.tsx +++ /dev/null @@ -1,611 +0,0 @@ -import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" -import { Select } from "@opencode-ai/ui/select" -import { Switch } from "@opencode-ai/ui/switch" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { useSettings, monoFontFamily } from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "./link" -import { SettingsList } from "./settings-list" - -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} - -type ThemeOption = { - id: string - name: string -} - -let font: Promise | undefined - -function loadFont() { - font ??= import("@opencode-ai/ui/font-loader") - return font -} - -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined -} - -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) -} - -export const SettingsGeneral: Component = () => { - const theme = useTheme() - const language = useLanguage() - const platform = usePlatform() - const settings = useSettings() - - onMount(() => { - void theme.loadThemes() - }) - - const [store, setStore] = createStore({ - checking: false, - }) - - const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - - const check = () => { - if (!platform.checkUpdate) return - setStore("checking", true) - - void platform - .checkUpdate() - .then((result) => { - if (!result.updateAvailable) { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("settings.updates.toast.latest.title"), - description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), - }) - return - } - - const actions = - platform.update && platform.restart - ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - - showToast({ - persistent: true, - icon: "download", - title: language.t("toast.update.title"), - description: language.t("toast.update.description", { version: result.version ?? "" }), - actions, - }) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setStore("checking", false)) - } - - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - - const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [ - { value: "queue", label: language.t("settings.general.row.followup.option.queue") }, - { value: "steer", label: language.t("settings.general.row.followup.option.steer") }, - ]) - - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - - const fontOptions = [ - { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" }, - { value: "cascadia-code", label: "font.option.cascadiaCode" }, - { value: "fira-code", label: "font.option.firaCode" }, - { value: "hack", label: "font.option.hack" }, - { value: "inconsolata", label: "font.option.inconsolata" }, - { value: "intel-one-mono", label: "font.option.intelOneMono" }, - { value: "iosevka", label: "font.option.iosevka" }, - { value: "jetbrains-mono", label: "font.option.jetbrainsMono" }, - { value: "meslo-lgs", label: "font.option.mesloLgs" }, - { value: "roboto-mono", label: "font.option.robotoMono" }, - { value: "source-code-pro", label: "font.option.sourceCodePro" }, - { value: "ubuntu-mono", label: "font.option.ubuntuMono" }, - { value: "geist-mono", label: "font.option.geistMono" }, - ] as const - const fontOptionsList = [...fontOptions] - - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - variant: "secondary" as const, - size: "small" as const, - triggerVariant: "settings" as const, - }) - - const GeneralSection = () => ( -
- - - o.value === settings.general.followup())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && settings.general.setFollowup(option.value)} - variant="secondary" - size="small" - triggerVariant="settings" - triggerStyle={{ "min-width": "180px" }} - /> - - -
- ) - - const AppearanceSection = () => ( -
-

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

- - - - o.id === theme.themeId())} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - - - - - - -
- ) - - const NotificationsSection = () => ( -
-

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

- - - -
- settings.notifications.setAgent(checked)} - /> -
-
- - -
- settings.notifications.setPermissions(checked)} - /> -
-
- - -
- settings.notifications.setErrors(checked)} - /> -
-
-
-
- ) - - const SoundsSection = () => ( -
-

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

- - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - /> - - - - - option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn") - } - onSelect={(option) => option && setStore("changes", option)} - variant="ghost" - size="small" - valueClass="text-14-medium" - /> - ) - } - - const emptyTurn = () => ( -
-
{language.t("session.review.noChanges")}
-
- ) - - const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { - if (store.changes === "turn") return emptyTurn() - - if (hasReview() && !diffsReady()) { - return
{language.t("session.review.loadingChanges")}
- } - - if (reviewEmptyKey() === "session.review.noVcs") { - return ( -
-
-
{language.t("session.review.noVcs.createGit.title")}
-
- {language.t("session.review.noVcs.createGit.description")} -
-
- -
- ) - } - - return ( -
-
{language.t(reviewEmptyKey())}
-
- ) - } - - const reviewContent = (input: { - diffStyle: DiffStyle - onDiffStyleChange?: (style: DiffStyle) => void - classes?: SessionReviewTabProps["classes"] - loadingClass: string - emptyClass: string - }) => ( - - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - ) - - const reviewPanel = () => ( -
-
- {reviewContent({ - diffStyle: layout.review.diffStyle(), - onDiffStyleChange: layout.review.setDiffStyle, - loadingClass: "px-6 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - })} -
-
- ) - - createEffect( - on( - activeFileTab, - (active) => { - if (!active) return - if (fileTreeTab() !== "changes") return - showAllFiles() - }, - { defer: true }, - ), - ) - - const reviewDiffId = (path: string) => { - const sum = checksum(path) - if (!sum) return - return `session-review-diff-${sum}` - } - - const reviewDiffTop = (path: string) => { - const root = tree.reviewScroll - if (!root) return - - const id = reviewDiffId(path) - if (!id) return - - const el = document.getElementById(id) - if (!(el instanceof HTMLElement)) return - if (!root.contains(el)) return - - const a = el.getBoundingClientRect() - const b = root.getBoundingClientRect() - return a.top - b.top + root.scrollTop - } - - const scrollToReviewDiff = (path: string) => { - const root = tree.reviewScroll - if (!root) return false - - const top = reviewDiffTop(path) - if (top === undefined) return false - - view().setScroll("review", { x: root.scrollLeft, y: top }) - root.scrollTo({ top, behavior: "auto" }) - return true - } - - const focusReviewDiff = (path: string) => { - openReviewPanel() - view().review.openPath(path) - setTree({ activeDiff: path, pendingDiff: path }) - } - - createEffect(() => { - const pending = tree.pendingDiff - if (!pending) return - if (!tree.reviewScroll) return - if (!diffsReady()) return - - const attempt = (count: number) => { - if (tree.pendingDiff !== pending) return - if (count > 60) { - setTree("pendingDiff", undefined) - return - } - - const root = tree.reviewScroll - if (!root) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - if (!scrollToReviewDiff(pending)) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - const top = reviewDiffTop(pending) - if (top === undefined) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - if (Math.abs(root.scrollTop - top) <= 1) { - setTree("pendingDiff", undefined) - return - } - - requestAnimationFrame(() => attempt(count + 1)) - } - - requestAnimationFrame(() => attempt(0)) - }) - - createEffect(() => { - const id = params.id - if (!id) return - - const wants = isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes" - if (!wants) return - if (sync.data.session_diff[id] !== undefined) return - if (sync.status === "loading") return - - void sync.session.diff(id) - }) - - createEffect( - on( - () => - [ - sessionKey(), - isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes", - ] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined - if (!wants) return - - const id = params.id - if (!id) return - if (!untrack(() => sync.data.session_diff[id] !== undefined)) return - - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return - void sync.session.diff(id, { force: true }) - }, 0) - }) - }, - { defer: true }, - ), - ) - - let treeDir: string | undefined - createEffect(() => { - const dir = sdk.directory - if (!isDesktop()) return - if (!layout.fileTree.opened()) return - if (sync.status === "loading") return - - fileTreeTab() - const refresh = treeDir !== dir - treeDir = dir - void (refresh ? file.tree.refresh("") : file.tree.list("")) - }) - - createEffect( - on( - () => sdk.directory, - () => { - void file.tree.list("") - - const tab = activeFileTab() - if (!tab) return - const path = file.pathFromTab(tab) - if (!path) return - void file.load(path, { force: true }) - }, - { defer: true }, - ), - ) - - const autoScroll = createAutoScroll({ - working: () => true, - overflowAnchor: "dynamic", - }) - - let scrollStateFrame: number | undefined - let scrollStateTarget: HTMLDivElement | undefined - let fillFrame: number | undefined - - const updateScrollState = (el: HTMLDivElement) => { - const max = el.scrollHeight - el.clientHeight - const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 - - if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return - setUi("scroll", { overflow, bottom }) - } - - const scheduleScrollState = (el: HTMLDivElement) => { - scrollStateTarget = el - if (scrollStateFrame !== undefined) return - - scrollStateFrame = requestAnimationFrame(() => { - scrollStateFrame = undefined - - const target = scrollStateTarget - scrollStateTarget = undefined - if (!target) return - - updateScrollState(target) - }) - } - - const resumeScroll = () => { - setStore("messageId", undefined) - autoScroll.forceScrollToBottom() - clearMessageHash() - - const el = scroller - if (el) scheduleScrollState(el) - } - - // When the user returns to the bottom, treat the active message as "latest". - createEffect( - on( - autoScroll.userScrolled, - (scrolled) => { - if (scrolled) return - setStore("messageId", undefined) - clearMessageHash() - }, - { defer: true }, - ), - ) - - let fill = () => {} - - const setScrollRef = (el: HTMLDivElement | undefined) => { - scroller = el - autoScroll.scrollRef(el) - if (!el) return - scheduleScrollState(el) - fill() - } - - const markUserScroll = () => { - scrollMark += 1 - } - - createResizeObserver( - () => content, - () => { - const el = scroller - if (el) scheduleScrollState(el) - fill() - }, - ) - - const historyWindow = createSessionHistoryWindow({ - sessionID: () => params.id, - messagesReady, - loaded: () => messages().length, - visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync.session.history.loadMore(sessionID), - userScrolled: autoScroll.userScrolled, - scroller: () => scroller, - }) - - fill = () => { - if (fillFrame !== undefined) return - - fillFrame = requestAnimationFrame(() => { - fillFrame = undefined - - if (!params.id || !messagesReady()) return - if (autoScroll.userScrolled() || historyLoading()) return - - const el = scroller - if (!el) return - if (el.scrollHeight > el.clientHeight + 1) return - if (historyWindow.turnStart() <= 0 && !historyMore()) return - - void historyWindow.loadAndReveal() - }) - } - - createEffect( - on( - () => - [ - params.id, - messagesReady(), - historyWindow.turnStart(), - historyMore(), - historyLoading(), - autoScroll.userScrolled(), - visibleUserMessages().length, - ] as const, - ([id, ready, start, more, loading, scrolled]) => { - if (!id || !ready || loading || scrolled) return - if (start <= 0 && !more) return - fill() - }, - { defer: true }, - ), - ) - - const draft = (id: string) => - extractPromptFromParts(sync.data.part[id] ?? [], { - directory: sdk.directory, - attachmentName: language.t("common.attachment"), - }) - - const line = (id: string) => { - const text = draft(id) - .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) - .join("") - .replace(/\s+/g, " ") - .trim() - if (text) return text - return `[${language.t("common.attachment")}]` - } - - const fail = (err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - } - - const merge = (next: NonNullable>) => - sync.set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) - - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync.set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) - - const busy = (sessionID: string) => { - if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true - return (sync.data.message[sessionID] ?? []).some( - (item) => item.role === "assistant" && typeof item.time.completed !== "number", - ) - } - - const queuedFollowups = createMemo(() => { - const id = params.id - if (!id) return emptyFollowups - return followup.items[id] ?? emptyFollowups - }) - - const editingFollowup = createMemo(() => { - const id = params.id - if (!id) return - return followup.edit[id] - }) - - const followupMutation = useMutation(() => ({ - mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { - const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) - if (!item) return - - if (input.manual) setFollowup("paused", input.sessionID, undefined) - setFollowup("failed", input.sessionID, undefined) - - const ok = await sendFollowupDraft({ - client: sdk.client, - sync, - globalSync, - draft: item, - optimisticBusy: item.sessionDirectory === sdk.directory, - }).catch((err) => { - setFollowup("failed", input.sessionID, input.id) - fail(err) - return false - }) - if (!ok) return - - setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() - }, - })) - - const followupBusy = (sessionID: string) => - followupMutation.isPending && followupMutation.variables?.sessionID === sessionID - - const sendingFollowup = createMemo(() => { - const id = params.id - if (!id) return - if (!followupBusy(id)) return - return followupMutation.variables?.id - }) - - const queueEnabled = createMemo(() => { - const id = params.id - if (!id) return false - return settings.general.followup() === "queue" && busy(id) && !composer.blocked() - }) - - const followupText = (item: FollowupDraft) => { - const text = item.prompt - .map((part) => { - if (part.type === "image") return `[image:${part.filename}]` - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - return part.content - }) - .join("") - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => !!line) - - if (text) return text - return `[${language.t("common.attachment")}]` - } - - const queueFollowup = (draft: FollowupDraft) => { - setFollowup("items", draft.sessionID, (items) => [ - ...(items ?? []), - { id: Identifier.ascending("message"), ...draft }, - ]) - setFollowup("failed", draft.sessionID, undefined) - setFollowup("paused", draft.sessionID, undefined) - } - - const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) - - const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { - const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) - if (!item) return Promise.resolve() - if (followupBusy(sessionID)) return Promise.resolve() - - return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) - } - - const editFollowup = (id: string) => { - const sessionID = params.id - if (!sessionID) return - if (followupBusy(sessionID)) return - - const item = queuedFollowups().find((entry) => entry.id === id) - if (!item) return - - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) - setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) - setFollowup("edit", sessionID, { - id: item.id, - prompt: item.prompt, - context: item.context, - }) - } - - const clearFollowupEdit = () => { - const id = params.id - if (!id) return - setFollowup("edit", id, undefined) - } - - const halt = (sessionID: string) => - busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() - - const revertMutation = useMutation(() => ({ - mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert - const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) - }) - await halt(input.sessionID) - .then(() => sdk.client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - }, - })) - - const restoreMutation = useMutation(() => ({ - mutationFn: async (id: string) => { - const sessionID = params.id - if (!sessionID) return - - const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert - - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() - }) - - const task = !next - ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk.client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - }, - })) - - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) - const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) - - const fork = (input: { sessionID: string; messageID: string }) => { - const value = draft(input.messageID) - const dir = base64Encode(sdk.directory) - return sdk.client.session - .fork(input) - .then((result) => { - const next = result.data - if (!next) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - }) - return - } - prompt.set(value, undefined, { dir, id: next.id }) - navigate(`/${dir}/session/${next.id}`) - }) - .catch(fail) - } - - const revert = (input: { sessionID: string; messageID: string }) => { - if (reverting()) return - return revertMutation.mutateAsync(input) - } - - const restore = (id: string) => { - if (!params.id || reverting()) return - return restoreMutation.mutateAsync(id) - } - - const rolled = createMemo(() => { - const id = revertMessageID() - if (!id) return [] - return userMessages() - .filter((item) => item.id >= id) - .map((item) => ({ id: item.id, text: line(item.id) })) - }) - - const actions = { fork, revert } - - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - - const item = queuedFollowups()[0] - if (!item) return - if (followupBusy(sessionID)) return - if (followup.failed[sessionID] === item.id) return - if (followup.paused[sessionID]) return - if (composer.blocked()) return - if (busy(sessionID)) return - - void sendFollowup(sessionID, item.id) - }) - - createResizeObserver( - () => promptDock, - ({ height }) => { - const next = Math.ceil(height) - - if (next === dockHeight) return - - const el = scroller - const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false - - dockHeight = next - - if (stick) autoScroll.forceScrollToBottom() - - if (el) scheduleScrollState(el) - fill() - }, - ) - - const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ - sessionKey, - sessionID: () => params.id, - messagesReady, - visibleUserMessages, - turnStart: historyWindow.turnStart, - currentMessageId: () => store.messageId, - pendingMessage: () => ui.pendingMessage, - setPendingMessage: (value) => setUi("pendingMessage", value), - setActiveMessage, - setTurnStart: historyWindow.setTurnStart, - autoScroll, - scroller: () => scroller, - anchor, - scheduleScrollState, - consumePendingMessage: layout.pendingMessage.consume, - }) - - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) - if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) - }) - - return ( -
- -
- - - - setStore("mobileTab", "session")} - > - {language.t("session.tab.session")} - - setStore("mobileTab", "changes")} - > - {hasReview() - ? language.t("session.review.filesChanged", { count: reviewCount() }) - : language.t("session.review.change.other")} - - - - - - {/* Session panel */} -
-
- - - - { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} - anchor={anchor} - /> - - - - - - -
- - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - onResponseSubmit={resumeScroll} - followup={ - params.id - ? { - queue: queueEnabled, - items: followupDock(), - sending: sendingFollowup(), - edit: editingFollowup(), - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - onSend: (id) => { - void sendFollowup(params.id!, id, { manual: true }) - }, - onEdit: editFollowup, - onEditLoaded: clearFollowupEdit, - } - : undefined - } - revert={ - rolled().length > 0 - ? { - items: rolled(), - restoring: restoring(), - disabled: reverting(), - onRestore: restore, - } - : undefined - } - setPromptDockRef={(el) => { - promptDock = el - }} - /> - - -
size.start()}> - { - size.touch() - layout.session.resize(width) - }} - /> -
-
-
- - -
- - -
- ) -} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx deleted file mode 100644 index 5fef41a55056..000000000000 --- a/packages/app/src/pages/session/message-timeline.tsx +++ /dev/null @@ -1,1013 +0,0 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate } from "@solidjs/router" -import { useMutation } from "@tanstack/solid-query" -import { Button } from "@opencode-ai/ui/button" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" -import { Spinner } from "@opencode-ai/ui/spinner" -import { SessionTurn } from "@opencode-ai/ui/session-turn" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { TextField } from "@opencode-ai/ui/text-field" -import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" -import { Binary } from "@opencode-ai/util/binary" -import { getFilename } from "@opencode-ai/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLanguage } from "@/context/language" -import { useSessionKey } from "@/pages/session/session-layout" -import { useGlobalSDK } from "@/context/global-sdk" -import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { messageAgentColor } from "@/utils/agent" -import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { makeTimer } from "@solid-primitives/timer" - -type MessageComment = { - path: string - comment: string - selection?: { - startLine: number - endLine: number - } -} - -const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } - -type UserActions = { - fork?: (input: { sessionID: string; messageID: string }) => Promise | void - revert?: (input: { sessionID: string; messageID: string }) => Promise | void -} - -const messageComments = (parts: Part[]): MessageComment[] => - parts.flatMap((part) => { - if (part.type !== "text" || !(part as TextPart).synthetic) return [] - const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) - if (!next) return [] - return [ - { - path: next.path, - comment: next.comment, - selection: next.selection - ? { - startLine: next.selection.startLine, - endLine: next.selection.endLine, - } - : undefined, - }, - ] - }) - -const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { - const current = target instanceof Element ? target : undefined - const nested = current?.closest("[data-scrollable]") - if (!nested || nested === root) return root - if (!(nested instanceof HTMLElement)) return root - return nested -} - -const markBoundaryGesture = (input: { - root: HTMLDivElement - target: EventTarget | null - delta: number - onMarkScrollGesture: (target?: EventTarget | null) => void -}) => { - const target = boundaryTarget(input.root, input.target) - if (target === input.root) { - input.onMarkScrollGesture(input.root) - return - } - if ( - shouldMarkBoundaryGesture({ - delta: input.delta, - scrollTop: target.scrollTop, - scrollHeight: target.scrollHeight, - clientHeight: target.clientHeight, - }) - ) { - input.onMarkScrollGesture(input.root) - } -} - -type StageConfig = { - init: number - batch: number -} - -type TimelineStageInput = { - sessionKey: () => string - turnStart: () => number - messages: () => UserMessage[] - config: StageConfig -} - -/** - * Defer-mounts small timeline windows so revealing older turns does not - * block first paint with a large DOM mount. - * - * Once staging completes for a session it never re-stages — backfill and - * new messages render immediately. - */ -function createTimelineStaging(input: TimelineStageInput) { - const [state, setState] = createStore({ - activeSession: "", - completedSession: "", - count: 0, - }) - - const stagedCount = createMemo(() => { - const total = input.messages().length - if (input.turnStart() <= 0) return total - if (state.completedSession === input.sessionKey()) return total - const init = Math.min(total, input.config.init) - if (state.count <= init) return init - if (state.count >= total) return total - return state.count - }) - - const stagedUserMessages = createMemo(() => { - const list = input.messages() - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) - }) - - let frame: number | undefined - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - createEffect( - on( - () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, - ([sessionKey, isWindowed, total]) => { - cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey - if (!shouldStage) { - setState({ activeSession: "", count: total }) - return - } - - let count = Math.min(total, input.config.init) - setState({ activeSession: sessionKey, count }) - - const step = () => { - if (input.sessionKey() !== sessionKey) { - frame = undefined - return - } - const currentTotal = input.messages().length - count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) - if (count >= currentTotal) { - setState({ completedSession: sessionKey, activeSession: "" }) - frame = undefined - return - } - frame = requestAnimationFrame(step) - } - frame = requestAnimationFrame(step) - }, - ), - ) - - const isStaging = createMemo(() => { - const key = input.sessionKey() - return state.activeSession === key && state.completedSession !== key - }) - - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } -} - -export function MessageTimeline(props: { - mobileChanges: boolean - mobileFallback: JSX.Element - actions?: UserActions - scroll: { overflow: boolean; bottom: boolean } - onResumeScroll: () => void - setScrollRef: (el: HTMLDivElement | undefined) => void - onScheduleScrollState: (el: HTMLDivElement) => void - onAutoScrollHandleScroll: () => void - onMarkScrollGesture: (target?: EventTarget | null) => void - hasScrollGesture: () => boolean - onUserScroll: () => void - onTurnBackfillScroll: () => void - onAutoScrollInteraction: (event: MouseEvent) => void - centered: boolean - setContentRef: (el: HTMLDivElement) => void - turnStart: number - historyMore: boolean - historyLoading: boolean - onLoadEarlier: () => void - renderedUserMessages: UserMessage[] - anchor: (id: string) => string -}) { - let touchGesture: number | undefined - - const navigate = useNavigate() - const globalSDK = useGlobalSDK() - const sdk = useSDK() - const sync = useSync() - const settings = useSettings() - const dialog = useDialog() - const language = useLanguage() - const { params, sessionKey } = useSessionKey() - const platform = usePlatform() - - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) - const sessionID = createMemo(() => params.id) - const sessionMessages = createMemo(() => { - const id = sessionID() - if (!id) return emptyMessages - return sync.data.message[id] ?? emptyMessages - }) - const pending = createMemo(() => - sessionMessages().findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ), - ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) - const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") - const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) - - const [timeoutDone, setTimeoutDone] = createSignal(true) - - const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => { - if (working()) return "showing" - if (prev === "showing" || !timeoutDone()) return "hiding" - return "hidden" - }) - - createEffect(() => { - if (workingStatus() !== "hiding") return - - setTimeoutDone(false) - makeTimer(() => setTimeoutDone(true), 260, setTimeout) - }) - - const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id - } - - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } - } - - return undefined - }) - const info = createMemo(() => { - const id = sessionID() - if (!id) return - return sync.session.get(id) - }) - const titleValue = createMemo(() => info()?.title) - const shareUrl = createMemo(() => info()?.share?.url) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) - const stageCfg = { init: 1, batch: 3 } - const staging = createTimelineStaging({ - sessionKey, - turnStart: () => props.turnStart, - messages: () => props.renderedUserMessages, - config: stageCfg, - }) - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - menuOpen: false, - pendingRename: false, - pendingShare: false, - }) - let titleRef: HTMLInputElement | undefined - - const [share, setShare] = createStore({ - open: false, - dismiss: null as "escape" | "outside" | null, - }) - - let more: HTMLButtonElement | undefined - - const viewShare = () => { - const url = shareUrl() - if (!url) return - platform.openLink(url) - } - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - const shareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to share session", err) - }, - })) - - const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to unshare session", err) - }, - })) - - const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => - sdk.client.session.update({ sessionID: input.id, title: input.title }), - onSuccess: (_, input) => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === input.id) - if (index !== -1) draft.session[index].title = input.title - }), - ) - setTitle("editing", false) - }, - onError: (err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }, - })) - - const shareSession = () => { - const id = sessionID() - if (!id || shareMutation.isPending) return - if (!shareEnabled()) return - shareMutation.mutate(id) - } - - const unshareSession = () => { - const id = sessionID() - if (!id || unshareMutation.isPending) return - if (!shareEnabled()) return - unshareMutation.mutate(id) - } - - createEffect( - on( - sessionKey, - () => - setTitle({ - draft: "", - editing: false, - menuOpen: false, - pendingRename: false, - pendingShare: false, - }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!sessionID()) return - setTitle({ editing: true, draft: titleValue() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (titleMutation.isPending) return - setTitle("editing", false) - } - - const saveTitleEditor = () => { - const id = sessionID() - if (!id) return - if (titleMutation.isPending) return - - const next = title.draft.trim() - if (!next || next === (titleValue() ?? "")) { - setTitle("editing", false) - return - } - - titleMutation.mutate({ id, title: next }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - const archiveSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return - - const sessions = sync.data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const deleteSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return false - - const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } - - return ( - {props.mobileFallback}
} - > -
-
- -
- { - const root = e.currentTarget - const delta = normalizeWheelDelta({ - deltaY: e.deltaY, - deltaMode: e.deltaMode, - rootHeight: root.clientHeight, - }) - if (!delta) return - markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) - }} - onTouchStart={(e) => { - touchGesture = e.touches[0]?.clientY - }} - onTouchMove={(e) => { - const next = e.touches[0]?.clientY - const prev = touchGesture - touchGesture = next - if (next === undefined || prev === undefined) return - - const delta = prev - next - if (!delta) return - - const root = e.currentTarget - markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) - }} - onTouchEnd={() => { - touchGesture = undefined - }} - onTouchCancel={() => { - touchGesture = undefined - }} - onPointerDown={(e) => { - if (e.target !== e.currentTarget) return - props.onMarkScrollGesture(e.currentTarget) - }} - onScroll={(e) => { - props.onScheduleScrollState(e.currentTarget) - props.onTurnBackfillScroll() - if (!props.hasScrollGesture()) return - props.onUserScroll() - props.onAutoScrollHandleScroll() - props.onMarkScrollGesture(e.currentTarget) - }} - onClick={props.onAutoScrollInteraction} - class="relative min-w-0 w-full h-full" - style={{ - "--session-title-height": showHeader() ? "40px" : "0px", - "--sticky-accordion-top": showHeader() ? "48px" : "0px", - }} - > -
- -
-
-
- - - -
- - - - {titleValue()} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={titleMutation.isPending} - class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
-
- - {(id) => ( -
- - { - setTitle("menuOpen", open) - if (open) return - }} - > - { - more = el - }} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) - } - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - - { - setTitle({ pendingShare: true, menuOpen: false }) - }} - > - - {language.t("session.share.action.share")} - - - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - - - more} - placement="bottom-end" - gutter={4} - modal={false} - onOpenChange={(open) => { - if (open) setShare("dismiss", null) - setShare("open", open) - }} - > - - { - setShare({ dismiss: "escape", open: false }) - event.preventDefault() - event.stopPropagation() - }} - onPointerDownOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onFocusOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onCloseAutoFocus={(event) => { - if (share.dismiss === "outside") event.preventDefault() - setShare("dismiss", null) - }} - > -
-
-
- {language.t("session.share.popover.title")} -
-
- {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} -
-
-
- - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - - } - > -
- -
- - -
-
-
-
-
-
-
-
-
- )} -
-
-
-
-
- 0 || props.historyMore}> -
- -
-
- - {(messageID) => { - const active = createMemo(() => activeMessageID() === messageID) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => - a.length === b.length && - a.every( - (c, i) => - c.path === b[i].path && - c.comment === b[i].comment && - c.selection?.startLine === b[i].selection?.startLine && - c.selection?.endLine === b[i].selection?.endLine, - ), - }) - const commentCount = createMemo(() => comments().length) - return ( -
- 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - - {(c) => ( -
-
- - {getFilename(c().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {c().comment} -
-
- )} -
- ) - }} -
-
-
-
-
- -
- ) - }} -
-
-
-
-
- - ) -} diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg index 6a67f62717b1..a39c165cedcd 100644 --- a/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg +++ b/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-logo-dark.svg b/packages/console/app/src/asset/brand/opencode-logo-dark.svg index c28babff1be1..790fbc494fe5 100644 --- a/packages/console/app/src/asset/brand/opencode-logo-dark.svg +++ b/packages/console/app/src/asset/brand/opencode-logo-dark.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-logo-light-square.svg b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg index a738ad87dbb5..5c45eae60ea5 100644 --- a/packages/console/app/src/asset/brand/opencode-logo-light-square.svg +++ b/packages/console/app/src/asset/brand/opencode-logo-light-square.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-logo-light.svg b/packages/console/app/src/asset/brand/opencode-logo-light.svg index 7ed0af003bb6..5d4a01a49b53 100644 --- a/packages/console/app/src/asset/brand/opencode-logo-light.svg +++ b/packages/console/app/src/asset/brand/opencode-logo-light.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-wordmark-dark.svg b/packages/console/app/src/asset/brand/opencode-wordmark-dark.svg index a242eeeab126..8fef01609245 100644 --- a/packages/console/app/src/asset/brand/opencode-wordmark-dark.svg +++ b/packages/console/app/src/asset/brand/opencode-wordmark-dark.svg @@ -1,30 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-wordmark-light.svg b/packages/console/app/src/asset/brand/opencode-wordmark-light.svg index 24a36c7ce7fa..c970f55a3193 100644 --- a/packages/console/app/src/asset/brand/opencode-wordmark-light.svg +++ b/packages/console/app/src/asset/brand/opencode-wordmark-light.svg @@ -1,30 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-wordmark-simple-dark.svg b/packages/console/app/src/asset/brand/opencode-wordmark-simple-dark.svg index afc323e4d558..041d9c0f3643 100644 --- a/packages/console/app/src/asset/brand/opencode-wordmark-simple-dark.svg +++ b/packages/console/app/src/asset/brand/opencode-wordmark-simple-dark.svg @@ -1,22 +1 @@ - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/brand/opencode-wordmark-simple-light.svg b/packages/console/app/src/asset/brand/opencode-wordmark-simple-light.svg index 29be24534d26..ce06c9ae2ce4 100644 --- a/packages/console/app/src/asset/brand/opencode-wordmark-simple-light.svg +++ b/packages/console/app/src/asset/brand/opencode-wordmark-simple-light.svg @@ -1,22 +1 @@ - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/go-ornate-dark.svg b/packages/console/app/src/asset/go-ornate-dark.svg index 9b617c6777f0..ce7686e1f3ef 100644 --- a/packages/console/app/src/asset/go-ornate-dark.svg +++ b/packages/console/app/src/asset/go-ornate-dark.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/go-ornate-light.svg b/packages/console/app/src/asset/go-ornate-light.svg index 79991973d6d9..a96688ba1d59 100644 --- a/packages/console/app/src/asset/go-ornate-light.svg +++ b/packages/console/app/src/asset/go-ornate-light.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/brand-assets-dark.svg b/packages/console/app/src/asset/lander/brand-assets-dark.svg index 93da2462d9eb..6dea57d9a0b6 100644 --- a/packages/console/app/src/asset/lander/brand-assets-dark.svg +++ b/packages/console/app/src/asset/lander/brand-assets-dark.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/brand-assets-light.svg b/packages/console/app/src/asset/lander/brand-assets-light.svg index aa9d115bfc97..ebb2556f133f 100644 --- a/packages/console/app/src/asset/lander/brand-assets-light.svg +++ b/packages/console/app/src/asset/lander/brand-assets-light.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/check.svg b/packages/console/app/src/asset/lander/check.svg index 0ac7759ea56c..0a68876455e2 100644 --- a/packages/console/app/src/asset/lander/check.svg +++ b/packages/console/app/src/asset/lander/check.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/copy.svg b/packages/console/app/src/asset/lander/copy.svg index e2263279e5ea..727e5ff384f5 100644 --- a/packages/console/app/src/asset/lander/copy.svg +++ b/packages/console/app/src/asset/lander/copy.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/logo-dark.svg b/packages/console/app/src/asset/lander/logo-dark.svg index d73830f9313d..fc80cbf6a4e6 100644 --- a/packages/console/app/src/asset/lander/logo-dark.svg +++ b/packages/console/app/src/asset/lander/logo-dark.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/logo-light.svg b/packages/console/app/src/asset/lander/logo-light.svg index 7394bf432566..aa2f76eba5d0 100644 --- a/packages/console/app/src/asset/lander/logo-light.svg +++ b/packages/console/app/src/asset/lander/logo-light.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/opencode-logo-dark.svg b/packages/console/app/src/asset/lander/opencode-logo-dark.svg index 154000aaa585..07efb360eba0 100644 --- a/packages/console/app/src/asset/lander/opencode-logo-dark.svg +++ b/packages/console/app/src/asset/lander/opencode-logo-dark.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/opencode-logo-light.svg b/packages/console/app/src/asset/lander/opencode-logo-light.svg index c1259a77def3..801d081e60dd 100644 --- a/packages/console/app/src/asset/lander/opencode-logo-light.svg +++ b/packages/console/app/src/asset/lander/opencode-logo-light.svg @@ -1,11 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/opencode-wordmark-dark.svg b/packages/console/app/src/asset/lander/opencode-wordmark-dark.svg index 822d971ad8e1..f8ab70772c95 100644 --- a/packages/console/app/src/asset/lander/opencode-wordmark-dark.svg +++ b/packages/console/app/src/asset/lander/opencode-wordmark-dark.svg @@ -1,25 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/opencode-wordmark-light.svg b/packages/console/app/src/asset/lander/opencode-wordmark-light.svg index 6d98af7004f5..b18291d5ea64 100644 --- a/packages/console/app/src/asset/lander/opencode-wordmark-light.svg +++ b/packages/console/app/src/asset/lander/opencode-wordmark-light.svg @@ -1,25 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/wordmark-dark.svg b/packages/console/app/src/asset/lander/wordmark-dark.svg index 42f8e22a6dc7..aee42d41b16f 100644 --- a/packages/console/app/src/asset/lander/wordmark-dark.svg +++ b/packages/console/app/src/asset/lander/wordmark-dark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/lander/wordmark-light.svg b/packages/console/app/src/asset/lander/wordmark-light.svg index 398278da6906..a81d5bb59408 100644 --- a/packages/console/app/src/asset/lander/wordmark-light.svg +++ b/packages/console/app/src/asset/lander/wordmark-light.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/logo-ornate-dark.svg b/packages/console/app/src/asset/logo-ornate-dark.svg index a1582732423a..9c8e076d266c 100644 --- a/packages/console/app/src/asset/logo-ornate-dark.svg +++ b/packages/console/app/src/asset/logo-ornate-dark.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/logo-ornate-light.svg b/packages/console/app/src/asset/logo-ornate-light.svg index 2a856dccefe8..6e3e80c6a2ed 100644 --- a/packages/console/app/src/asset/logo-ornate-light.svg +++ b/packages/console/app/src/asset/logo-ornate-light.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/logo.svg b/packages/console/app/src/asset/logo.svg index 2a856dccefe8..6e3e80c6a2ed 100644 --- a/packages/console/app/src/asset/logo.svg +++ b/packages/console/app/src/asset/logo.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/zen-ornate-dark.svg b/packages/console/app/src/asset/zen-ornate-dark.svg index cdc4485fc59d..23b500f8106c 100644 --- a/packages/console/app/src/asset/zen-ornate-dark.svg +++ b/packages/console/app/src/asset/zen-ornate-dark.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/packages/console/app/src/asset/zen-ornate-light.svg b/packages/console/app/src/asset/zen-ornate-light.svg index 2a9ed13421e3..45e35dcbaf66 100644 --- a/packages/console/app/src/asset/zen-ornate-light.svg +++ b/packages/console/app/src/asset/zen-ornate-light.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/packages/desktop/src-tauri/icons/dev/128x128.png b/packages/desktop/src-tauri/icons/dev/128x128.png index d7fc4db1498f..09099915bb77 100644 Binary files a/packages/desktop/src-tauri/icons/dev/128x128.png and b/packages/desktop/src-tauri/icons/dev/128x128.png differ diff --git a/packages/desktop/src-tauri/icons/dev/128x128@2x.png b/packages/desktop/src-tauri/icons/dev/128x128@2x.png index 59188230647c..05fece818111 100644 Binary files a/packages/desktop/src-tauri/icons/dev/128x128@2x.png and b/packages/desktop/src-tauri/icons/dev/128x128@2x.png differ diff --git a/packages/desktop/src-tauri/icons/dev/32x32.png b/packages/desktop/src-tauri/icons/dev/32x32.png index 53925cc4f546..c86bc542eaee 100644 Binary files a/packages/desktop/src-tauri/icons/dev/32x32.png and b/packages/desktop/src-tauri/icons/dev/32x32.png differ diff --git a/packages/desktop/src-tauri/icons/dev/64x64.png b/packages/desktop/src-tauri/icons/dev/64x64.png index a88ef15c64ac..b102e8e01c56 100644 Binary files a/packages/desktop/src-tauri/icons/dev/64x64.png and b/packages/desktop/src-tauri/icons/dev/64x64.png differ diff --git a/packages/desktop/src-tauri/icons/dev/icon.icns b/packages/desktop/src-tauri/icons/dev/icon.icns index d73a94904ad7..d017f8d8b0cc 100644 Binary files a/packages/desktop/src-tauri/icons/dev/icon.icns and b/packages/desktop/src-tauri/icons/dev/icon.icns differ diff --git a/packages/desktop/src-tauri/icons/dev/icon.png b/packages/desktop/src-tauri/icons/dev/icon.png index 6de37ea2942a..f6387977479b 100644 Binary files a/packages/desktop/src-tauri/icons/dev/icon.png and b/packages/desktop/src-tauri/icons/dev/icon.png differ diff --git a/packages/docs/favicon-v3.svg b/packages/docs/favicon-v3.svg index b785c738bf17..595c77f3a150 100644 --- a/packages/docs/favicon-v3.svg +++ b/packages/docs/favicon-v3.svg @@ -1,19 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/docs/favicon.svg b/packages/docs/favicon.svg index b785c738bf17..595c77f3a150 100644 --- a/packages/docs/favicon.svg +++ b/packages/docs/favicon.svg @@ -1,19 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/docs/logo/dark.svg b/packages/docs/logo/dark.svg index 8b343cd6fc90..8b0ec5562f16 100644 --- a/packages/docs/logo/dark.svg +++ b/packages/docs/logo/dark.svg @@ -1,21 +1 @@ - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/docs/logo/light.svg b/packages/docs/logo/light.svg index 03e62bf1d9fc..a9cfe5450db5 100644 --- a/packages/docs/logo/light.svg +++ b/packages/docs/logo/light.svg @@ -1,21 +1 @@ - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/extensions/zed/icons/opencode.svg b/packages/extensions/zed/icons/opencode.svg index fc001e49b5c6..7868bb73d36f 100644 --- a/packages/extensions/zed/icons/opencode.svg +++ b/packages/extensions/zed/icons/opencode.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/identity/mark-light.svg b/packages/identity/mark-light.svg index ac619f1b2ff2..19a2b29408e0 100644 --- a/packages/identity/mark-light.svg +++ b/packages/identity/mark-light.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/identity/mark.svg b/packages/identity/mark.svg index 157edc4d7522..7e00cac5fafb 100644 --- a/packages/identity/mark.svg +++ b/packages/identity/mark.svg @@ -1,7 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 7a171f4dbb66..b0cde7a9e9ce 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -46,7 +46,7 @@ export namespace ProviderError { function message(providerID: ProviderID, e: APICallError) { return iife(() => { const msg = e.message - if (msg === "") { + if (msg === "" || msg === "undefined") { if (e.responseBody) return e.responseBody if (e.statusCode) { const err = STATUS_CODES[e.statusCode] diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6ab45d028b9a..0fcbb0752f4b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -900,6 +900,17 @@ export namespace Provider { m.variants = mapValues(ProviderTransform.variants(m), (v) => v) + // Bedrock enforces 200K context unless the context-1m beta header is sent. + // models-snapshot.ts (auto-generated) lists capability (1M) not runtime limit. + const BEDROCK_CONTEXT_CAP = 200_000 + if ( + provider.id === "amazon-bedrock" && + m.limit.context > BEDROCK_CONTEXT_CAP && + m.id.includes("anthropic") + ) { + m.limit.context = BEDROCK_CONTEXT_CAP + } + return m } @@ -1037,6 +1048,16 @@ export namespace Provider { pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]), ) + // Bedrock enforces 200K context unless the context-1m beta header is sent. + // models-snapshot.ts (auto-generated) lists capability (1M) not runtime limit. + const BEDROCK_CONTEXT_CAP = 200_000 + if ( + providerID === "amazon-bedrock" && + parsedModel.limit.context > BEDROCK_CONTEXT_CAP && + parsedModel.id.includes("anthropic") + ) { + parsedModel.limit.context = BEDROCK_CONTEXT_CAP + } parsed.models[modelID] = parsedModel } database[providerID] = parsed diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts deleted file mode 100644 index abc820c2af71..000000000000 --- a/packages/opencode/src/server/routes/session.ts +++ /dev/null @@ -1,1023 +0,0 @@ -import { Hono } from "hono" -import { stream } from "hono/streaming" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID, MessageID, PartID } from "@/session/schema" -import z from "zod" -import { Session } from "../../session" -import { MessageV2 } from "../../session/message-v2" -import { SessionPrompt } from "../../session/prompt" -import { SessionCompaction } from "../../session/compaction" -import { SessionRevert } from "../../session/revert" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "../../session/todo" -import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot" -import { Log } from "../../util/log" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { ModelID, ProviderID } from "@/provider/schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -const log = Log.create({ service: "server" }) - -export const SessionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessions: Session.Info[] = [] - for await (const session of Session.list({ - directory: query.directory, - roots: query.roots, - start: query.start, - search: query.search, - limit: query.limit, - })) { - sessions.push(session) - } - return c.json(sessions) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - const result = await SessionStatus.list() - return c.json(Object.fromEntries(result)) - }, - ) - .get( - "/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.get.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.children.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await Session.children(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const todos = await Todo.get(sessionID) - return c.json(todos) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator("json", Session.create.schema.optional()), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await Session.create(body) - return c.json(session) - }, - ) - .delete( - "/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.remove.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.remove(sessionID) - return c.json(true) - }, - ) - .patch( - "/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - - let session = await Session.get(sessionID) - if (updates.title !== undefined) { - session = await Session.setTitle({ sessionID, title: updates.title }) - } - if (updates.time?.archived !== undefined) { - session = await Session.setArchived({ sessionID, time: updates.time.archived }) - } - - return c.json(session) - }, - ) - .post( - "/:sessionID/init", - describeRoute({ - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - operationId: "session.init", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", Session.initialize.schema.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await Session.initialize({ ...body, sessionID }) - return c.json(true) - }, - ) - .post( - "/:sessionID/fork", - describeRoute({ - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - operationId: "session.fork", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: Session.fork.schema.shape.sessionID, - }), - ), - validator("json", Session.fork.schema.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await Session.fork({ ...body, sessionID }) - return c.json(result) - }, - ) - .post( - "/:sessionID/abort", - describeRoute({ - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - operationId: "session.abort", - responses: { - 200: { - description: "Aborted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - SessionPrompt.cancel(c.req.valid("param").sessionID) - return c.json(true) - }, - ) - .post( - "/:sessionID/share", - describeRoute({ - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - operationId: "session.share", - responses: { - 200: { - description: "Successfully shared session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/diff", - describeRoute({ - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - operationId: "session.diff", - responses: { - 200: { - description: "Successfully retrieved diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionSummary.diff.schema.shape.sessionID, - }), - ), - validator( - "query", - z.object({ - messageID: SessionSummary.diff.schema.shape.messageID, - }), - ), - async (c) => { - const query = c.req.valid("query") - const params = c.req.valid("param") - const result = await SessionSummary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - return c.json(result) - }, - ) - .delete( - "/:sessionID/share", - describeRoute({ - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - operationId: "session.unshare", - responses: { - 200: { - description: "Successfully unshared session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.unshare.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .post( - "/:sessionID/summarize", - describeRoute({ - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - operationId: "session.summarize", - responses: { - 200: { - description: "Summarized session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - auto: z.boolean().optional().default(false), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = await Session.get(sessionID) - await SessionRevert.cleanup(session) - const msgs = await Session.messages({ sessionID }) - let currentAgent = await Agent.defaultAgent() - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) - break - } - } - await SessionCompaction.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - await SessionPrompt.loop({ sessionID }) - return c.json(true) - }, - ) - .get( - "/:sessionID/message", - describeRoute({ - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - operationId: "session.messages", - responses: { - 200: { - description: "List of messages", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "query", - z - .object({ - limit: z.coerce - .number() - .int() - .min(0) - .optional() - .meta({ description: "Maximum number of messages to return" }), - before: z - .string() - .optional() - .meta({ description: "Opaque cursor for loading older messages" }) - .refine( - (value) => { - if (!value) return true - try { - MessageV2.cursor.decode(value) - return true - } catch { - return false - } - }, - { message: "Invalid cursor" }, - ), - }) - .refine((value) => !value.before || value.limit !== undefined, { - message: "before requires limit", - path: ["before"], - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) - return c.json(messages) - } - - if (query.limit === 0) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) - return c.json(messages) - } - - const page = await MessageV2.page({ - sessionID, - limit: query.limit, - before: query.before, - }) - if (page.cursor) { - const url = new URL(c.req.url) - url.searchParams.set("limit", query.limit.toString()) - url.searchParams.set("before", page.cursor) - c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel=\"next\"`) - c.header("X-Next-Cursor", page.cursor) - } - return c.json(page.items) - }, - ) - .get( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - operationId: "session.message", - responses: { - 200: { - description: "Message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Info, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Delete message", - description: - "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", - operationId: "session.deleteMessage", - responses: { - 200: { - description: "Successfully deleted message", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - SessionPrompt.assertNotBusy(params.sessionID) - await Session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(true) - }, - ) - .delete( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Delete a part from a message", - operationId: "part.delete", - responses: { - 200: { - description: "Successfully deleted part", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - await Session.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return c.json(true) - }, - ) - .patch( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Update a part in a message", - operationId: "part.update", - responses: { - 200: { - description: "Successfully updated part", - content: { - "application/json": { - schema: resolver(MessageV2.Part), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - validator("json", MessageV2.Part), - async (c) => { - const params = c.req.valid("param") - const body = c.req.valid("json") - if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { - throw new Error( - `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, - ) - } - const part = await Session.updatePart(body) - return c.json(part) - }, - ) - .post( - "/:sessionID/message", - describeRoute({ - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - operationId: "session.prompt", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), - async (c) => { - c.status(200) - c.header("Content-Type", "application/json") - return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) - stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/:sessionID/prompt_async", - describeRoute({ - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - operationId: "session.prompt_async", - responses: { - 204: { - description: "Prompt accepted", - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), - async (c) => { - c.status(204) - c.header("Content-Type", "application/json") - return stream(c, async () => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) - }) - }, - ) - .post( - "/:sessionID/command", - describeRoute({ - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - operationId: "session.command", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.command({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/:sessionID/shell", - describeRoute({ - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - operationId: "session.shell", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver(MessageV2.Assistant), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.shell({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/:sessionID/revert", - describeRoute({ - summary: "Revert message", - description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", - operationId: "session.revert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("revert", c.req.valid("json")) - const session = await SessionRevert.revert({ - sessionID, - ...c.req.valid("json"), - }) - return c.json(session) - }, - ) - .post( - "/:sessionID/unrevert", - describeRoute({ - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - operationId: "session.unrevert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await SessionRevert.unrevert({ sessionID }) - return c.json(session) - }, - ) - .post( - "/:sessionID/permissions/:permissionID", - describeRoute({ - summary: "Respond to permission", - deprecated: true, - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.respond", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - }), - ), - validator("json", z.object({ response: Permission.Reply })), - async (c) => { - const params = c.req.valid("param") - Permission.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 072ea1d574e8..4df9ab3de1d4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -28,8 +28,6 @@ export namespace SessionCompaction { ), } - const COMPACTION_BUFFER = 20_000 - export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { const config = await Config.get() if (config.compaction?.auto === false) return false @@ -40,11 +38,12 @@ export namespace SessionCompaction { input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write - const reserved = - config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) - const usable = input.model.limit.input - ? input.model.limit.input - reserved - : context - ProviderTransform.maxOutputTokens(input.model) + // Reserve headroom so compaction triggers before the next turn overflows. + // maxOutputTokens() is capped at 32K (OUTPUT_TOKEN_MAX) regardless of the + // model's raw output limit, so this is never excessively aggressive. + // Users can override via config.compaction.reserved if needed (#12924). + const reserved = config.compaction?.reserved ?? ProviderTransform.maxOutputTokens(input.model) + const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved return count >= usable } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts deleted file mode 100644 index f1335f6f21a3..000000000000 --- a/packages/opencode/src/session/message-v2.ts +++ /dev/null @@ -1,988 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID, PartID } from "./schema" -import z from "zod" -import { NamedError } from "@opencode-ai/util/error" -import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { LSP } from "../lsp" -import { Snapshot } from "@/snapshot" -import { fn } from "@/util/fn" -import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" -import { MessageTable, PartTable, SessionTable } from "./session.sql" -import { ProviderTransform } from "@/provider/transform" -import { STATUS_CODES } from "http" -import { Storage } from "@/storage/storage" -import { ProviderError } from "@/provider/error" -import { iife } from "@/util/iife" -import type { SystemError } from "bun" -import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" - -export namespace MessageV2 { - export function isMedia(mime: string) { - return mime.startsWith("image/") || mime === "application/pdf" - } - - export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) - export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) - export const StructuredOutputError = NamedError.create( - "StructuredOutputError", - z.object({ - message: z.string(), - retries: z.number(), - }), - ) - export const AuthError = NamedError.create( - "ProviderAuthError", - z.object({ - providerID: z.string(), - message: z.string(), - }), - ) - export const APIError = NamedError.create( - "APIError", - z.object({ - message: z.string(), - statusCode: z.number().optional(), - isRetryable: z.boolean(), - responseHeaders: z.record(z.string(), z.string()).optional(), - responseBody: z.string().optional(), - metadata: z.record(z.string(), z.string()).optional(), - }), - ) - export type APIError = z.infer - export const ContextOverflowError = NamedError.create( - "ContextOverflowError", - z.object({ message: z.string(), responseBody: z.string().optional() }), - ) - - export const OutputFormatText = z - .object({ - type: z.literal("text"), - }) - .meta({ - ref: "OutputFormatText", - }) - - export const OutputFormatJsonSchema = z - .object({ - type: z.literal("json_schema"), - schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), - retryCount: z.number().int().min(0).default(2), - }) - .meta({ - ref: "OutputFormatJsonSchema", - }) - - export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({ - ref: "OutputFormat", - }) - export type OutputFormat = z.infer - - const PartBase = z.object({ - id: PartID.zod, - sessionID: SessionID.zod, - messageID: MessageID.zod, - }) - - export const SnapshotPart = PartBase.extend({ - type: z.literal("snapshot"), - snapshot: z.string(), - }).meta({ - ref: "SnapshotPart", - }) - export type SnapshotPart = z.infer - - export const PatchPart = PartBase.extend({ - type: z.literal("patch"), - hash: z.string(), - files: z.string().array(), - }).meta({ - ref: "PatchPart", - }) - export type PatchPart = z.infer - - export const TextPart = PartBase.extend({ - type: z.literal("text"), - text: z.string(), - synthetic: z.boolean().optional(), - ignored: z.boolean().optional(), - time: z - .object({ - start: z.number(), - end: z.number().optional(), - }) - .optional(), - metadata: z.record(z.string(), z.any()).optional(), - }).meta({ - ref: "TextPart", - }) - export type TextPart = z.infer - - export const ReasoningPart = PartBase.extend({ - type: z.literal("reasoning"), - text: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number().optional(), - }), - }).meta({ - ref: "ReasoningPart", - }) - export type ReasoningPart = z.infer - - const FilePartSourceBase = z.object({ - text: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .meta({ - ref: "FilePartSourceText", - }), - }) - - export const FileSource = FilePartSourceBase.extend({ - type: z.literal("file"), - path: z.string(), - }).meta({ - ref: "FileSource", - }) - - export const SymbolSource = FilePartSourceBase.extend({ - type: z.literal("symbol"), - path: z.string(), - range: LSP.Range, - name: z.string(), - kind: z.number().int(), - }).meta({ - ref: "SymbolSource", - }) - - export const ResourceSource = FilePartSourceBase.extend({ - type: z.literal("resource"), - clientName: z.string(), - uri: z.string(), - }).meta({ - ref: "ResourceSource", - }) - - export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({ - ref: "FilePartSource", - }) - - export const FilePart = PartBase.extend({ - type: z.literal("file"), - mime: z.string(), - filename: z.string().optional(), - url: z.string(), - source: FilePartSource.optional(), - }).meta({ - ref: "FilePart", - }) - export type FilePart = z.infer - - export const AgentPart = PartBase.extend({ - type: z.literal("agent"), - name: z.string(), - source: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .optional(), - }).meta({ - ref: "AgentPart", - }) - export type AgentPart = z.infer - - export const CompactionPart = PartBase.extend({ - type: z.literal("compaction"), - auto: z.boolean(), - overflow: z.boolean().optional(), - }).meta({ - ref: "CompactionPart", - }) - export type CompactionPart = z.infer - - export const SubtaskPart = PartBase.extend({ - type: z.literal("subtask"), - prompt: z.string(), - description: z.string(), - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string().optional(), - }).meta({ - ref: "SubtaskPart", - }) - export type SubtaskPart = z.infer - - export const RetryPart = PartBase.extend({ - type: z.literal("retry"), - attempt: z.number(), - error: APIError.Schema, - time: z.object({ - created: z.number(), - }), - }).meta({ - ref: "RetryPart", - }) - export type RetryPart = z.infer - - export const StepStartPart = PartBase.extend({ - type: z.literal("step-start"), - snapshot: z.string().optional(), - }).meta({ - ref: "StepStartPart", - }) - export type StepStartPart = z.infer - - export const StepFinishPart = PartBase.extend({ - type: z.literal("step-finish"), - reason: z.string(), - snapshot: z.string().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }), - }).meta({ - ref: "StepFinishPart", - }) - export type StepFinishPart = z.infer - - export const ToolStatePending = z - .object({ - status: z.literal("pending"), - input: z.record(z.string(), z.any()), - raw: z.string(), - }) - .meta({ - ref: "ToolStatePending", - }) - - export type ToolStatePending = z.infer - - export const ToolStateRunning = z - .object({ - status: z.literal("running"), - input: z.record(z.string(), z.any()), - title: z.string().optional(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - }), - }) - .meta({ - ref: "ToolStateRunning", - }) - export type ToolStateRunning = z.infer - - export const ToolStateCompleted = z - .object({ - status: z.literal("completed"), - input: z.record(z.string(), z.any()), - output: z.string(), - title: z.string(), - metadata: z.record(z.string(), z.any()), - time: z.object({ - start: z.number(), - end: z.number(), - compacted: z.number().optional(), - }), - attachments: FilePart.array().optional(), - }) - .meta({ - ref: "ToolStateCompleted", - }) - export type ToolStateCompleted = z.infer - - export const ToolStateError = z - .object({ - status: z.literal("error"), - input: z.record(z.string(), z.any()), - error: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number(), - }), - }) - .meta({ - ref: "ToolStateError", - }) - export type ToolStateError = z.infer - - export const ToolState = z - .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) - .meta({ - ref: "ToolState", - }) - - export const ToolPart = PartBase.extend({ - type: z.literal("tool"), - callID: z.string(), - tool: z.string(), - state: ToolState, - metadata: z.record(z.string(), z.any()).optional(), - }).meta({ - ref: "ToolPart", - }) - export type ToolPart = z.infer - - const Base = z.object({ - id: MessageID.zod, - sessionID: SessionID.zod, - }) - - export const User = Base.extend({ - role: z.literal("user"), - time: z.object({ - created: z.number(), - }), - format: Format.optional(), - summary: z - .object({ - title: z.string().optional(), - body: z.string().optional(), - diffs: Snapshot.FileDiff.array(), - }) - .optional(), - agent: z.string(), - model: z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }), - system: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), - variant: z.string().optional(), - }).meta({ - ref: "UserMessage", - }) - export type User = z.infer - - export const Part = z - .discriminatedUnion("type", [ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, - ]) - .meta({ - ref: "Part", - }) - export type Part = z.infer - - export const Assistant = Base.extend({ - role: z.literal("assistant"), - time: z.object({ - created: z.number(), - completed: z.number().optional(), - }), - error: z - .discriminatedUnion("name", [ - AuthError.Schema, - NamedError.Unknown.Schema, - OutputLengthError.Schema, - AbortedError.Schema, - StructuredOutputError.Schema, - ContextOverflowError.Schema, - APIError.Schema, - ]) - .optional(), - parentID: MessageID.zod, - modelID: ModelID.zod, - providerID: ProviderID.zod, - /** - * @deprecated - */ - mode: z.string(), - agent: z.string(), - path: z.object({ - cwd: z.string(), - root: z.string(), - }), - summary: z.boolean().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }), - structured: z.any().optional(), - variant: z.string().optional(), - finish: z.string().optional(), - }).meta({ - ref: "AssistantMessage", - }) - export type Assistant = z.infer - - export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ - ref: "Message", - }) - export type Info = z.infer - - export const Event = { - Updated: BusEvent.define( - "message.updated", - z.object({ - info: Info, - }), - ), - Removed: BusEvent.define( - "message.removed", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - PartUpdated: BusEvent.define( - "message.part.updated", - z.object({ - part: Part, - }), - ), - PartDelta: BusEvent.define( - "message.part.delta", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - field: z.string(), - delta: z.string(), - }), - ), - PartRemoved: BusEvent.define( - "message.part.removed", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - } - - export const WithParts = z.object({ - info: Info, - parts: z.array(Part), - }) - export type WithParts = z.infer - - const Cursor = z.object({ - id: MessageID.zod, - time: z.number(), - }) - type Cursor = z.infer - - export const cursor = { - encode(input: Cursor) { - return Buffer.from(JSON.stringify(input)).toString("base64url") - }, - decode(input: string) { - return Cursor.parse(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) - }, - } - - const info = (row: typeof MessageTable.$inferSelect) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - }) as MessageV2.Info - - const part = (row: typeof PartTable.$inferSelect) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as MessageV2.Part - - const older = (row: Cursor) => - or( - lt(MessageTable.time_created, row.time), - and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)), - ) - - async function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { - const ids = rows.map((row) => row.id) - const partByMessage = new Map() - if (ids.length > 0) { - const partRows = Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const next = part(row) - const list = partByMessage.get(row.message_id) - if (list) list.push(next) - else partByMessage.set(row.message_id, [next]) - } - } - - return rows.map((row) => ({ - info: info(row), - parts: partByMessage.get(row.id) ?? [], - })) - } - - export function toModelMessages( - input: WithParts[], - model: Provider.Model, - options?: { stripMedia?: boolean }, - ): ModelMessage[] { - const result: UIMessage[] = [] - const toolNames = new Set() - // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. - // - // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. - // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { - if (model.api.npm === "@ai-sdk/anthropic") return true - if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true - if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true - if (model.api.npm === "@ai-sdk/google") { - const id = model.api.id.toLowerCase() - return id.includes("gemini-3") && !id.includes("gemini-2") - } - return false - })() - - const toModelOutput = (output: unknown) => { - if (typeof output === "string") { - return { type: "text", value: output } - } - - if (typeof output === "object") { - const outputObject = output as { - text: string - attachments?: Array<{ mime: string; url: string }> - } - const attachments = (outputObject.attachments ?? []).filter((attachment) => { - return attachment.url.startsWith("data:") && attachment.url.includes(",") - }) - - return { - type: "content", - value: [ - { type: "text", text: outputObject.text }, - ...attachments.map((attachment) => ({ - type: "media", - mediaType: attachment.mime, - data: iife(() => { - const commaIndex = attachment.url.indexOf(",") - return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) - }), - })), - ], - } - } - - return { type: "json", value: output as never } - } - - for (const msg of input) { - if (msg.parts.length === 0) continue - - if (msg.info.role === "user") { - const userMessage: UIMessage = { - id: msg.info.id, - role: "user", - parts: [], - } - result.push(userMessage) - for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) - userMessage.parts.push({ - type: "text", - text: part.text, - }) - // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { - userMessage.parts.push({ - type: "text", - text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, - }) - } else { - userMessage.parts.push({ - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }) - } - } - - if (part.type === "compaction") { - userMessage.parts.push({ - type: "text", - text: "What did we do so far?", - }) - } - if (part.type === "subtask") { - userMessage.parts.push({ - type: "text", - text: "The following tool was executed by the user", - }) - } - } - } - - if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] - - if ( - msg.info.error && - !( - MessageV2.AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { - continue - } - const assistantMessage: UIMessage = { - id: msg.info.id, - role: "assistant", - parts: [], - } - for (const part of msg.parts) { - if (part.type === "text") - assistantMessage.parts.push({ - type: "text", - text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), - }) - if (part.type === "step-start") - assistantMessage.parts.push({ - type: "step-start", - }) - if (part.type === "tool") { - toolNames.add(part.tool) - if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) - - // For providers that don't support media in tool results, extract media files - // (images, PDFs) to be sent as a separate user message - const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) - } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments - - const output = - finalAttachments.length > 0 - ? { - text: outputText, - attachments: finalAttachments, - } - : outputText - - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), - }) - } - if (part.state.status === "error") - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), - }) - // Handle pending/running tool calls to prevent dangling tool_use blocks - // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result - if (part.state.status === "pending" || part.state.status === "running") - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: "[Tool execution was interrupted]", - ...(differentModel ? {} : { callProviderMetadata: part.metadata }), - }) - } - if (part.type === "reasoning") { - assistantMessage.parts.push({ - type: "reasoning", - text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), - }) - } - } - if (assistantMessage.parts.length > 0) { - result.push(assistantMessage) - // Inject pending media as a user message for providers that don't support - // media (images, PDFs) in tool results - if (media.length > 0) { - result.push({ - id: MessageID.ascending(), - role: "user", - parts: [ - { - type: "text" as const, - text: "Attached image(s) from tool result:", - }, - ...media.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - })), - ], - }) - } - } - } - } - - const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) - - return convertToModelMessages( - result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), - { - //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) - tools, - }, - ) - } - - export const page = fn( - z.object({ - sessionID: SessionID.zod, - limit: z.number().int().positive(), - before: z.string().optional(), - }), - async (input) => { - const before = input.before ? cursor.decode(input.before) : undefined - const where = before - ? and(eq(MessageTable.session_id, input.sessionID), older(before)) - : eq(MessageTable.session_id, input.sessionID) - const rows = Database.use((db) => - db - .select() - .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) - .limit(input.limit + 1) - .all(), - ) - if (rows.length === 0) { - const row = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), - ) - if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - return { - items: [] as MessageV2.WithParts[], - more: false, - } - } - - const more = rows.length > input.limit - const page = more ? rows.slice(0, input.limit) : rows - const items = await hydrate(page) - items.reverse() - const tail = page.at(-1) - return { - items, - more, - cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, - } - }, - ) - - export const stream = fn(SessionID.zod, async function* (sessionID) { - const size = 50 - let before: string | undefined - while (true) { - const next = await page({ sessionID, limit: size, before }) - if (next.items.length === 0) break - for (let i = next.items.length - 1; i >= 0; i--) { - yield next.items[i] - } - if (!next.more || !next.cursor) break - before = next.cursor - } - }) - - export const parts = fn(MessageID.zod, async (message_id) => { - const rows = Database.use((db) => - db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), - ) - return rows.map( - (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part, - ) - }) - - export const get = fn( - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - async (input): Promise => { - const row = Database.use((db) => - db - .select() - .from(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .get(), - ) - if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` }) - return { - info: info(row), - parts: await parts(input.messageID), - } - }, - ) - - export async function filterCompacted(stream: AsyncIterable) { - const result = [] as MessageV2.WithParts[] - const completed = new Set() - for await (const msg of stream) { - result.push(msg) - if ( - msg.info.role === "user" && - completed.has(msg.info.id) && - msg.parts.some((part) => part.type === "compaction") - ) - break - if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) - completed.add(msg.info.parentID) - } - result.reverse() - return result - } - - export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable { - switch (true) { - case e instanceof DOMException && e.name === "AbortError": - return new MessageV2.AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() - case MessageV2.OutputLengthError.isInstance(e): - return e - case LoadAPIKeyError.isInstance(e): - return new MessageV2.AuthError( - { - providerID: ctx.providerID, - message: e.message, - }, - { cause: e }, - ).toObject() - case (e as SystemError)?.code === "ECONNRESET": - return new MessageV2.APIError( - { - message: "Connection reset by server", - isRetryable: true, - metadata: { - code: (e as SystemError).code ?? "", - syscall: (e as SystemError).syscall ?? "", - message: (e as SystemError).message ?? "", - }, - }, - { cause: e }, - ).toObject() - case APICallError.isInstance(e): - const parsed = ProviderError.parseAPICallError({ - providerID: ctx.providerID, - error: e, - }) - if (parsed.type === "context_overflow") { - return new MessageV2.ContextOverflowError( - { - message: parsed.message, - responseBody: parsed.responseBody, - }, - { cause: e }, - ).toObject() - } - - return new MessageV2.APIError( - { - message: parsed.message, - statusCode: parsed.statusCode, - isRetryable: parsed.isRetryable, - responseHeaders: parsed.responseHeaders, - responseBody: parsed.responseBody, - metadata: parsed.metadata, - }, - { cause: e }, - ).toObject() - case e instanceof Error: - return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject() - default: - try { - const parsed = ProviderError.parseStreamError(e) - if (parsed) { - if (parsed.type === "context_overflow") { - return new MessageV2.ContextOverflowError( - { - message: parsed.message, - responseBody: parsed.responseBody, - }, - { cause: e }, - ).toObject() - } - return new MessageV2.APIError( - { - message: parsed.message, - isRetryable: parsed.isRetryable, - responseBody: parsed.responseBody, - }, - { - cause: e, - }, - ).toObject() - } - } catch {} - return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() - } - } -} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts deleted file mode 100644 index ccb09e71ac7f..000000000000 --- a/packages/opencode/src/session/processor.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { MessageV2 } from "./message-v2" -import { Log } from "@/util/log" -import { Session } from "." -import { Agent } from "@/agent/agent" -import { Snapshot } from "@/snapshot" -import { SessionSummary } from "./summary" -import { Bus } from "@/bus" -import { SessionRetry } from "./retry" -import { SessionStatus } from "./status" -import { Plugin } from "@/plugin" -import type { Provider } from "@/provider/provider" -import { LLM } from "./llm" -import { Config } from "@/config/config" -import { SessionCompaction } from "./compaction" -import { Permission } from "@/permission" -import { Question } from "@/question" -import { PartID } from "./schema" -import type { SessionID, MessageID } from "./schema" - -export namespace SessionProcessor { - const DOOM_LOOP_THRESHOLD = 3 - const log = Log.create({ service: "session.processor" }) - - export type Info = Awaited> - export type Result = Awaited> - - export function create(input: { - assistantMessage: MessageV2.Assistant - sessionID: SessionID - model: Provider.Model - abort: AbortSignal - }) { - const toolcalls: Record = {} - let snapshot: string | undefined - let blocked = false - let attempt = 0 - let needsCompaction = false - - const result = { - get message() { - return input.assistantMessage - }, - partFromToolCall(toolCallID: string) { - return toolcalls[toolCallID] - }, - async process(streamInput: LLM.StreamInput) { - log.info("process") - needsCompaction = false - const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true - while (true) { - try { - let currentText: MessageV2.TextPart | undefined - let reasoningMap: Record = {} - const stream = await LLM.stream(streamInput) - - for await (const value of stream.fullStream) { - input.abort.throwIfAborted() - switch (value.type) { - case "start": - await SessionStatus.set(input.sessionID, { type: "busy" }) - break - - case "reasoning-start": - if (value.id in reasoningMap) { - continue - } - const reasoningPart = { - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "reasoning" as const, - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - reasoningMap[value.id] = reasoningPart - await Session.updatePart(reasoningPart) - break - - case "reasoning-delta": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text += value.text - if (value.providerMetadata) part.metadata = value.providerMetadata - await Session.updatePartDelta({ - sessionID: part.sessionID, - messageID: part.messageID, - partID: part.id, - field: "text", - delta: value.text, - }) - } - break - - case "reasoning-end": - if (value.id in reasoningMap) { - const part = reasoningMap[value.id] - part.text = part.text.trimEnd() - - part.time = { - ...part.time, - end: Date.now(), - } - if (value.providerMetadata) part.metadata = value.providerMetadata - await Session.updatePart(part) - delete reasoningMap[value.id] - } - break - - case "tool-input-start": - const part = await Session.updatePart({ - id: toolcalls[value.id]?.id ?? PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "tool", - tool: value.toolName, - callID: value.id, - state: { - status: "pending", - input: {}, - raw: "", - }, - }) - toolcalls[value.id] = part as MessageV2.ToolPart - break - - case "tool-input-delta": - break - - case "tool-input-end": - break - - case "tool-call": { - const match = toolcalls[value.toolCallId] - if (match) { - const part = await Session.updatePart({ - ...match, - tool: value.toolName, - state: { - status: "running", - input: value.input, - time: { - start: Date.now(), - }, - }, - metadata: value.providerMetadata, - }) - toolcalls[value.toolCallId] = part as MessageV2.ToolPart - - const parts = await MessageV2.parts(input.assistantMessage.id) - const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) - - if ( - lastThree.length === DOOM_LOOP_THRESHOLD && - lastThree.every( - (p) => - p.type === "tool" && - p.tool === value.toolName && - p.state.status !== "pending" && - JSON.stringify(p.state.input) === JSON.stringify(value.input), - ) - ) { - const agent = await Agent.get(input.assistantMessage.agent) - await Permission.ask({ - permission: "doom_loop", - patterns: [value.toolName], - sessionID: input.assistantMessage.sessionID, - metadata: { - tool: value.toolName, - input: value.input, - }, - always: [value.toolName], - ruleset: agent.permission, - }) - } - } - break - } - case "tool-result": { - const match = toolcalls[value.toolCallId] - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - status: "completed", - input: value.input ?? match.state.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { - start: match.state.time.start, - end: Date.now(), - }, - attachments: value.output.attachments, - }, - }) - - delete toolcalls[value.toolCallId] - } - break - } - - 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 instanceof Error ? value.error.message : String(value.error), - time: { - start: match.state.time.start, - end: Date.now(), - }, - }, - }) - - if ( - value.error instanceof Permission.RejectedError || - value.error instanceof Question.RejectedError - ) { - blocked = shouldBreak - } - delete toolcalls[value.toolCallId] - } - break - } - case "error": - throw value.error - - case "start-step": - snapshot = await Snapshot.track() - await Session.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - snapshot, - type: "step-start", - }) - break - - case "finish-step": - const usage = Session.getUsage({ - model: input.model, - usage: value.usage, - metadata: value.providerMetadata, - }) - input.assistantMessage.finish = value.finishReason - input.assistantMessage.cost += usage.cost - input.assistantMessage.tokens = usage.tokens - await Session.updatePart({ - id: PartID.ascending(), - reason: value.finishReason, - snapshot: await Snapshot.track(), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "step-finish", - tokens: usage.tokens, - cost: usage.cost, - }) - await Session.updateMessage(input.assistantMessage) - if (snapshot) { - const patch = await Snapshot.patch(snapshot) - if (patch.files.length) { - await Session.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "patch", - hash: patch.hash, - files: patch.files, - }) - } - snapshot = undefined - } - SessionSummary.summarize({ - sessionID: input.sessionID, - messageID: input.assistantMessage.parentID, - }) - if ( - !input.assistantMessage.summary && - (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) - ) { - needsCompaction = true - } - break - - case "text-start": - currentText = { - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.assistantMessage.sessionID, - type: "text", - text: "", - time: { - start: Date.now(), - }, - metadata: value.providerMetadata, - } - await Session.updatePart(currentText) - break - - case "text-delta": - if (currentText) { - currentText.text += value.text - if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePartDelta({ - sessionID: currentText.sessionID, - messageID: currentText.messageID, - partID: currentText.id, - field: "text", - delta: value.text, - }) - } - break - - case "text-end": - if (currentText) { - currentText.text = currentText.text.trimEnd() - const textOutput = await Plugin.trigger( - "experimental.text.complete", - { - sessionID: input.sessionID, - messageID: input.assistantMessage.id, - partID: currentText.id, - }, - { text: currentText.text }, - ) - currentText.text = textOutput.text - currentText.time = { - start: Date.now(), - end: Date.now(), - } - if (value.providerMetadata) currentText.metadata = value.providerMetadata - await Session.updatePart(currentText) - } - currentText = undefined - break - - case "finish": - break - - default: - log.info("unhandled", { - ...value, - }) - continue - } - if (needsCompaction) break - } - } catch (e: any) { - log.error("process", { - error: e, - stack: JSON.stringify(e.stack), - }) - const error = MessageV2.fromError(e, { providerID: input.model.providerID }) - if (MessageV2.ContextOverflowError.isInstance(error)) { - needsCompaction = true - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error, - }) - } else { - const retry = SessionRetry.retryable(error) - if (retry !== undefined) { - attempt++ - const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) - await SessionStatus.set(input.sessionID, { - type: "retry", - attempt, - message: retry, - next: Date.now() + delay, - }) - await SessionRetry.sleep(delay, input.abort).catch(() => {}) - continue - } - input.assistantMessage.error = error - Bus.publish(Session.Event.Error, { - sessionID: input.assistantMessage.sessionID, - error: input.assistantMessage.error, - }) - await SessionStatus.set(input.sessionID, { type: "idle" }) - } - } - if (snapshot) { - const patch = await Snapshot.patch(snapshot) - if (patch.files.length) { - await Session.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "patch", - hash: patch.hash, - files: patch.files, - }) - } - snapshot = undefined - } - const p = await MessageV2.parts(input.assistantMessage.id) - for (const part of p) { - if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") { - await Session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - time: { - start: Date.now(), - end: Date.now(), - }, - }, - }) - } - } - input.assistantMessage.time.completed = Date.now() - await Session.updateMessage(input.assistantMessage) - if (needsCompaction) return "compact" - if (blocked) return "stop" - if (input.assistantMessage.error) return "stop" - return "continue" - } - }, - } - return result - } -} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts deleted file mode 100644 index dca8085c5b2e..000000000000 --- a/packages/opencode/src/session/prompt.ts +++ /dev/null @@ -1,2001 +0,0 @@ -import path from "path" -import os from "os" -import fs from "fs/promises" -import z from "zod" -import { Filesystem } from "../util/filesystem" -import { SessionID, MessageID, PartID } from "./schema" -import { MessageV2 } from "./message-v2" -import { Log } from "../util/log" -import { SessionRevert } from "./revert" -import { Session } from "." -import { Agent } from "../agent/agent" -import { Provider } from "../provider/provider" -import { ModelID, ProviderID } from "../provider/schema" -import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai" -import { SessionCompaction } from "./compaction" -import { Instance } from "../project/instance" -import { Bus } from "../bus" -import { ProviderTransform } from "../provider/transform" -import { SystemPrompt } from "./system" -import { InstructionPrompt } from "./instruction" -import { Plugin } from "../plugin" -import PROMPT_PLAN from "../session/prompt/plan.txt" -import BUILD_SWITCH from "../session/prompt/build-switch.txt" -import MAX_STEPS from "../session/prompt/max-steps.txt" -import { defer } from "../util/defer" -import { ToolRegistry } from "../tool/registry" -import { MCP } from "../mcp" -import { LSP } from "../lsp" -import { ReadTool } from "../tool/read" -import { FileTime } from "../file/time" -import { NotFoundError } from "@/storage/db" -import { Flag } from "../flag/flag" -import { ulid } from "ulid" -import { spawn } from "child_process" -import { Command } from "../command" -import { pathToFileURL, fileURLToPath } from "url" -import { ConfigMarkdown } from "../config/markdown" -import { SessionSummary } from "./summary" -import { NamedError } from "@opencode-ai/util/error" -import { fn } from "@/util/fn" -import { SessionProcessor } from "./processor" -import { TaskTool } from "@/tool/task" -import { Tool } from "@/tool/tool" -import { Permission } from "@/permission" -import { SessionStatus } from "./status" -import { LLM } from "./llm" -import { iife } from "@/util/iife" -import { Shell } from "@/shell/shell" -import { Truncate } from "@/tool/truncate" -import { decodeDataUrl } from "@/util/data-url" -import { Process } from "@/util/process" - -// @ts-ignore -globalThis.AI_SDK_LOG_WARNINGS = false - -const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. - -IMPORTANT: -- You MUST call this tool exactly once at the end of your response -- The input must be valid JSON matching the required schema -- Complete all necessary research and tool calls BEFORE calling this tool -- This tool provides your final answer - no further actions are taken after calling it` - -const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` - -export namespace SessionPrompt { - const log = Log.create({ service: "session.prompt" }) - - const state = Instance.state( - () => { - const data: Record< - string, - { - abort: AbortController - callbacks: { - resolve(input: MessageV2.WithParts): void - reject(reason?: any): void - }[] - } - > = {} - return data - }, - async (current) => { - for (const item of Object.values(current)) { - item.abort.abort() - } - }, - ) - - export function assertNotBusy(sessionID: SessionID) { - const match = state()[sessionID] - if (match) throw new Session.BusyError(sessionID) - } - - export const PromptInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod.optional(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - agent: z.string().optional(), - noReply: z.boolean().optional(), - tools: z - .record(z.string(), z.boolean()) - .optional() - .describe( - "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - ), - format: MessageV2.Format.optional(), - system: z.string().optional(), - variant: z.string().optional(), - parts: z.array( - z.discriminatedUnion("type", [ - MessageV2.TextPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "TextPartInput", - }), - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "FilePartInput", - }), - MessageV2.AgentPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "AgentPartInput", - }), - MessageV2.SubtaskPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "SubtaskPartInput", - }), - ]), - ), - }) - export type PromptInput = z.infer - - export const prompt = fn(PromptInput, async (input) => { - const session = await Session.get(input.sessionID) - await SessionRevert.cleanup(session) - - const message = await createUserMessage(input) - await Session.touch(input.sessionID) - - // this is backwards compatibility for allowing `tools` to be specified when - // prompting - const permissions: Permission.Ruleset = [] - for (const [tool, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ - permission: tool, - action: enabled ? "allow" : "deny", - pattern: "*", - }) - } - if (permissions.length > 0) { - session.permission = permissions - await Session.setPermission({ sessionID: session.id, permission: permissions }) - } - - if (input.noReply === true) { - return message - } - - return loop({ sessionID: input.sessionID }) - }) - - export async function resolvePromptParts(template: string): Promise { - const parts: PromptInput["parts"] = [ - { - type: "text", - text: template, - }, - ] - const files = ConfigMarkdown.files(template) - const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return - seen.add(name) - const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) - : path.resolve(Instance.worktree, name) - - const stats = await fs.stat(filepath).catch(() => undefined) - if (!stats) { - const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return - } - - if (stats.isDirectory()) { - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: "application/x-directory", - }) - return - } - - parts.push({ - type: "file", - url: pathToFileURL(filepath).href, - filename: name, - mime: "text/plain", - }) - }), - ) - return parts - } - - function start(sessionID: SessionID) { - const s = state() - if (s[sessionID]) return - const controller = new AbortController() - s[sessionID] = { - abort: controller, - callbacks: [], - } - return controller.signal - } - - function resume(sessionID: SessionID) { - const s = state() - if (!s[sessionID]) return - - return s[sessionID].abort.signal - } - - export async function cancel(sessionID: SessionID) { - log.info("cancel", { sessionID }) - const s = state() - const match = s[sessionID] - if (!match) { - await SessionStatus.set(sessionID, { type: "idle" }) - return - } - match.abort.abort() - delete s[sessionID] - await SessionStatus.set(sessionID, { type: "idle" }) - return - } - - export const LoopInput = z.object({ - sessionID: SessionID.zod, - resume_existing: z.boolean().optional(), - }) - export const loop = fn(LoopInput, async (input) => { - const { sessionID, resume_existing } = input - - const abort = resume_existing ? resume(sessionID) : start(sessionID) - if (!abort) { - return new Promise((resolve, reject) => { - const callbacks = state()[sessionID].callbacks - callbacks.push({ resolve, reject }) - }) - } - - await using _ = defer(() => cancel(sessionID)) - - // Structured output state - // Note: On session resumption, state is reset but outputFormat is preserved - // on the user message and will be retrieved from lastUser below - let structuredOutput: unknown | undefined - - let step = 0 - const session = await Session.get(sessionID) - while (true) { - await SessionStatus.set(sessionID, { type: "busy" }) - log.info("loop", { step, sessionID }) - if (abort.aborted) break - let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) - - let lastUser: MessageV2.User | undefined - let lastAssistant: MessageV2.Assistant | undefined - let lastFinished: MessageV2.Assistant | undefined - let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] - for (let i = msgs.length - 1; i >= 0; i--) { - const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) - lastFinished = msg.info as MessageV2.Assistant - if (lastUser && lastFinished) break - const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") - if (task && !lastFinished) { - tasks.push(...task) - } - } - - if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - if ( - lastAssistant?.finish && - !["tool-calls", "unknown"].includes(lastAssistant.finish) && - lastUser.id < lastAssistant.id - ) { - log.info("exiting loop", { sessionID }) - break - } - - step++ - if (step === 1) - ensureTitle({ - session, - modelID: lastUser.model.modelID, - providerID: lastUser.model.providerID, - history: msgs, - }) - - const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { - if (Provider.ModelNotFoundError.isInstance(e)) { - const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, - }).toObject(), - }) - } - throw e - }) - const task = tasks.pop() - - // pending subtask - // TODO: centralize "invoke tool" logic - if (task?.type === "subtask") { - const taskTool = await TaskTool.init() - const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model - const assistantMessage = (await Session.updateMessage({ - id: MessageID.ascending(), - role: "assistant", - parentID: lastUser.id, - sessionID, - mode: task.agent, - agent: task.agent, - variant: lastUser.variant, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: taskModel.id, - providerID: taskModel.providerID, - time: { - created: Date.now(), - }, - })) as MessageV2.Assistant - let part = (await Session.updatePart({ - id: PartID.ascending(), - messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, - type: "tool", - callID: ulid(), - tool: TaskTool.id, - state: { - status: "running", - input: { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - command: task.command, - }, - time: { - start: Date.now(), - }, - }, - })) as MessageV2.ToolPart - const taskArgs = { - prompt: task.prompt, - description: task.description, - subagent_type: task.agent, - command: task.command, - } - await Plugin.trigger( - "tool.execute.before", - { - tool: "task", - sessionID, - callID: part.id, - }, - { args: taskArgs }, - ) - let executionError: Error | undefined - const taskAgent = await Agent.get(task.agent) - const taskCtx: Tool.Context = { - agent: task.agent, - messageID: assistantMessage.id, - sessionID: sessionID, - abort, - callID: part.callID, - extra: { bypassAgentCheck: true }, - messages: msgs, - async metadata(input) { - part = (await Session.updatePart({ - ...part, - type: "tool", - state: { - ...part.state, - ...input, - }, - } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart - }, - async ask(req) { - await Permission.ask({ - ...req, - sessionID: sessionID, - ruleset: Permission.merge(taskAgent.permission, session.permission ?? []), - }) - }, - } - const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => { - executionError = error - log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) - return undefined - }) - const attachments = result?.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID, - messageID: assistantMessage.id, - })) - await Plugin.trigger( - "tool.execute.after", - { - tool: "task", - sessionID, - callID: part.id, - args: taskArgs, - }, - result, - ) - assistantMessage.finish = "tool-calls" - assistantMessage.time.completed = Date.now() - await Session.updateMessage(assistantMessage) - if (result && part.state.status === "running") { - await Session.updatePart({ - ...part, - state: { - status: "completed", - input: part.state.input, - title: result.title, - metadata: result.metadata, - output: result.output, - attachments, - time: { - ...part.state.time, - end: Date.now(), - }, - }, - } satisfies MessageV2.ToolPart) - } - if (!result) { - await Session.updatePart({ - ...part, - state: { - status: "error", - error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", - time: { - start: part.state.status === "running" ? part.state.time.start : Date.now(), - end: Date.now(), - }, - metadata: "metadata" in part.state ? part.state.metadata : undefined, - input: part.state.input, - }, - } satisfies MessageV2.ToolPart) - } - - if (task.command) { - // Add synthetic user message to prevent certain reasoning models from erroring - // If we create assistant messages w/ out user ones following mid loop thinking signatures - // will be missing and it can cause errors for models like gemini for example - const summaryUserMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { - created: Date.now(), - }, - agent: lastUser.agent, - model: lastUser.model, - } - await Session.updateMessage(summaryUserMsg) - await Session.updatePart({ - id: PartID.ascending(), - messageID: summaryUserMsg.id, - sessionID, - type: "text", - text: "Summarize the task tool output above and continue with your task.", - synthetic: true, - } satisfies MessageV2.TextPart) - } - - continue - } - - // pending compaction - if (task?.type === "compaction") { - const result = await SessionCompaction.process({ - messages: msgs, - parentID: lastUser.id, - abort, - sessionID, - auto: task.auto, - overflow: task.overflow, - }) - if (result === "stop") break - continue - } - - // context overflow, needs compaction - if ( - lastFinished && - lastFinished.summary !== true && - (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) - ) { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - }) - continue - } - - // normal processing - const agent = await Agent.get(lastUser.agent) - const maxSteps = agent.steps ?? Infinity - const isLastStep = step >= maxSteps - msgs = await insertReminders({ - messages: msgs, - agent, - session, - }) - - const processor = SessionProcessor.create({ - assistantMessage: (await Session.updateMessage({ - id: MessageID.ascending(), - parentID: lastUser.id, - role: "assistant", - mode: agent.name, - agent: agent.name, - variant: lastUser.variant, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: model.id, - providerID: model.providerID, - time: { - created: Date.now(), - }, - sessionID, - })) as MessageV2.Assistant, - sessionID: sessionID, - model, - abort, - }) - using _ = defer(() => InstructionPrompt.clear(processor.message.id)) - - // Check if user explicitly invoked an agent via @ in this turn - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") - const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false - - const tools = await resolveTools({ - agent, - session, - model, - tools: lastUser.tools, - processor, - bypassAgentCheck, - messages: msgs, - }) - - // Inject StructuredOutput tool if JSON schema mode enabled - if (lastUser.format?.type === "json_schema") { - tools["StructuredOutput"] = createStructuredOutputTool({ - schema: lastUser.format.schema, - onSuccess(output) { - structuredOutput = output - }, - }) - } - - if (step === 1) { - SessionSummary.summarize({ - sessionID: sessionID, - messageID: lastUser.id, - }) - } - - // Ephemerally wrap queued user messages with a reminder to stay on track - if (step > 1 && lastFinished) { - for (const msg of msgs) { - if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue - for (const part of msg.parts) { - if (part.type !== "text" || part.ignored || part.synthetic) continue - if (!part.text.trim()) continue - part.text = [ - "", - "The user sent the following message:", - part.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } - } - } - - await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - - // Build system prompt, adding structured output instruction if needed - const skills = await SystemPrompt.skills(agent) - const system = [ - ...(await SystemPrompt.environment(model)), - ...(skills ? [skills] : []), - ...(await InstructionPrompt.system()), - ] - const format = lastUser.format ?? { type: "text" } - if (format.type === "json_schema") { - system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) - } - - const result = await processor.process({ - user: lastUser, - agent, - permission: session.permission, - abort, - sessionID, - system, - messages: [ - ...MessageV2.toModelMessages(msgs, model), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ], - tools, - model, - toolChoice: format.type === "json_schema" ? "required" : undefined, - }) - - // If structured output was captured, save it and exit immediately - // This takes priority because the StructuredOutput tool was called successfully - if (structuredOutput !== undefined) { - processor.message.structured = structuredOutput - processor.message.finish = processor.message.finish ?? "stop" - await Session.updateMessage(processor.message) - break - } - - // Check if model finished (finish reason is not "tool-calls" or "unknown") - const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) - - if (modelFinished && !processor.message.error) { - if (format.type === "json_schema") { - // Model stopped without calling StructuredOutput tool - processor.message.error = new MessageV2.StructuredOutputError({ - message: "Model did not produce structured output", - retries: 0, - }).toObject() - await Session.updateMessage(processor.message) - break - } - } - - if (result === "stop") break - if (result === "compact") { - await SessionCompaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - overflow: !processor.message.finish, - }) - } - continue - } - SessionCompaction.prune({ sessionID }) - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user") continue - const queued = state()[sessionID]?.callbacks ?? [] - for (const q of queued) { - q.resolve(item) - } - return item - } - throw new Error("Impossible") - }) - - async function lastModel(sessionID: SessionID) { - for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model - } - return Provider.defaultModel() - } - - /** @internal Exported for testing */ - export async function resolveTools(input: { - agent: Agent.Info - model: Provider.Model - session: Session.Info - tools?: Record - processor: SessionProcessor.Info - bypassAgentCheck: boolean - messages: MessageV2.WithParts[] - }) { - using _ = log.time("resolveTools") - const tools: Record = {} - - const context = (args: any, options: ToolCallOptions): Tool.Context => ({ - sessionID: input.session.id, - abort: options.abortSignal!, - messageID: input.processor.message.id, - callID: options.toolCallId, - extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, - agent: input.agent.name, - messages: input.messages, - metadata: async (val: { title?: string; metadata?: any }) => { - const match = input.processor.partFromToolCall(options.toolCallId) - if (match && match.state.status === "running") { - await Session.updatePart({ - ...match, - state: { - title: val.title, - metadata: val.metadata, - status: "running", - input: args, - time: { - start: Date.now(), - }, - }, - }) - } - }, - async ask(req) { - await Permission.ask({ - ...req, - sessionID: input.session.id, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []), - }) - }, - }) - - for (const item of await ToolRegistry.tools( - { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, - input.agent, - )) { - const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) - tools[item.id] = tool({ - id: item.id as any, - description: item.description, - inputSchema: jsonSchema(schema as any), - async execute(args, options) { - const ctx = context(args, options) - await Plugin.trigger( - "tool.execute.before", - { - tool: item.id, - sessionID: ctx.sessionID, - callID: ctx.callID, - }, - { - args, - }, - ) - const result = await item.execute(args, ctx) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - } - await Plugin.trigger( - "tool.execute.after", - { - tool: item.id, - sessionID: ctx.sessionID, - callID: ctx.callID, - args, - }, - output, - ) - return output - }, - }) - } - - for (const [key, item] of Object.entries(await MCP.tools())) { - const execute = item.execute - if (!execute) continue - - const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema) - item.inputSchema = jsonSchema(transformed) - // Wrap execute to add plugin hooks and format output - item.execute = async (args, opts) => { - const ctx = context(args, opts) - - await Plugin.trigger( - "tool.execute.before", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - }, - { - args, - }, - ) - - await ctx.ask({ - permission: key, - metadata: {}, - patterns: ["*"], - always: ["*"], - }) - - const result = await execute(args, opts) - - await Plugin.trigger( - "tool.execute.after", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - args, - }, - result, - ) - - const textParts: string[] = [] - const attachments: Omit[] = [] - - for (const contentItem of result.content) { - if (contentItem.type === "text") { - textParts.push(contentItem.text) - } else if (contentItem.type === "image") { - attachments.push({ - type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, - }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) { - textParts.push(resource.text) - } - if (resource.blob) { - attachments.push({ - type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, - filename: resource.uri, - }) - } - } - } - - const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent) - const metadata = { - ...(result.metadata ?? {}), - truncated: truncated.truncated, - ...(truncated.truncated && { outputPath: truncated.outputPath }), - } - - return { - title: "", - metadata, - output: truncated.content, - attachments: attachments.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - content: result.content, // directly return content to preserve ordering when outputting to model - } - } - tools[key] = item - } - - return tools - } - - /** @internal Exported for testing */ - export function createStructuredOutputTool(input: { - schema: Record - onSuccess: (output: unknown) => void - }): AITool { - // Remove $schema property if present (not needed for tool input) - const { $schema, ...toolSchema } = input.schema - - return tool({ - id: "StructuredOutput" as any, - description: STRUCTURED_OUTPUT_DESCRIPTION, - inputSchema: jsonSchema(toolSchema as any), - async execute(args) { - // AI SDK validates args against inputSchema before calling execute() - input.onSuccess(args) - return { - output: "Structured output captured successfully.", - title: "Structured Output", - metadata: { valid: true }, - } - }, - toModelOutput(result) { - return { - type: "text", - value: result.output, - } - }, - }) - } - - async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) - - const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) - const full = - !input.variant && agent.variant - ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) - : undefined - const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined) - - const info: MessageV2.Info = { - id: input.messageID ?? MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - tools: input.tools, - agent: agent.name, - model, - system: input.system, - format: input.format, - variant, - } - using _ = defer(() => InstructionPrompt.clear(info.id)) - - type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never - const assign = (part: Draft): MessageV2.Part => ({ - ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), - }) - - const parts = await Promise.all( - input.parts.map(async (part): Promise[]> => { - if (part.type === "file") { - // before checking the protocol we check if this is an mcp resource because it needs special handling - if (part.source?.type === "resource") { - const { clientName, uri } = part.source - log.info("mcp resource", { clientName, uri, mime: part.mime }) - - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Reading MCP resource: ${part.filename} (${uri})`, - }, - ] - - try { - const resourceContent = await MCP.readResource(clientName, uri) - if (!resourceContent) { - throw new Error(`Resource not found: ${clientName}/${uri}`) - } - - // Handle different content types - const contents = Array.isArray(resourceContent.contents) - ? resourceContent.contents - : [resourceContent.contents] - - for (const content of contents) { - if ("text" in content && content.text) { - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: content.text as string, - }) - } else if ("blob" in content && content.blob) { - // Handle binary content if needed - const mimeType = "mimeType" in content ? content.mimeType : part.mime - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `[Binary content: ${mimeType}]`, - }) - } - } - - pieces.push({ - ...part, - messageID: info.id, - sessionID: input.sessionID, - }) - } catch (error: unknown) { - log.error("failed to read MCP resource", { error, clientName, uri }) - const message = error instanceof Error ? error.message : String(error) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Failed to read MCP resource ${part.filename}: ${message}`, - }) - } - - return pieces - } - const url = new URL(part.url) - switch (url.protocol) { - case "data:": - if (part.mime === "text/plain") { - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: decodeDataUrl(part.url), - }, - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - } - break - case "file:": - log.info("file", { mime: part.mime }) - // have to normalize, symbol search returns absolute paths - // Decode the pathname since URL constructor doesn't automatically decode it - const filepath = fileURLToPath(part.url) - const s = Filesystem.stat(filepath) - - if (s?.isDirectory()) { - part.mime = "application/x-directory" - } - - if (part.mime === "text/plain") { - let offset: number | undefined = undefined - let limit: number | undefined = undefined - const range = { - start: url.searchParams.get("start"), - end: url.searchParams.get("end"), - } - if (range.start != null) { - const filePathURI = part.url.split("?")[0] - let start = parseInt(range.start) - let end = range.end ? parseInt(range.end) : undefined - // some LSP servers (eg, gopls) don't give full range in - // workspace/symbol searches, so we'll try to find the - // symbol in the document to get the full range - if (start === end) { - const symbols = await LSP.documentSymbol(filePathURI).catch(() => []) - for (const symbol of symbols) { - let range: LSP.Range | undefined - if ("range" in symbol) { - range = symbol.range - } else if ("location" in symbol) { - range = symbol.location.range - } - if (range?.start?.line && range?.start?.line === start) { - start = range.start.line - end = range?.end?.line ?? start - break - } - } - } - offset = Math.max(start, 1) - if (end) { - limit = end - (offset - 1) - } - } - const args = { filePath: filepath, offset, limit } - - const pieces: Draft[] = [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - ] - - await ReadTool.init() - .then(async (t) => { - const model = await Provider.getModel(info.model.providerID, info.model.modelID) - const readCtx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true, model }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await t.execute(args, readCtx) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }) - if (result.attachments?.length) { - pieces.push( - ...result.attachments.map((attachment) => ({ - ...attachment, - synthetic: true, - filename: attachment.filename ?? part.filename, - messageID: info.id, - sessionID: input.sessionID, - })), - ) - } else { - pieces.push({ - ...part, - messageID: info.id, - sessionID: input.sessionID, - }) - } - }) - .catch((error) => { - log.error("failed to read file", { error }) - const message = error instanceof Error ? error.message : error.toString() - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ - message, - }).toObject(), - }) - pieces.push({ - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Read tool failed to read ${filepath} with the following error: ${message}`, - }) - }) - - return pieces - } - - if (part.mime === "application/x-directory") { - const args = { filePath: filepath } - const listCtx: Tool.Context = { - sessionID: input.sessionID, - abort: new AbortController().signal, - agent: input.agent!, - messageID: info.id, - extra: { bypassCwdCheck: true }, - messages: [], - metadata: async () => {}, - ask: async () => {}, - } - const result = await ReadTool.init().then((t) => t.execute(args, listCtx)) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: result.output, - }, - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - } - - await FileTime.read(input.sessionID, filepath) - return [ - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`, - synthetic: true, - }, - { - id: part.id, - messageID: info.id, - sessionID: input.sessionID, - type: "file", - url: `data:${part.mime};base64,` + (await Filesystem.readBytes(filepath)).toString("base64"), - mime: part.mime, - filename: part.filename!, - source: part.source, - }, - ] - } - } - - if (part.type === "agent") { - // Check if this agent would be denied by task permission - const perm = Permission.evaluate("task", part.name, agent.permission) - const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" - return [ - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - { - messageID: info.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - // An extra space is added here. Otherwise the 'Use' gets appended - // to user's last word; making a combined word - text: - " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + - hint, - }, - ] - } - - return [ - { - ...part, - messageID: info.id, - sessionID: input.sessionID, - }, - ] - }), - ).then((x) => x.flat().map(assign)) - - await Plugin.trigger( - "chat.message", - { - sessionID: input.sessionID, - agent: input.agent, - model: input.model, - messageID: input.messageID, - variant: input.variant, - }, - { - message: info, - parts, - }, - ) - - const parsedInfo = MessageV2.Info.safeParse(info) - if (!parsedInfo.success) { - log.error("invalid user message before save", { - sessionID: input.sessionID, - messageID: info.id, - agent: info.agent, - model: info.model, - issues: parsedInfo.error.issues, - }) - } - - parts.forEach((part, index) => { - const parsedPart = MessageV2.Part.safeParse(part) - if (parsedPart.success) return - log.error("invalid user part before save", { - sessionID: input.sessionID, - messageID: info.id, - partID: part.id, - partType: part.type, - index, - issues: parsedPart.error.issues, - part, - }) - }) - - await Session.updateMessage(info) - for (const part of parts) { - await Session.updatePart(part) - } - - return { - info, - parts, - } - } - - async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) { - const userMessage = input.messages.findLast((msg) => msg.info.role === "user") - if (!userMessage) return input.messages - - // Original logic when experimental plan mode is disabled - if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) { - if (input.agent.name === "plan") { - userMessage.parts.push({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: PROMPT_PLAN, - synthetic: true, - }) - } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") - if (wasPlan && input.agent.name === "build") { - userMessage.parts.push({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: BUILD_SWITCH, - synthetic: true, - }) - } - return input.messages - } - - // New plan mode logic when flag is enabled - const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") - - // Switching from plan mode to build mode - if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { - const plan = Session.plan(input.session) - const exists = await Filesystem.exists(plan) - if (exists) { - const part = await Session.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: - BUILD_SWITCH + "\n\n" + `A plan file exists at ${plan}. You should execute on the plan defined within it`, - synthetic: true, - }) - userMessage.parts.push(part) - } - return input.messages - } - - // Entering plan mode - if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") { - const plan = Session.plan(input.session) - const exists = await Filesystem.exists(plan) - if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true }) - const part = await Session.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: ` -Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. - -## Plan File Info: -${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`} -You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. - -## Plan Workflow - -### Phase 1: Initial Understanding -Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type. - -1. Focus on understanding the user's request and the code associated with their request - -2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase. - - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change. - - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning. - - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1) - - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns - -3. After exploring the code, use the question tool to clarify ambiguities in the user request up front. - -### Phase 2: Design -Goal: Design an implementation approach. - -Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1. - -You can launch up to 1 agent(s) in parallel. - -**Guidelines:** -- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives -- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames) - -Examples of when to use multiple agents: -- The task touches multiple parts of the codebase -- It's a large refactor or architectural change -- There are many edge cases to consider -- You'd benefit from exploring different approaches - -Example perspectives by task type: -- New feature: simplicity vs performance vs maintainability -- Bug fix: root cause vs workaround vs prevention -- Refactoring: minimal change vs clean architecture - -In the agent prompt: -- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces -- Describe requirements and constraints -- Request a detailed implementation plan - -### Phase 3: Review -Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions. -1. Read the critical files identified by agents to deepen your understanding -2. Ensure that the plans align with the user's original request -3. Use question tool to clarify any remaining questions with the user - -### Phase 4: Final Plan -Goal: Write your final plan to the plan file (the only file you can edit). -- Include only your recommended approach, not all alternatives -- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively -- Include the paths of critical files to be modified -- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests) - -### Phase 5: Call plan_exit tool -At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning. -This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons. - -**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does. - -NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins. -`, - synthetic: true, - }) - userMessage.parts.push(part) - return input.messages - } - return input.messages - } - - export const ShellInput = z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string(), - }) - export type ShellInput = z.infer - export async function shell(input: ShellInput) { - const abort = start(input.sessionID) - if (!abort) { - throw new Session.BusyError(input.sessionID) - } - - using _ = defer(() => { - // If no queued callbacks, cancel (the default) - const callbacks = state()[input.sessionID]?.callbacks ?? [] - if (callbacks.length === 0) { - cancel(input.sessionID) - } else { - // Otherwise, trigger the session loop to process queued items - loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => { - log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error }) - }) - } - }) - - const session = await Session.get(input.sessionID) - if (session.revert) { - await SessionRevert.cleanup(session) - } - const agent = await Agent.get(input.agent) - const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) - const userMsg: MessageV2.User = { - id: MessageID.ascending(), - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - role: "user", - agent: input.agent, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - } - await Session.updateMessage(userMsg) - const userPart: MessageV2.Part = { - type: "text", - id: PartID.ascending(), - messageID: userMsg.id, - sessionID: input.sessionID, - text: "The following tool was executed by the user", - synthetic: true, - } - await Session.updatePart(userPart) - - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - sessionID: input.sessionID, - parentID: userMsg.id, - mode: input.agent, - agent: input.agent, - cost: 0, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - time: { - created: Date.now(), - }, - role: "assistant", - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: model.modelID, - providerID: model.providerID, - } - await Session.updateMessage(msg) - const part: MessageV2.Part = { - type: "tool", - id: PartID.ascending(), - messageID: msg.id, - sessionID: input.sessionID, - tool: "bash", - callID: ulid(), - state: { - status: "running", - time: { - start: Date.now(), - }, - input: { - command: input.command, - }, - }, - } - await Session.updatePart(part) - const shell = Shell.preferred() - const shellName = ( - process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell) - ).toLowerCase() - - const invocations: Record = { - nu: { - args: ["-c", input.command], - }, - fish: { - args: ["-c", input.command], - }, - zsh: { - args: [ - "-c", - "-l", - ` - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - bash: { - args: [ - "-c", - "-l", - ` - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - eval ${JSON.stringify(input.command)} - `, - ], - }, - // Windows cmd - cmd: { - args: ["/c", input.command], - }, - // Windows PowerShell - powershell: { - args: ["-NoProfile", "-Command", input.command], - }, - pwsh: { - args: ["-NoProfile", "-Command", input.command], - }, - // Fallback: any shell that doesn't match those above - // - No -l, for max compatibility - "": { - args: ["-c", `${input.command}`], - }, - } - - const matchingInvocation = invocations[shellName] ?? invocations[""] - const args = matchingInvocation?.args - - const cwd = Instance.directory - const shellEnv = await Plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ) - const proc = spawn(shell, args, { - cwd, - detached: process.platform !== "win32", - windowsHide: process.platform === "win32", - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - ...shellEnv.env, - TERM: "dumb", - }, - }) - - let output = "" - - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) - - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", - } - Session.updatePart(part) - } - }) - - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited }) - - if (abort.aborted) { - aborted = true - await kill() - } - - const abortHandler = () => { - aborted = true - void kill() - } - - abort.addEventListener("abort", abortHandler, { once: true }) - - await new Promise((resolve) => { - proc.on("close", () => { - exited = true - abort.removeEventListener("abort", abortHandler) - resolve() - }) - }) - - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") - } - msg.time.completed = Date.now() - await Session.updateMessage(msg) - if (part.state.status === "running") { - part.state = { - status: "completed", - time: { - ...part.state.time, - end: Date.now(), - }, - input: part.state.input, - title: "", - metadata: { - output, - description: "", - }, - output, - } - await Session.updatePart(part) - } - return { info: msg, parts: [part] } - } - - export const CommandInput = z.object({ - messageID: MessageID.zod.optional(), - sessionID: SessionID.zod, - agent: z.string().optional(), - model: z.string().optional(), - arguments: z.string(), - command: z.string(), - variant: z.string().optional(), - parts: z - .array( - z.discriminatedUnion("type", [ - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }).partial({ - id: true, - }), - ]), - ) - .optional(), - }) - export type CommandInput = z.infer - const bashRegex = /!`([^`]+)`/g - // Match [Image N] as single token, quoted strings, or non-space sequences - const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi - const placeholderRegex = /\$(\d+)/g - const quoteTrimRegex = /^["']|["']$/g - /** - * Regular expression to match @ file references in text - * Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks - * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references) - */ - - export async function command(input: CommandInput) { - log.info("command", input) - const command = await Command.get(input.command) - if (!command) { - throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) - } - const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) - - const raw = input.arguments.match(argsRegex) ?? [] - const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - - const templateCommand = await command.template - - const placeholders = templateCommand.match(placeholderRegex) ?? [] - let last = 0 - for (const item of placeholders) { - const value = Number(item.slice(1)) - if (value > last) last = value - } - - // Let the final placeholder swallow any extra arguments so prompts read naturally - const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) - const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS") - let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - - // If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders) - // but user provided arguments, append them to the template - if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) { - template = template + "\n\n" + input.arguments - } - - const shellMatches = ConfigMarkdown.shell(template) - if (shellMatches.length > 0) { - const sh = Shell.preferred() - const results = await Promise.all( - shellMatches.map(async ([, cmd]) => { - const out = await Process.text([cmd], { shell: sh, nothrow: true }) - return out.text - }), - ) - let index = 0 - template = template.replace(bashRegex, () => results[index++]) - } - template = template.trim() - - const taskModel = await (async () => { - if (command.model) { - return Provider.parseModel(command.model) - } - if (command.agent) { - const cmdAgent = await Agent.get(command.agent) - if (cmdAgent?.model) { - return cmdAgent.model - } - } - if (input.model) return Provider.parseModel(input.model) - return await lastModel(input.sessionID) - })() - - try { - await Provider.getModel(taskModel.providerID, taskModel.modelID) - } catch (e) { - if (Provider.ModelNotFoundError.isInstance(e)) { - const { providerID, modelID, suggestions } = e.data - const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : "" - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(), - }) - } - throw e - } - const agent = await Agent.get(agentName) - if (!agent) { - const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)) - const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" - const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - Bus.publish(Session.Event.Error, { - sessionID: input.sessionID, - error: error.toObject(), - }) - throw error - } - - const templateParts = await resolvePromptParts(template) - const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true - const parts = isSubtask - ? [ - { - type: "subtask" as const, - agent: agent.name, - description: command.description ?? "", - command: input.command, - model: { - providerID: taskModel.providerID, - modelID: taskModel.modelID, - }, - // TODO: how can we make task tool accept a more complex input? - prompt: templateParts.find((y) => y.type === "text")?.text ?? "", - }, - ] - : [...templateParts, ...(input.parts ?? [])] - - const userAgent = isSubtask ? (input.agent ?? (await Agent.defaultAgent())) : agentName - const userModel = isSubtask - ? input.model - ? Provider.parseModel(input.model) - : await lastModel(input.sessionID) - : taskModel - - await Plugin.trigger( - "command.execute.before", - { - command: input.command, - sessionID: input.sessionID, - arguments: input.arguments, - }, - { parts }, - ) - - const result = (await prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - model: userModel, - agent: userAgent, - parts, - variant: input.variant, - })) as MessageV2.WithParts - - Bus.publish(Command.Event.Executed, { - name: input.command, - sessionID: input.sessionID, - arguments: input.arguments, - messageID: result.info.id, - }) - - return result - } - - async function ensureTitle(input: { - session: Session.Info - history: MessageV2.WithParts[] - providerID: ProviderID - modelID: ModelID - }) { - if (input.session.parentID) return - if (!Session.isDefaultTitle(input.session.title)) return - - // Find first non-synthetic user message - const firstRealUserIdx = input.history.findIndex( - (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), - ) - if (firstRealUserIdx === -1) return - - const isFirst = - input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) - .length === 1 - if (!isFirst) return - - // Gather all messages up to and including the first real user message for context - // This includes any shell/subtask executions that preceded the user's first prompt - const contextMessages = input.history.slice(0, firstRealUserIdx + 1) - const firstRealUser = contextMessages[firstRealUserIdx] - - // For subtask-only messages (from command invocations), extract the prompt directly - // since toModelMessage converts subtask parts to generic "The following tool was executed by the user" - const subtaskParts = firstRealUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[] - const hasOnlySubtaskParts = subtaskParts.length > 0 && firstRealUser.parts.every((p) => p.type === "subtask") - - const agent = await Agent.get("title") - if (!agent) return - const model = await iife(async () => { - if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID) - return ( - (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) - ) - }) - const result = await LLM.stream({ - agent, - user: firstRealUser.info as MessageV2.User, - system: [], - small: true, - tools: {}, - model, - abort: new AbortController().signal, - sessionID: input.session.id, - retries: 2, - messages: [ - { - role: "user", - content: "Generate a title for this conversation:\n", - }, - ...(hasOnlySubtaskParts - ? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }] - : MessageV2.toModelMessages(contextMessages, model)), - ], - }) - const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) - if (text) { - const cleaned = text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return - - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => { - if (NotFoundError.isInstance(err)) return - throw err - }) - } - } -} diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6d057f539f81..303a819b45e1 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -93,6 +93,11 @@ export namespace SessionRetry { if (json.type === "error" && json.error?.code?.includes("rate_limit")) { return "Rate Limited" } + // Respect explicit isRetryable: false from provider SDKs (e.g. Bedrock) + if (json.isRetryable === false) return undefined + // 4xx errors are client errors — not retryable + const status = typeof json.status === "number" ? json.status : typeof json.statusCode === "number" ? json.statusCode : undefined + if (status !== undefined && status >= 400 && status < 500) return undefined return JSON.stringify(json) } catch { return undefined diff --git a/packages/opencode/src/session/steer.ts b/packages/opencode/src/session/steer.ts new file mode 100644 index 000000000000..5de01facc86b --- /dev/null +++ b/packages/opencode/src/session/steer.ts @@ -0,0 +1,127 @@ +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import z from "zod" + +export namespace SessionSteer { + const log = Log.create({ service: "session.steer" }) + + export type Mode = "queue" | "steer" + + const QueuedMessageSchema = z.object({ + id: z.string(), + text: z.string(), + time: z.number(), + mode: z.enum(["queue", "steer"]), + }) + + export const Event = { + QueueChanged: BusEvent.define( + "session.queue.changed", + z.object({ + sessionID: z.string(), + queue: z.array(QueuedMessageSchema), + }), + ), + } + + export interface QueuedMessage { + id: string + text: string + time: number + mode: Mode + } + + interface SteerState { + pending: QueuedMessage[] + } + + const state = Instance.state( + () => { + const data: Record = {} + return data + }, + async () => {}, + ) + + function ensure(sessionID: string): SteerState { + const s = state() + if (!s[sessionID]) s[sessionID] = { pending: [] } + return s[sessionID] + } + + /** Push a message into the pending buffer for an active session. */ + export function push(sessionID: string, text: string, mode: Mode = "queue"): QueuedMessage { + const entry: QueuedMessage = { + id: crypto.randomUUID(), + text, + time: Date.now(), + mode, + } + const s = ensure(sessionID) + s.pending.push(entry) + log.info("steer.push", { sessionID, id: entry.id, queueLength: s.pending.length }) + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + return entry + } + + /** Drain all pending messages and return them. Clears the buffer. */ + export function take(sessionID: string): QueuedMessage[] { + const s = state()[sessionID] + if (!s || s.pending.length === 0) return [] + const result = s.pending.splice(0) + log.info("steer.take", { sessionID, count: result.length }) + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + return result + } + + /** Drain only messages matching the given mode. Leaves other messages in the buffer. */ + export function takeByMode(sessionID: string, mode: Mode): QueuedMessage[] { + const s = state()[sessionID] + if (!s || s.pending.length === 0) return [] + const matched: QueuedMessage[] = [] + const remaining: QueuedMessage[] = [] + for (const m of s.pending) { + if (m.mode === mode) matched.push(m) + else remaining.push(m) + } + if (matched.length === 0) return [] + s.pending = remaining + log.info("steer.takeByMode", { sessionID, mode, count: matched.length }) + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + return matched + } + + /** Check if there's pending steered input for a session. */ + export function has(sessionID: string): boolean { + const s = state()[sessionID] + return !!s && s.pending.length > 0 + } + + /** Get the current queue without draining. */ + export function list(sessionID: string): QueuedMessage[] { + return state()[sessionID]?.pending ?? [] + } + + /** Remove a specific queued message by id. */ + export function remove(sessionID: string, id: string): boolean { + const s = state()[sessionID] + if (!s) return false + const idx = s.pending.findIndex((m) => m.id === id) + if (idx === -1) return false + s.pending.splice(idx, 1) + log.info("steer.remove", { sessionID, id }) + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + return true + } + + /** Clear all pending messages for a session. */ + export function clear(sessionID: string) { + const s = state()[sessionID] + if (!s || s.pending.length === 0) return + s.pending.length = 0 + log.info("steer.clear", { sessionID }) + Bus.publish(Event.QueueChanged, { sessionID, queue: s.pending }) + } +} diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 00c22bfe6be0..9598d78fb2fc 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -162,7 +162,14 @@ export const BatchTool = Tool.define("batch", async () => { const outputMessage = failedCalls > 0 - ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` + ? [ + `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!` return { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e3781126d0c1..6405397cea6c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -144,6 +144,24 @@ export const TaskTool = Tool.define("task", async (ctx) => { parts: promptParts, }) + if ((result.info as MessageV2.Assistant).error) { + const errorObj = (result.info as MessageV2.Assistant).error! + const msg = ("data" in errorObj && "message" in errorObj.data) + ? errorObj.data.message + : errorObj.name + return { + title: params.description, + metadata: { sessionId: session.id, model }, + output: [ + `task_id: ${session.id} (for resuming to continue this task if needed)`, + "", + "", + `ERROR: ${msg}`, + "", + ].join("\n"), + } + } + const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" const output = [ diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts new file mode 100644 index 000000000000..e0b4afb45d6e --- /dev/null +++ b/packages/opencode/test/provider/error.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test" +import { APICallError } from "ai" +import { ProviderError } from "../../src/provider/error" + +function makeAPICallError(opts: { + message: string + statusCode?: number + responseBody?: string + isRetryable?: boolean + responseHeaders?: Record +}) { + return new APICallError({ + message: opts.message, + url: "https://bedrock.us-east-1.amazonaws.com", + requestBodyValues: {}, + statusCode: opts.statusCode ?? 400, + responseHeaders: opts.responseHeaders ?? {}, + responseBody: opts.responseBody, + isRetryable: opts.isRetryable ?? false, + }) +} + +describe("provider.error.parseAPICallError", () => { + // Issue #2: Bedrock "undefined" message handling + test("detects overflow when message is literal 'undefined' and responseBody has prompt-too-long", () => { + const error = makeAPICallError({ + message: "undefined", + statusCode: 400, + responseBody: JSON.stringify({ + message: "prompt is too long: 208845 tokens > 200000 maximum", + }), + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + test("detects overflow when message is empty string and responseBody has prompt-too-long", () => { + const error = makeAPICallError({ + message: "", + statusCode: 400, + responseBody: JSON.stringify({ + message: "prompt is too long: 208845 tokens > 200000 maximum", + }), + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + test("does NOT treat literal 'undefined' as overflow when responseBody has no overflow pattern", () => { + const error = makeAPICallError({ + message: "undefined", + statusCode: 403, + responseBody: JSON.stringify({ message: "Access denied" }), + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("api_error") + }) + + test("falls back to statusCode text when message is 'undefined' and no responseBody", () => { + const error = makeAPICallError({ + message: "undefined", + statusCode: 429, + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.message).toBe("Too Many Requests") + } + }) + + test("returns 'Unknown error' when message is 'undefined' and no responseBody or statusCode", () => { + const error = new APICallError({ + message: "undefined", + url: "https://test", + requestBodyValues: {}, + responseHeaders: {}, + isRetryable: false, + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.message).toBe("Unknown error") + } + }) + + // Overflow patterns across providers + test("detects overflow for direct Anthropic provider with normal message", () => { + const error = makeAPICallError({ + message: "prompt is too long: 208845 tokens > 200000 maximum", + statusCode: 400, + }) + const result = ProviderError.parseAPICallError({ + providerID: "anthropic", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + test("detects overflow for Bedrock 'input is too long' pattern", () => { + const error = makeAPICallError({ + message: "undefined", + statusCode: 400, + responseBody: "input is too long for requested model", + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + test("detects overflow for OpenAI 'exceeds the context window' pattern", () => { + const error = makeAPICallError({ + message: "This model's maximum context length exceeds the context window", + statusCode: 400, + }) + const result = ProviderError.parseAPICallError({ + providerID: "openai", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + test("detects overflow for generic 'context_length_exceeded' pattern", () => { + const error = makeAPICallError({ + message: "context_length_exceeded", + statusCode: 400, + }) + const result = ProviderError.parseAPICallError({ + providerID: "openrouter", + error, + }) + expect(result.type).toBe("context_overflow") + }) + + // Non-overflow API errors + test("returns api_error for rate limit errors", () => { + const error = makeAPICallError({ + message: "Rate limit exceeded", + statusCode: 429, + isRetryable: true, + }) + const result = ProviderError.parseAPICallError({ + providerID: "amazon-bedrock", + error, + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.statusCode).toBe(429) + } + }) + + test("returns api_error with isRetryable false for auth errors", () => { + const error = makeAPICallError({ + message: "Invalid API key", + statusCode: 401, + isRetryable: false, + }) + const result = ProviderError.parseAPICallError({ + providerID: "anthropic", + error, + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.isRetryable).toBe(false) + } + }) + + test("includes url metadata when error has url", () => { + const error = makeAPICallError({ + message: "Server error", + statusCode: 500, + isRetryable: true, + }) + const result = ProviderError.parseAPICallError({ + providerID: "anthropic", + error, + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.metadata).toBeDefined() + } + }) +}) + +describe("provider.error.parseStreamError", () => { + test("detects context_length_exceeded from stream error", () => { + const result = ProviderError.parseStreamError({ + type: "error", + error: { code: "context_length_exceeded" }, + }) + expect(result).toBeDefined() + expect(result!.type).toBe("context_overflow") + }) + + test("detects insufficient_quota from stream error", () => { + const result = ProviderError.parseStreamError({ + type: "error", + error: { code: "insufficient_quota" }, + }) + expect(result).toBeDefined() + expect(result!.type).toBe("api_error") + if (result!.type === "api_error") { + expect(result!.isRetryable).toBe(false) + } + }) + + test("returns undefined for non-error stream events", () => { + const result = ProviderError.parseStreamError({ type: "data", content: "hello" }) + expect(result).toBeUndefined() + }) + + test("returns undefined for non-object input", () => { + expect(ProviderError.parseStreamError("string")).toBeUndefined() + expect(ProviderError.parseStreamError(42)).toBeUndefined() + expect(ProviderError.parseStreamError(null)).toBeUndefined() + }) + + test("parses JSON string input", () => { + const result = ProviderError.parseStreamError( + JSON.stringify({ type: "error", error: { code: "context_length_exceeded" } }), + ) + expect(result).toBeDefined() + expect(result!.type).toBe("context_overflow") + }) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 452926d12e1b..90fa5c49705d 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -113,19 +113,19 @@ describe("session.compaction.isOverflow", () => { }) }) - // ─── Bug reproduction tests ─────────────────────────────────────────── - // These tests demonstrate that when limit.input is set, isOverflow() - // does not subtract any headroom for the next model response. This means - // compaction only triggers AFTER we've already consumed the full input - // budget, leaving zero room for the next API call's output tokens. + // ─── Headroom reservation tests ────────────────────────────────────── + // These tests verify that when limit.input is set, isOverflow() + // correctly reserves headroom (maxOutputTokens, capped at 32K) so + // compaction triggers before the next API call overflows. // - // Compare: without limit.input, usable = context - output (reserves space). - // With limit.input, usable = limit.input (reserves nothing). + // Previously (bug), the limit.input path only subtracted a 20K buffer + // while the non-input path subtracted the full maxOutputTokens — an + // asymmetry that let sessions grow ~12K tokens too large before compacting. // // Related issues: #10634, #8089, #11086, #12621 // Open PRs: #6875, #12924 - test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => { + test("no headroom when limit.input is set — compaction should trigger near boundary", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -151,7 +151,7 @@ describe("session.compaction.isOverflow", () => { }) }) - test("BUG: without limit.input, same token count correctly triggers compaction", async () => { + test("without limit.input, same token count correctly triggers compaction", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -171,7 +171,7 @@ describe("session.compaction.isOverflow", () => { }) }) - test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => { + test("asymmetry — limit.input model does not allow more usage than equivalent model without it", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, @@ -180,7 +180,7 @@ describe("session.compaction.isOverflow", () => { const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 }) const withoutInputLimit = createModel({ context: 200_000, output: 32_000 }) - // 170K total tokens — well above context-output (168K) but below input limit (200K) + // 181K total tokens — above usable (context - maxOutput = 168K) const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } } const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 0d5b89730a98..8a7465860bd4 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -57,6 +57,17 @@ const model: Provider.Model = { release_date: "2026-01-01", } +const model2: Provider.Model = { + ...model, + id: "other-model", + providerID: "other", + api: { + ...model.api, + id: "other-model", + }, + name: "Other Model", +} + function userInfo(id: string): MessageV2.User { return { id, @@ -359,7 +370,90 @@ describe("session.message-v2.toModelMessage", () => { ]) }) - test("omits provider metadata when assistant model differs", () => { + test("preserves reasoning providerMetadata when model matches", () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { openai: { signature: "sig-match" } }, + time: { start: 0 }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-match" } } }], + }, + ]) + }) + + test("preserves reasoning providerMetadata when model differs", () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { openai: { signature: "sig-different" } }, + time: { start: 0 }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-different" } } }], + }, + ]) + }) + + test("preserves text providerMetadata when model differs", () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "text", + text: "done", + metadata: { openai: { assistant: "meta" } }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } }], + }, + ]) + }) + + test("preserves tool callProviderMetadata when model differs", () => { const userID = "m-user" const assistantID = "m-assistant" @@ -375,16 +469,97 @@ describe("session.message-v2.toModelMessage", () => { ] as MessageV2.Part[], }, { - info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }), + info: assistantInfo(assistantID, userID, undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "ok", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + metadata: { openai: { tool: "meta" } }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + providerOptions: { openai: { tool: "meta" } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "ok" }, + providerOptions: { openai: { tool: "meta" } }, + }, + ], + }, + ]) + }) + + test("handles undefined metadata gracefully", () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID, undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), parts: [ { ...basePart(assistantID, "a1"), type: "text", text: "done", - metadata: { openai: { assistant: "meta" } }, }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -396,7 +571,6 @@ describe("session.message-v2.toModelMessage", () => { metadata: {}, time: { start: 0, end: 1 }, }, - metadata: { openai: { tool: "meta" } }, }, ] as MessageV2.Part[], }, @@ -411,6 +585,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "reasoning", text: "thinking", providerOptions: undefined }, { type: "tool-call", toolCallId: "call-1", diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 621ad99e9b47..b02bf4236ce4 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -117,6 +117,31 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error)).toBeUndefined() }) + test("does not retry when json has isRetryable: false", () => { + const error = wrap(JSON.stringify({ isRetryable: false, message: "prompt is too long" })) + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("does not retry 400 status in json body", () => { + const error = wrap(JSON.stringify({ status: 400, message: "Bad request" })) + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("does not retry 403 statusCode in json body", () => { + const error = wrap(JSON.stringify({ statusCode: 403, message: "Forbidden" })) + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("retries 500 status in json body", () => { + const error = wrap(JSON.stringify({ status: 500, message: "Internal Server Error" })) + expect(SessionRetry.retryable(error)).toBe(JSON.stringify({ status: 500, message: "Internal Server Error" })) + }) + + test("retries json without status or isRetryable fields", () => { + const error = wrap(JSON.stringify({ error: { message: "some_unknown_error" } })) + expect(SessionRetry.retryable(error)).toBe(JSON.stringify({ error: { message: "some_unknown_error" } })) + }) + test("does not retry context overflow errors", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", diff --git a/packages/opencode/test/session/steer.test.ts b/packages/opencode/test/session/steer.test.ts new file mode 100644 index 000000000000..5e4e8e8639a9 --- /dev/null +++ b/packages/opencode/test/session/steer.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import { SessionSteer } from "../../src/session/steer" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +const SESSION = "session_test_steer_001" +Log.init({ print: false }) + +/** Helper to run a test function inside Instance.provide context */ +function withInstance(fn: () => void | Promise) { + return Instance.provide({ + directory: projectRoot, + fn: async () => { + await fn() + }, + }) +} + +describe("SessionSteer", () => { + describe("push", () => { + test("creates a queued message with default mode 'queue'", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + const msg = SessionSteer.push(SESSION, "hello") + expect(msg.text).toBe("hello") + expect(msg.mode).toBe("queue") + expect(msg.id).toBeTruthy() + expect(msg.time).toBeGreaterThan(0) + }) + }) + + test("accepts explicit mode 'steer'", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + const msg = SessionSteer.push(SESSION, "redirect", "steer") + expect(msg.text).toBe("redirect") + expect(msg.mode).toBe("steer") + }) + }) + + test("accepts explicit mode 'queue'", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + const msg = SessionSteer.push(SESSION, "later", "queue") + expect(msg.mode).toBe("queue") + }) + }) + }) + + describe("take", () => { + test("drains all messages regardless of mode", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "a", "queue") + SessionSteer.push(SESSION, "b", "steer") + SessionSteer.push(SESSION, "c", "queue") + + const taken = SessionSteer.take(SESSION) + expect(taken).toHaveLength(3) + expect(taken.map((m) => m.text)).toEqual(["a", "b", "c"]) + expect(SessionSteer.list(SESSION)).toHaveLength(0) + }) + }) + + test("returns empty array when no messages", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + expect(SessionSteer.take(SESSION)).toEqual([]) + }) + }) + }) + + describe("takeByMode", () => { + test("drains only 'steer' messages, leaving 'queue' messages", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "queued-1", "queue") + SessionSteer.push(SESSION, "steer-1", "steer") + SessionSteer.push(SESSION, "queued-2", "queue") + SessionSteer.push(SESSION, "steer-2", "steer") + + const steered = SessionSteer.takeByMode(SESSION, "steer") + expect(steered).toHaveLength(2) + expect(steered.map((m) => m.text)).toEqual(["steer-1", "steer-2"]) + + const remaining = SessionSteer.list(SESSION) + expect(remaining).toHaveLength(2) + expect(remaining.map((m) => m.text)).toEqual(["queued-1", "queued-2"]) + }) + }) + + test("drains only 'queue' messages, leaving 'steer' messages", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "queued-1", "queue") + SessionSteer.push(SESSION, "steer-1", "steer") + SessionSteer.push(SESSION, "queued-2", "queue") + + const queued = SessionSteer.takeByMode(SESSION, "queue") + expect(queued).toHaveLength(2) + expect(queued.map((m) => m.text)).toEqual(["queued-1", "queued-2"]) + + const remaining = SessionSteer.list(SESSION) + expect(remaining).toHaveLength(1) + expect(remaining[0].text).toBe("steer-1") + }) + }) + + test("returns empty when no messages match mode", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "queued", "queue") + const steered = SessionSteer.takeByMode(SESSION, "steer") + expect(steered).toEqual([]) + expect(SessionSteer.list(SESSION)).toHaveLength(1) + }) + }) + + test("returns empty when buffer is empty", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + expect(SessionSteer.takeByMode(SESSION, "steer")).toEqual([]) + expect(SessionSteer.takeByMode(SESSION, "queue")).toEqual([]) + }) + }) + + test("sequential takeByMode drains both modes completely", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "s1", "steer") + SessionSteer.push(SESSION, "q1", "queue") + SessionSteer.push(SESSION, "s2", "steer") + SessionSteer.push(SESSION, "q2", "queue") + + const steered = SessionSteer.takeByMode(SESSION, "steer") + expect(steered).toHaveLength(2) + + const queued = SessionSteer.takeByMode(SESSION, "queue") + expect(queued).toHaveLength(2) + + expect(SessionSteer.has(SESSION)).toBe(false) + expect(SessionSteer.list(SESSION)).toHaveLength(0) + }) + }) + }) + + describe("has", () => { + test("returns false for empty session", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + expect(SessionSteer.has(SESSION)).toBe(false) + }) + }) + + test("returns true after push", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "test") + expect(SessionSteer.has(SESSION)).toBe(true) + }) + }) + + test("returns false after take drains all", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "test") + SessionSteer.take(SESSION) + expect(SessionSteer.has(SESSION)).toBe(false) + }) + }) + + test("returns true when takeByMode leaves remaining", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "q", "queue") + SessionSteer.takeByMode(SESSION, "steer") + expect(SessionSteer.has(SESSION)).toBe(true) + }) + }) + }) + + describe("list", () => { + test("returns current queue without draining", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "a", "queue") + SessionSteer.push(SESSION, "b", "steer") + + const first = SessionSteer.list(SESSION) + expect(first).toHaveLength(2) + + const second = SessionSteer.list(SESSION) + expect(second).toHaveLength(2) + }) + }) + }) + + describe("remove", () => { + test("removes specific message by id", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + const msg = SessionSteer.push(SESSION, "target", "steer") + SessionSteer.push(SESSION, "keep", "queue") + + const removed = SessionSteer.remove(SESSION, msg.id) + expect(removed).toBe(true) + expect(SessionSteer.list(SESSION)).toHaveLength(1) + expect(SessionSteer.list(SESSION)[0].text).toBe("keep") + }) + }) + + test("returns false for non-existent id", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "test") + expect(SessionSteer.remove(SESSION, "nonexistent")).toBe(false) + }) + }) + }) + + describe("clear", () => { + test("removes all pending messages", async () => { + await withInstance(() => { + SessionSteer.clear(SESSION) + SessionSteer.push(SESSION, "a", "queue") + SessionSteer.push(SESSION, "b", "steer") + SessionSteer.clear(SESSION) + expect(SessionSteer.has(SESSION)).toBe(false) + expect(SessionSteer.list(SESSION)).toHaveLength(0) + }) + }) + }) +}) diff --git a/packages/ui/src/assets/icons/app/android-studio.svg b/packages/ui/src/assets/icons/app/android-studio.svg index 8d87619f754e..99072abbddaf 100644 --- a/packages/ui/src/assets/icons/app/android-studio.svg +++ b/packages/ui/src/assets/icons/app/android-studio.svg @@ -1,369 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/antigravity.svg b/packages/ui/src/assets/icons/app/antigravity.svg index 52f235068f0b..c24ea04d57e1 100644 --- a/packages/ui/src/assets/icons/app/antigravity.svg +++ b/packages/ui/src/assets/icons/app/antigravity.svg @@ -1,97 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/cursor.svg b/packages/ui/src/assets/icons/app/cursor.svg index a778608649d0..d434e642a8a3 100644 --- a/packages/ui/src/assets/icons/app/cursor.svg +++ b/packages/ui/src/assets/icons/app/cursor.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/file-explorer.svg b/packages/ui/src/assets/icons/app/file-explorer.svg index e2166579688c..1c491c1c7b78 100644 --- a/packages/ui/src/assets/icons/app/file-explorer.svg +++ b/packages/ui/src/assets/icons/app/file-explorer.svg @@ -1,20 +1 @@ - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/ghostty.svg b/packages/ui/src/assets/icons/app/ghostty.svg index d646bd32b38b..ff273f8b4b14 100644 --- a/packages/ui/src/assets/icons/app/ghostty.svg +++ b/packages/ui/src/assets/icons/app/ghostty.svg @@ -1,13 +1 @@ - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/iterm2.svg b/packages/ui/src/assets/icons/app/iterm2.svg index 48c1aac51f27..e6690e377a02 100644 --- a/packages/ui/src/assets/icons/app/iterm2.svg +++ b/packages/ui/src/assets/icons/app/iterm2.svg @@ -1,13 +1 @@ - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/powershell.svg b/packages/ui/src/assets/icons/app/powershell.svg index 46570203c543..f7cedf5a1d08 100644 --- a/packages/ui/src/assets/icons/app/powershell.svg +++ b/packages/ui/src/assets/icons/app/powershell.svg @@ -1,14 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/sublimetext.svg b/packages/ui/src/assets/icons/app/sublimetext.svg index 7e625b559ed2..0bbda8d4a0fc 100644 --- a/packages/ui/src/assets/icons/app/sublimetext.svg +++ b/packages/ui/src/assets/icons/app/sublimetext.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/vscode.svg b/packages/ui/src/assets/icons/app/vscode.svg index 6b1827a6a2e9..0cb5ad533a33 100644 --- a/packages/ui/src/assets/icons/app/vscode.svg +++ b/packages/ui/src/assets/icons/app/vscode.svg @@ -1,39 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/zed-dark.svg b/packages/ui/src/assets/icons/app/zed-dark.svg index 0f9d12d05f12..4cf2fe69e882 100644 --- a/packages/ui/src/assets/icons/app/zed-dark.svg +++ b/packages/ui/src/assets/icons/app/zed-dark.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/zed.svg b/packages/ui/src/assets/icons/app/zed.svg index 37aa2d6c09be..c0e201dae44e 100644 --- a/packages/ui/src/assets/icons/app/zed.svg +++ b/packages/ui/src/assets/icons/app/zed.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/auto_light.svg b/packages/ui/src/assets/icons/file-types/auto_light.svg index 5f2451b2384f..51d091821b2c 100644 --- a/packages/ui/src/assets/icons/file-types/auto_light.svg +++ b/packages/ui/src/assets/icons/file-types/auto_light.svg @@ -1,12 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/cursor.svg b/packages/ui/src/assets/icons/file-types/cursor.svg index b754147ff0b2..a30ffe2bf01e 100644 --- a/packages/ui/src/assets/icons/file-types/cursor.svg +++ b/packages/ui/src/assets/icons/file-types/cursor.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/cursor_light.svg b/packages/ui/src/assets/icons/file-types/cursor_light.svg index f65b6461ae41..533fd3078528 100644 --- a/packages/ui/src/assets/icons/file-types/cursor_light.svg +++ b/packages/ui/src/assets/icons/file-types/cursor_light.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/drone_light.svg b/packages/ui/src/assets/icons/file-types/drone_light.svg index ce3ad253b47c..b1de6a290488 100644 --- a/packages/ui/src/assets/icons/file-types/drone_light.svg +++ b/packages/ui/src/assets/icons/file-types/drone_light.svg @@ -1,4 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-admin-open.svg b/packages/ui/src/assets/icons/file-types/folder-admin-open.svg index 5e77464ff8e0..acfda3b4b395 100644 --- a/packages/ui/src/assets/icons/file-types/folder-admin-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-admin-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-admin.svg b/packages/ui/src/assets/icons/file-types/folder-admin.svg index f8d1ea13c07a..c9dfa563f0a7 100644 --- a/packages/ui/src/assets/icons/file-types/folder-admin.svg +++ b/packages/ui/src/assets/icons/file-types/folder-admin.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-circleci-open.svg b/packages/ui/src/assets/icons/file-types/folder-circleci-open.svg index 9e323ff9c289..2e90e8e7c4d5 100644 --- a/packages/ui/src/assets/icons/file-types/folder-circleci-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-circleci-open.svg @@ -1,7 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-circleci.svg b/packages/ui/src/assets/icons/file-types/folder-circleci.svg index ef3251857948..4d82e2ee262d 100644 --- a/packages/ui/src/assets/icons/file-types/folder-circleci.svg +++ b/packages/ui/src/assets/icons/file-types/folder-circleci.svg @@ -1,7 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-flow-open.svg b/packages/ui/src/assets/icons/file-types/folder-flow-open.svg index a72dd76b7f22..5676f8f8e9bb 100644 --- a/packages/ui/src/assets/icons/file-types/folder-flow-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-flow-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-flow.svg b/packages/ui/src/assets/icons/file-types/folder-flow.svg index 015518922de3..5a48b820e86f 100644 --- a/packages/ui/src/assets/icons/file-types/folder-flow.svg +++ b/packages/ui/src/assets/icons/file-types/folder-flow.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg b/packages/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg index 3ae400ed9f37..c41c21e07699 100644 --- a/packages/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-gh-workflows-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-gh-workflows.svg b/packages/ui/src/assets/icons/file-types/folder-gh-workflows.svg index 3a868cca97c5..561f758a1937 100644 --- a/packages/ui/src/assets/icons/file-types/folder-gh-workflows.svg +++ b/packages/ui/src/assets/icons/file-types/folder-gh-workflows.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-github-open.svg b/packages/ui/src/assets/icons/file-types/folder-github-open.svg index 84e5bee8497c..0cd9a59c15a9 100644 --- a/packages/ui/src/assets/icons/file-types/folder-github-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-github-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-github.svg b/packages/ui/src/assets/icons/file-types/folder-github.svg index 374bcae0b257..70b83b583910 100644 --- a/packages/ui/src/assets/icons/file-types/folder-github.svg +++ b/packages/ui/src/assets/icons/file-types/folder-github.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg b/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg index 5839a2b1cc27..ed4d386f445b 100644 --- a/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-intellij-open.svg @@ -1,39 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-intellij.svg b/packages/ui/src/assets/icons/file-types/folder-intellij.svg index c655f37ef1aa..5c50f70bed23 100644 --- a/packages/ui/src/assets/icons/file-types/folder-intellij.svg +++ b/packages/ui/src/assets/icons/file-types/folder-intellij.svg @@ -1,39 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-macos-open.svg b/packages/ui/src/assets/icons/file-types/folder-macos-open.svg index 8d0280ae902b..24e0e6434002 100644 --- a/packages/ui/src/assets/icons/file-types/folder-macos-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-macos-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-macos.svg b/packages/ui/src/assets/icons/file-types/folder-macos.svg index 6afe2ed2ae10..b2ae0d95f7d6 100644 --- a/packages/ui/src/assets/icons/file-types/folder-macos.svg +++ b/packages/ui/src/assets/icons/file-types/folder-macos.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-next-open.svg b/packages/ui/src/assets/icons/file-types/folder-next-open.svg index c8709cac308b..f88d0cdd73bb 100644 --- a/packages/ui/src/assets/icons/file-types/folder-next-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-next-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-next.svg b/packages/ui/src/assets/icons/file-types/folder-next.svg index cab1e8fca1a7..51d2fe5bf1ac 100644 --- a/packages/ui/src/assets/icons/file-types/folder-next.svg +++ b/packages/ui/src/assets/icons/file-types/folder-next.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-nuxt-open.svg b/packages/ui/src/assets/icons/file-types/folder-nuxt-open.svg index c49ff8d214e7..478f120400b8 100644 --- a/packages/ui/src/assets/icons/file-types/folder-nuxt-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-nuxt-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-nuxt.svg b/packages/ui/src/assets/icons/file-types/folder-nuxt.svg index a0a52b06ea74..83095bca3787 100644 --- a/packages/ui/src/assets/icons/file-types/folder-nuxt.svg +++ b/packages/ui/src/assets/icons/file-types/folder-nuxt.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-open.svg b/packages/ui/src/assets/icons/file-types/folder-open.svg index eac89185e84f..dc22934a92d6 100644 --- a/packages/ui/src/assets/icons/file-types/folder-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-open.svg @@ -1,5 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-scripts-open.svg b/packages/ui/src/assets/icons/file-types/folder-scripts-open.svg index 981a43f783c1..74273cff9914 100644 --- a/packages/ui/src/assets/icons/file-types/folder-scripts-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-scripts-open.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-scripts.svg b/packages/ui/src/assets/icons/file-types/folder-scripts.svg index 4b755acb9f88..fb524ec0e968 100644 --- a/packages/ui/src/assets/icons/file-types/folder-scripts.svg +++ b/packages/ui/src/assets/icons/file-types/folder-scripts.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-target-open.svg b/packages/ui/src/assets/icons/file-types/folder-target-open.svg index 0004bf8668aa..370d1be014b4 100644 --- a/packages/ui/src/assets/icons/file-types/folder-target-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-target-open.svg @@ -1,10 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-turborepo-open.svg b/packages/ui/src/assets/icons/file-types/folder-turborepo-open.svg index e0d7c35cff48..ec401f2165ae 100644 --- a/packages/ui/src/assets/icons/file-types/folder-turborepo-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-turborepo-open.svg @@ -1,15 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-turborepo.svg b/packages/ui/src/assets/icons/file-types/folder-turborepo.svg index ea203360bd4b..7b87db3a75f1 100644 --- a/packages/ui/src/assets/icons/file-types/folder-turborepo.svg +++ b/packages/ui/src/assets/icons/file-types/folder-turborepo.svg @@ -1,15 +1 @@ - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg b/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg index c571c63f3d66..a4ed0efcb39a 100644 --- a/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg +++ b/packages/ui/src/assets/icons/file-types/folder-vercel-open.svg @@ -1,5 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder-vercel.svg b/packages/ui/src/assets/icons/file-types/folder-vercel.svg index 51384813030c..9b645651ff2a 100644 --- a/packages/ui/src/assets/icons/file-types/folder-vercel.svg +++ b/packages/ui/src/assets/icons/file-types/folder-vercel.svg @@ -1,5 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/folder.svg b/packages/ui/src/assets/icons/file-types/folder.svg index 97ee81ca4305..daa6894f52d6 100644 --- a/packages/ui/src/assets/icons/file-types/folder.svg +++ b/packages/ui/src/assets/icons/file-types/folder.svg @@ -1,5 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/go_gopher.svg b/packages/ui/src/assets/icons/file-types/go_gopher.svg index e465f7456137..7cb8682bb97b 100644 --- a/packages/ui/src/assets/icons/file-types/go_gopher.svg +++ b/packages/ui/src/assets/icons/file-types/go_gopher.svg @@ -1,35 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/nano-staged_light.svg b/packages/ui/src/assets/icons/file-types/nano-staged_light.svg index 698232f0c229..e00d768a9704 100644 --- a/packages/ui/src/assets/icons/file-types/nano-staged_light.svg +++ b/packages/ui/src/assets/icons/file-types/nano-staged_light.svg @@ -1,4 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/opa.svg b/packages/ui/src/assets/icons/file-types/opa.svg index 3afc1c6bf457..763492caa450 100644 --- a/packages/ui/src/assets/icons/file-types/opa.svg +++ b/packages/ui/src/assets/icons/file-types/opa.svg @@ -1,9 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/quarto.svg b/packages/ui/src/assets/icons/file-types/quarto.svg index 3bb8ef7c7a71..d09b4051f7b0 100644 --- a/packages/ui/src/assets/icons/file-types/quarto.svg +++ b/packages/ui/src/assets/icons/file-types/quarto.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/rome.svg b/packages/ui/src/assets/icons/file-types/rome.svg index 8f5de92d2892..0e270bbc8d67 100644 --- a/packages/ui/src/assets/icons/file-types/rome.svg +++ b/packages/ui/src/assets/icons/file-types/rome.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/stitches_light.svg b/packages/ui/src/assets/icons/file-types/stitches_light.svg index 8001d9dfc9cd..5e34c97b299e 100644 --- a/packages/ui/src/assets/icons/file-types/stitches_light.svg +++ b/packages/ui/src/assets/icons/file-types/stitches_light.svg @@ -1,10 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/stylelint_light.svg b/packages/ui/src/assets/icons/file-types/stylelint_light.svg index 502fec33f14e..fd65ecea3727 100644 --- a/packages/ui/src/assets/icons/file-types/stylelint_light.svg +++ b/packages/ui/src/assets/icons/file-types/stylelint_light.svg @@ -1,13 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/unocss.svg b/packages/ui/src/assets/icons/file-types/unocss.svg index eab05c437963..5072d8a2eb31 100644 --- a/packages/ui/src/assets/icons/file-types/unocss.svg +++ b/packages/ui/src/assets/icons/file-types/unocss.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/file-types/vlang.svg b/packages/ui/src/assets/icons/file-types/vlang.svg index 17bf0e049c36..1d794ea72ddb 100644 --- a/packages/ui/src/assets/icons/file-types/vlang.svg +++ b/packages/ui/src/assets/icons/file-types/vlang.svg @@ -1,6 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/302ai.svg b/packages/ui/src/assets/icons/provider/302ai.svg index 46f2e4315e02..95bbaf6b2535 100644 --- a/packages/ui/src/assets/icons/provider/302ai.svg +++ b/packages/ui/src/assets/icons/provider/302ai.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/abacus.svg b/packages/ui/src/assets/icons/provider/abacus.svg index 121a91d98ef0..13c195518df8 100644 --- a/packages/ui/src/assets/icons/provider/abacus.svg +++ b/packages/ui/src/assets/icons/provider/abacus.svg @@ -1,14 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/aihubmix.svg b/packages/ui/src/assets/icons/provider/aihubmix.svg index 33164b78b3e5..8f3555623d51 100644 --- a/packages/ui/src/assets/icons/provider/aihubmix.svg +++ b/packages/ui/src/assets/icons/provider/aihubmix.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/alibaba-cn.svg b/packages/ui/src/assets/icons/provider/alibaba-cn.svg index 5d8355c18e49..8f7b71fa8794 100644 --- a/packages/ui/src/assets/icons/provider/alibaba-cn.svg +++ b/packages/ui/src/assets/icons/provider/alibaba-cn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg index b3a2edc3c02a..95d098985d50 100644 --- a/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg index b3a2edc3c02a..95d098985d50 100644 --- a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/alibaba.svg b/packages/ui/src/assets/icons/provider/alibaba.svg index b3a2edc3c02a..95d098985d50 100644 --- a/packages/ui/src/assets/icons/provider/alibaba.svg +++ b/packages/ui/src/assets/icons/provider/alibaba.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/amazon-bedrock.svg b/packages/ui/src/assets/icons/provider/amazon-bedrock.svg index 1f185ef53195..e744926ef616 100644 --- a/packages/ui/src/assets/icons/provider/amazon-bedrock.svg +++ b/packages/ui/src/assets/icons/provider/amazon-bedrock.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/anthropic.svg b/packages/ui/src/assets/icons/provider/anthropic.svg index aaa01fcdb2e4..2e516b785d43 100644 --- a/packages/ui/src/assets/icons/provider/anthropic.svg +++ b/packages/ui/src/assets/icons/provider/anthropic.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg b/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg +++ b/packages/ui/src/assets/icons/provider/azure-cognitive-services.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/azure.svg b/packages/ui/src/assets/icons/provider/azure.svg index 07c6519ba4f3..8ac09549de11 100644 --- a/packages/ui/src/assets/icons/provider/azure.svg +++ b/packages/ui/src/assets/icons/provider/azure.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/bailing.svg b/packages/ui/src/assets/icons/provider/bailing.svg index b8ed486a86d1..db65ca21b622 100644 --- a/packages/ui/src/assets/icons/provider/bailing.svg +++ b/packages/ui/src/assets/icons/provider/bailing.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/baseten.svg b/packages/ui/src/assets/icons/provider/baseten.svg index ffd4fbd8b002..7ff53306fece 100644 --- a/packages/ui/src/assets/icons/provider/baseten.svg +++ b/packages/ui/src/assets/icons/provider/baseten.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/berget.svg b/packages/ui/src/assets/icons/provider/berget.svg index 831547a59ed0..ccef3abe8a79 100644 --- a/packages/ui/src/assets/icons/provider/berget.svg +++ b/packages/ui/src/assets/icons/provider/berget.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cerebras.svg b/packages/ui/src/assets/icons/provider/cerebras.svg index b167596729fe..8c697cfc9793 100644 --- a/packages/ui/src/assets/icons/provider/cerebras.svg +++ b/packages/ui/src/assets/icons/provider/cerebras.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/chutes.svg b/packages/ui/src/assets/icons/provider/chutes.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/chutes.svg +++ b/packages/ui/src/assets/icons/provider/chutes.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/clarifai.svg +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg index 6f09a794e6ce..8171da2f8d2a 100644 --- a/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg +++ b/packages/ui/src/assets/icons/provider/cloudferro-sherlock.svg @@ -1,5 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg b/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg index 02c7e51d3e0d..3ce2c59b2e43 100644 --- a/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg +++ b/packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg b/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg index 02c7e51d3e0d..3ce2c59b2e43 100644 --- a/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg +++ b/packages/ui/src/assets/icons/provider/cloudflare-workers-ai.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cohere.svg b/packages/ui/src/assets/icons/provider/cohere.svg index cfeaa60028df..5299b6a50eac 100644 --- a/packages/ui/src/assets/icons/provider/cohere.svg +++ b/packages/ui/src/assets/icons/provider/cohere.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/cortecs.svg b/packages/ui/src/assets/icons/provider/cortecs.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/cortecs.svg +++ b/packages/ui/src/assets/icons/provider/cortecs.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/deepinfra.svg b/packages/ui/src/assets/icons/provider/deepinfra.svg index c35ab7183c17..4816e71f53d9 100644 --- a/packages/ui/src/assets/icons/provider/deepinfra.svg +++ b/packages/ui/src/assets/icons/provider/deepinfra.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/deepseek.svg b/packages/ui/src/assets/icons/provider/deepseek.svg index 5d6efa991b7d..54844595d307 100644 --- a/packages/ui/src/assets/icons/provider/deepseek.svg +++ b/packages/ui/src/assets/icons/provider/deepseek.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg index e045c96fb355..ce30e8d31ddd 100644 --- a/packages/ui/src/assets/icons/provider/dinference.svg +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg index 472dee9122e3..0e306be2bc74 100644 --- a/packages/ui/src/assets/icons/provider/drun.svg +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/evroc.svg b/packages/ui/src/assets/icons/provider/evroc.svg index 7597820a192d..9130a85df7b9 100644 --- a/packages/ui/src/assets/icons/provider/evroc.svg +++ b/packages/ui/src/assets/icons/provider/evroc.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/fastrouter.svg b/packages/ui/src/assets/icons/provider/fastrouter.svg index ec73dc49cdf0..6e5a45114669 100644 --- a/packages/ui/src/assets/icons/provider/fastrouter.svg +++ b/packages/ui/src/assets/icons/provider/fastrouter.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/fireworks-ai.svg b/packages/ui/src/assets/icons/provider/fireworks-ai.svg index 72cc91f094c2..bf55fdf273ed 100644 --- a/packages/ui/src/assets/icons/provider/fireworks-ai.svg +++ b/packages/ui/src/assets/icons/provider/fireworks-ai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/firmware.svg b/packages/ui/src/assets/icons/provider/firmware.svg index baa524ba2d42..22384c290de8 100644 --- a/packages/ui/src/assets/icons/provider/firmware.svg +++ b/packages/ui/src/assets/icons/provider/firmware.svg @@ -1,18 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/friendli.svg b/packages/ui/src/assets/icons/provider/friendli.svg index 8acb7632df09..1ea0ba0d4238 100644 --- a/packages/ui/src/assets/icons/provider/friendli.svg +++ b/packages/ui/src/assets/icons/provider/friendli.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/github-copilot.svg b/packages/ui/src/assets/icons/provider/github-copilot.svg index 2d426f265cf3..ea19c462c4d3 100644 --- a/packages/ui/src/assets/icons/provider/github-copilot.svg +++ b/packages/ui/src/assets/icons/provider/github-copilot.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/github-models.svg b/packages/ui/src/assets/icons/provider/github-models.svg index 39689d95c0c6..825f9c1ee53e 100644 --- a/packages/ui/src/assets/icons/provider/github-models.svg +++ b/packages/ui/src/assets/icons/provider/github-models.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/gitlab.svg b/packages/ui/src/assets/icons/provider/gitlab.svg index eef04ace2b09..cb42932781a2 100644 --- a/packages/ui/src/assets/icons/provider/gitlab.svg +++ b/packages/ui/src/assets/icons/provider/gitlab.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/google-vertex-anthropic.svg b/packages/ui/src/assets/icons/provider/google-vertex-anthropic.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/google-vertex-anthropic.svg +++ b/packages/ui/src/assets/icons/provider/google-vertex-anthropic.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/google-vertex.svg b/packages/ui/src/assets/icons/provider/google-vertex.svg index fda56b4479b5..165a4db84bc1 100644 --- a/packages/ui/src/assets/icons/provider/google-vertex.svg +++ b/packages/ui/src/assets/icons/provider/google-vertex.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/google.svg b/packages/ui/src/assets/icons/provider/google.svg index 4ebfcfd2b4c0..8628c8311fae 100644 --- a/packages/ui/src/assets/icons/provider/google.svg +++ b/packages/ui/src/assets/icons/provider/google.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/groq.svg b/packages/ui/src/assets/icons/provider/groq.svg index fdd22ed7dfdc..b89172200d3c 100644 --- a/packages/ui/src/assets/icons/provider/groq.svg +++ b/packages/ui/src/assets/icons/provider/groq.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/helicone.svg b/packages/ui/src/assets/icons/provider/helicone.svg index 8a4bd43ad3ef..4f4a9e44f3ae 100644 --- a/packages/ui/src/assets/icons/provider/helicone.svg +++ b/packages/ui/src/assets/icons/provider/helicone.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/huggingface.svg b/packages/ui/src/assets/icons/provider/huggingface.svg index 4241ff94e3d4..02ec3d6dce45 100644 --- a/packages/ui/src/assets/icons/provider/huggingface.svg +++ b/packages/ui/src/assets/icons/provider/huggingface.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/iflowcn.svg b/packages/ui/src/assets/icons/provider/iflowcn.svg index 6f35a7d591ae..61e21dc2185a 100644 --- a/packages/ui/src/assets/icons/provider/iflowcn.svg +++ b/packages/ui/src/assets/icons/provider/iflowcn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/inception.svg b/packages/ui/src/assets/icons/provider/inception.svg index f70ffbc785b3..005db929545a 100644 --- a/packages/ui/src/assets/icons/provider/inception.svg +++ b/packages/ui/src/assets/icons/provider/inception.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/inference.svg b/packages/ui/src/assets/icons/provider/inference.svg index c17f6657448f..b9f4f79e66db 100644 --- a/packages/ui/src/assets/icons/provider/inference.svg +++ b/packages/ui/src/assets/icons/provider/inference.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/io-net.svg b/packages/ui/src/assets/icons/provider/io-net.svg index 651e57df845a..5938ad715140 100644 --- a/packages/ui/src/assets/icons/provider/io-net.svg +++ b/packages/ui/src/assets/icons/provider/io-net.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/jiekou.svg b/packages/ui/src/assets/icons/provider/jiekou.svg index 7fe6378e561c..e70f8c415d1a 100644 --- a/packages/ui/src/assets/icons/provider/jiekou.svg +++ b/packages/ui/src/assets/icons/provider/jiekou.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/kilo.svg b/packages/ui/src/assets/icons/provider/kilo.svg index 0a761347a8e2..02933bfd33d7 100644 --- a/packages/ui/src/assets/icons/provider/kilo.svg +++ b/packages/ui/src/assets/icons/provider/kilo.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/kimi-for-coding.svg b/packages/ui/src/assets/icons/provider/kimi-for-coding.svg index 8f2af02e63e1..5955ba0fce8e 100644 --- a/packages/ui/src/assets/icons/provider/kimi-for-coding.svg +++ b/packages/ui/src/assets/icons/provider/kimi-for-coding.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg index 3d0d0c455737..ee2bd75f0f99 100644 --- a/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/kuae-cloud-coding-plan.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/llama.svg b/packages/ui/src/assets/icons/provider/llama.svg index 3053b251fcce..d46af7ceb0b4 100644 --- a/packages/ui/src/assets/icons/provider/llama.svg +++ b/packages/ui/src/assets/icons/provider/llama.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/lmstudio.svg b/packages/ui/src/assets/icons/provider/lmstudio.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/lmstudio.svg +++ b/packages/ui/src/assets/icons/provider/lmstudio.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/lucidquery.svg b/packages/ui/src/assets/icons/provider/lucidquery.svg index 6420a042eab6..c573d50dbc52 100644 --- a/packages/ui/src/assets/icons/provider/lucidquery.svg +++ b/packages/ui/src/assets/icons/provider/lucidquery.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/meganova.svg b/packages/ui/src/assets/icons/provider/meganova.svg index 59e8b3a1f271..7630d6705335 100644 --- a/packages/ui/src/assets/icons/provider/meganova.svg +++ b/packages/ui/src/assets/icons/provider/meganova.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/minimax-cn-coding-plan.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/minimax-cn.svg b/packages/ui/src/assets/icons/provider/minimax-cn.svg index 44c5eec21d3e..ac32b6bf5430 100644 --- a/packages/ui/src/assets/icons/provider/minimax-cn.svg +++ b/packages/ui/src/assets/icons/provider/minimax-cn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/minimax-coding-plan.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/minimax.svg b/packages/ui/src/assets/icons/provider/minimax.svg index 44c5eec21d3e..ac32b6bf5430 100644 --- a/packages/ui/src/assets/icons/provider/minimax.svg +++ b/packages/ui/src/assets/icons/provider/minimax.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/mistral.svg b/packages/ui/src/assets/icons/provider/mistral.svg index 966e474bc087..188570804737 100644 --- a/packages/ui/src/assets/icons/provider/mistral.svg +++ b/packages/ui/src/assets/icons/provider/mistral.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/moark.svg b/packages/ui/src/assets/icons/provider/moark.svg index dc84a9191c78..d903d64a9c8a 100644 --- a/packages/ui/src/assets/icons/provider/moark.svg +++ b/packages/ui/src/assets/icons/provider/moark.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/modelscope.svg b/packages/ui/src/assets/icons/provider/modelscope.svg index 94a894a555b6..66af60379fd8 100644 --- a/packages/ui/src/assets/icons/provider/modelscope.svg +++ b/packages/ui/src/assets/icons/provider/modelscope.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/moonshotai-cn.svg b/packages/ui/src/assets/icons/provider/moonshotai-cn.svg index 3cdf7c868125..5c247017cdfb 100644 --- a/packages/ui/src/assets/icons/provider/moonshotai-cn.svg +++ b/packages/ui/src/assets/icons/provider/moonshotai-cn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/moonshotai.svg b/packages/ui/src/assets/icons/provider/moonshotai.svg index 3cdf7c868125..5c247017cdfb 100644 --- a/packages/ui/src/assets/icons/provider/moonshotai.svg +++ b/packages/ui/src/assets/icons/provider/moonshotai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/morph.svg b/packages/ui/src/assets/icons/provider/morph.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/morph.svg +++ b/packages/ui/src/assets/icons/provider/morph.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/nano-gpt.svg b/packages/ui/src/assets/icons/provider/nano-gpt.svg index f7fbb4cdcdef..613c58ef3fe9 100644 --- a/packages/ui/src/assets/icons/provider/nano-gpt.svg +++ b/packages/ui/src/assets/icons/provider/nano-gpt.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/nebius.svg b/packages/ui/src/assets/icons/provider/nebius.svg index aeba5890d11a..b6a322d07b4b 100644 --- a/packages/ui/src/assets/icons/provider/nebius.svg +++ b/packages/ui/src/assets/icons/provider/nebius.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/nova.svg b/packages/ui/src/assets/icons/provider/nova.svg index 9fcae228c0ae..f163427913e6 100644 --- a/packages/ui/src/assets/icons/provider/nova.svg +++ b/packages/ui/src/assets/icons/provider/nova.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/novita-ai.svg b/packages/ui/src/assets/icons/provider/novita-ai.svg index ac537b8dd424..f58e70b1f539 100644 --- a/packages/ui/src/assets/icons/provider/novita-ai.svg +++ b/packages/ui/src/assets/icons/provider/novita-ai.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/nvidia.svg b/packages/ui/src/assets/icons/provider/nvidia.svg index 1f53eefca7ce..8a2e7db454e9 100644 --- a/packages/ui/src/assets/icons/provider/nvidia.svg +++ b/packages/ui/src/assets/icons/provider/nvidia.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/ollama-cloud.svg b/packages/ui/src/assets/icons/provider/ollama-cloud.svg index 08c05cf28c50..33c5654fbe6f 100644 --- a/packages/ui/src/assets/icons/provider/ollama-cloud.svg +++ b/packages/ui/src/assets/icons/provider/ollama-cloud.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/openai.svg b/packages/ui/src/assets/icons/provider/openai.svg index 000f65c34aa5..d2e46865d7ba 100644 --- a/packages/ui/src/assets/icons/provider/openai.svg +++ b/packages/ui/src/assets/icons/provider/openai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/opencode-go.svg b/packages/ui/src/assets/icons/provider/opencode-go.svg index e0833b9230f8..3a6def94d98f 100644 --- a/packages/ui/src/assets/icons/provider/opencode-go.svg +++ b/packages/ui/src/assets/icons/provider/opencode-go.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/opencode.svg b/packages/ui/src/assets/icons/provider/opencode.svg index 95084658210b..b28984442221 100644 --- a/packages/ui/src/assets/icons/provider/opencode.svg +++ b/packages/ui/src/assets/icons/provider/opencode.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/openrouter.svg b/packages/ui/src/assets/icons/provider/openrouter.svg index 7e8abc81d953..76996f71b982 100644 --- a/packages/ui/src/assets/icons/provider/openrouter.svg +++ b/packages/ui/src/assets/icons/provider/openrouter.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/ovhcloud.svg b/packages/ui/src/assets/icons/provider/ovhcloud.svg index 064b0835be24..8162a70a0be4 100644 --- a/packages/ui/src/assets/icons/provider/ovhcloud.svg +++ b/packages/ui/src/assets/icons/provider/ovhcloud.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg index a0f38862a4a0..b657b3753c66 100644 --- a/packages/ui/src/assets/icons/provider/perplexity-agent.svg +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/perplexity.svg b/packages/ui/src/assets/icons/provider/perplexity.svg index a0f38862a4a0..b657b3753c66 100644 --- a/packages/ui/src/assets/icons/provider/perplexity.svg +++ b/packages/ui/src/assets/icons/provider/perplexity.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/poe.svg b/packages/ui/src/assets/icons/provider/poe.svg index a5ab62d725ab..306e0a6dcd7e 100644 --- a/packages/ui/src/assets/icons/provider/poe.svg +++ b/packages/ui/src/assets/icons/provider/poe.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/privatemode-ai.svg b/packages/ui/src/assets/icons/provider/privatemode-ai.svg index edb5a6d76481..6f268cd00547 100644 --- a/packages/ui/src/assets/icons/provider/privatemode-ai.svg +++ b/packages/ui/src/assets/icons/provider/privatemode-ai.svg @@ -1,5 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/qihang-ai.svg b/packages/ui/src/assets/icons/provider/qihang-ai.svg index 3b356637a121..fc9911abf691 100644 --- a/packages/ui/src/assets/icons/provider/qihang-ai.svg +++ b/packages/ui/src/assets/icons/provider/qihang-ai.svg @@ -1,9 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/qiniu-ai.svg b/packages/ui/src/assets/icons/provider/qiniu-ai.svg index 858560f9ffe7..2bc874f31a6a 100644 --- a/packages/ui/src/assets/icons/provider/qiniu-ai.svg +++ b/packages/ui/src/assets/icons/provider/qiniu-ai.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/requesty.svg b/packages/ui/src/assets/icons/provider/requesty.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/requesty.svg +++ b/packages/ui/src/assets/icons/provider/requesty.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/sap-ai-core.svg b/packages/ui/src/assets/icons/provider/sap-ai-core.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/sap-ai-core.svg +++ b/packages/ui/src/assets/icons/provider/sap-ai-core.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/scaleway.svg b/packages/ui/src/assets/icons/provider/scaleway.svg index 7574f72f3887..a5cf1fe05bfc 100644 --- a/packages/ui/src/assets/icons/provider/scaleway.svg +++ b/packages/ui/src/assets/icons/provider/scaleway.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/siliconflow-cn.svg b/packages/ui/src/assets/icons/provider/siliconflow-cn.svg index 13cac22b9d30..ffe32c54e379 100644 --- a/packages/ui/src/assets/icons/provider/siliconflow-cn.svg +++ b/packages/ui/src/assets/icons/provider/siliconflow-cn.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/siliconflow.svg b/packages/ui/src/assets/icons/provider/siliconflow.svg index 13cac22b9d30..ffe32c54e379 100644 --- a/packages/ui/src/assets/icons/provider/siliconflow.svg +++ b/packages/ui/src/assets/icons/provider/siliconflow.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/stackit.svg b/packages/ui/src/assets/icons/provider/stackit.svg index 0d78b781acf6..ec062eccea8d 100644 --- a/packages/ui/src/assets/icons/provider/stackit.svg +++ b/packages/ui/src/assets/icons/provider/stackit.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/stepfun.svg b/packages/ui/src/assets/icons/provider/stepfun.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/stepfun.svg +++ b/packages/ui/src/assets/icons/provider/stepfun.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/submodel.svg b/packages/ui/src/assets/icons/provider/submodel.svg index 5bef03c6492c..283348f25ab0 100644 --- a/packages/ui/src/assets/icons/provider/submodel.svg +++ b/packages/ui/src/assets/icons/provider/submodel.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/synthetic.svg b/packages/ui/src/assets/icons/provider/synthetic.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/synthetic.svg +++ b/packages/ui/src/assets/icons/provider/synthetic.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg index 502e51a5be08..522db4a818c1 100644 --- a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/togetherai.svg b/packages/ui/src/assets/icons/provider/togetherai.svg index 68413386c0e3..579a85252a67 100644 --- a/packages/ui/src/assets/icons/provider/togetherai.svg +++ b/packages/ui/src/assets/icons/provider/togetherai.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/upstage.svg b/packages/ui/src/assets/icons/provider/upstage.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/upstage.svg +++ b/packages/ui/src/assets/icons/provider/upstage.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/v0.svg b/packages/ui/src/assets/icons/provider/v0.svg index 09f3b411e3be..303d8f1ce205 100644 --- a/packages/ui/src/assets/icons/provider/v0.svg +++ b/packages/ui/src/assets/icons/provider/v0.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/venice.svg b/packages/ui/src/assets/icons/provider/venice.svg index 3d5809e3bf19..d214582a1404 100644 --- a/packages/ui/src/assets/icons/provider/venice.svg +++ b/packages/ui/src/assets/icons/provider/venice.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/vercel.svg b/packages/ui/src/assets/icons/provider/vercel.svg index a99425f2a945..d08c1cff1b9e 100644 --- a/packages/ui/src/assets/icons/provider/vercel.svg +++ b/packages/ui/src/assets/icons/provider/vercel.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/vivgrid.svg b/packages/ui/src/assets/icons/provider/vivgrid.svg index 928fa3ff1ed2..37c27d0d08f0 100644 --- a/packages/ui/src/assets/icons/provider/vivgrid.svg +++ b/packages/ui/src/assets/icons/provider/vivgrid.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/vultr.svg b/packages/ui/src/assets/icons/provider/vultr.svg index 9205d4484555..3b5e25a73de3 100644 --- a/packages/ui/src/assets/icons/provider/vultr.svg +++ b/packages/ui/src/assets/icons/provider/vultr.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/wandb.svg b/packages/ui/src/assets/icons/provider/wandb.svg index 086e9aa1fca1..ce1f77422bf4 100644 --- a/packages/ui/src/assets/icons/provider/wandb.svg +++ b/packages/ui/src/assets/icons/provider/wandb.svg @@ -1,24 +1 @@ - - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/xai.svg b/packages/ui/src/assets/icons/provider/xai.svg index ccd22443c491..db93091d7d7c 100644 --- a/packages/ui/src/assets/icons/provider/xai.svg +++ b/packages/ui/src/assets/icons/provider/xai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/xiaomi.svg b/packages/ui/src/assets/icons/provider/xiaomi.svg index 4a893919e0d7..a8f35bb2739a 100644 --- a/packages/ui/src/assets/icons/provider/xiaomi.svg +++ b/packages/ui/src/assets/icons/provider/xiaomi.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/zai-coding-plan.svg b/packages/ui/src/assets/icons/provider/zai-coding-plan.svg index d7da9b7c5f35..5fd25266ea0c 100644 --- a/packages/ui/src/assets/icons/provider/zai-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/zai-coding-plan.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/zai.svg b/packages/ui/src/assets/icons/provider/zai.svg index d7da9b7c5f35..5fd25266ea0c 100644 --- a/packages/ui/src/assets/icons/provider/zai.svg +++ b/packages/ui/src/assets/icons/provider/zai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index 9eb8045e453a..77203134ef76 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg b/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg index 3d0d0c455737..ee2bd75f0f99 100644 --- a/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg +++ b/packages/ui/src/assets/icons/provider/zhipuai-coding-plan.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/provider/zhipuai.svg b/packages/ui/src/assets/icons/provider/zhipuai.svg index d7da9b7c5f35..5fd25266ea0c 100644 --- a/packages/ui/src/assets/icons/provider/zhipuai.svg +++ b/packages/ui/src/assets/icons/provider/zhipuai.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/packages/ui/src/components/app-icons/sprite.svg b/packages/ui/src/components/app-icons/sprite.svg index 68361f4137ba..ffbef6bd56dc 100644 --- a/packages/ui/src/components/app-icons/sprite.svg +++ b/packages/ui/src/components/app-icons/sprite.svg @@ -1,114 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx deleted file mode 100644 index a02fe941b1df..000000000000 --- a/packages/ui/src/components/basic-tool.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" -import { useI18n } from "../context/i18n" -import { createStore } from "solid-js/store" -import { Collapsible } from "./collapsible" -import type { IconProps } from "./icon" -import { TextShimmer } from "./text-shimmer" - -export type TriggerTitle = { - title: string - titleClass?: string - subtitle?: string - subtitleClass?: string - args?: string[] - argsClass?: string - action?: JSX.Element -} - -const isTriggerTitle = (val: any): val is TriggerTitle => { - return ( - typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node)) - ) -} - -export interface BasicToolProps { - icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element - children?: JSX.Element - status?: string - hideDetails?: boolean - defaultOpen?: boolean - forceOpen?: boolean - defer?: boolean - locked?: boolean - animated?: boolean - onSubtitleClick?: () => void -} - -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } - -export function BasicTool(props: BasicToolProps) { - const [state, setState] = createStore({ - open: props.defaultOpen ?? false, - ready: props.defaultOpen ?? false, - }) - const open = () => state.open - const ready = () => state.ready - const pending = () => props.status === "pending" || props.status === "running" - - let frame: number | undefined - - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - onCleanup(cancel) - - createEffect(() => { - if (props.forceOpen) setState("open", true) - }) - - createEffect( - on( - open, - (value) => { - if (!props.defer) return - if (!value) { - cancel() - setState("ready", false) - return - } - - cancel() - frame = requestAnimationFrame(() => { - frame = undefined - if (!open()) return - setState("ready", true) - }) - }, - { defer: true }, - ), - ) - - // Animated height for collapsible open/close - let contentRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined - const initialOpen = open() - - createEffect( - on( - open, - (isOpen) => { - if (!props.animated || !contentRef) return - heightAnim?.stop() - if (isOpen) { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "auto" }, SPRING) - heightAnim.finished.then(() => { - if (!contentRef || !open()) return - contentRef.style.overflow = "visible" - contentRef.style.height = "auto" - }) - } else { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "0px" }, SPRING) - } - }, - { defer: true }, - ), - ) - - onCleanup(() => { - heightAnim?.stop() - }) - - const handleOpenChange = (value: boolean) => { - if (pending()) return - if (props.locked && !value) return - setState("open", value) - } - - return ( - - -
-
-
- - - {(trigger) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- - {trigger().action} - -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
-
- -
- {props.children} -
-
- - - {props.children} - - -
- ) -} - -function label(input: Record | undefined) { - const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"] - return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0) -} - -function args(input: Record | undefined) { - if (!input) return [] - const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"]) - return Object.entries(input) - .filter(([key]) => !skip.has(key)) - .flatMap(([key, value]) => { - if (typeof value === "string") return [`${key}=${value}`] - if (typeof value === "number") return [`${key}=${value}`] - if (typeof value === "boolean") return [`${key}=${value}`] - return [] - }) - .slice(0, 3) -} - -export function GenericTool(props: { - tool: string - status?: string - hideDetails?: boolean - input?: Record -}) { - const i18n = useI18n() - - return ( - - ) -} diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 923b2bab374b..fb7e58a69ebc 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -10,6 +10,12 @@ cursor: default; outline: none; white-space: nowrap; + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 100ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -22,15 +28,18 @@ &:hover:not(:disabled) { background-color: var(--icon-strong-hover); + box-shadow: var(--shadow-sm); } &:focus:not(:disabled) { background-color: var(--icon-strong-focus); } &:active:not(:disabled) { background-color: var(--icon-strong-active); + transform: scale(0.98); } &:disabled { background-color: var(--icon-strong-disabled); + opacity: 0.6; [data-slot="icon-svg"] { color: var(--icon-invert-base); @@ -45,20 +54,27 @@ [data-slot="icon-svg"] { color: var(--icon-base); + transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1); } &:hover:not(:disabled) { background-color: var(--surface-base-hover); + + [data-slot="icon-svg"] { + color: var(--icon-hover); + } } &:focus-visible:not(:disabled) { background-color: var(--surface-base-hover); } &:active:not(:disabled) { background-color: var(--surface-base-active); + transform: scale(0.97); } &:disabled { color: var(--text-weak); cursor: not-allowed; + opacity: 0.5; [data-slot="icon-svg"] { color: var(--icon-disabled); @@ -80,6 +96,7 @@ &:hover:not(:disabled) { background-color: var(--button-secondary-hover); + box-shadow: var(--shadow-xs-border-hover); } &:focus:not(:disabled) { background-color: var(--button-secondary-base); @@ -93,12 +110,14 @@ } &:active:not(:disabled) { background-color: var(--button-secondary-base); + transform: scale(0.98); } &:disabled { border-color: var(--border-disabled); background-color: var(--surface-disabled); color: var(--text-weak); cursor: not-allowed; + opacity: 0.6; } [data-slot="icon-svg"] { diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css deleted file mode 100644 index 2d482dba7a82..000000000000 --- a/packages/ui/src/components/card.css +++ /dev/null @@ -1,94 +0,0 @@ -[data-component="card"] { - --card-pad-y: 10px; - --card-pad-r: 12px; - --card-pad-l: 10px; - - width: 100%; - display: flex; - flex-direction: column; - position: relative; - background: transparent; - border: none; - border-radius: var(--radius-md); - padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - - --card-gap: 8px; - --card-icon: 16px; - --card-indent: 0px; - --card-line-pad: 8px; - - --card-accent: var(--icon-active); - - &:has([data-slot="card-title"]) { - gap: 8px; - } - - &:has([data-slot="card-title-icon"]) { - --card-indent: calc(var(--card-icon) + var(--card-gap)); - } - - &::before { - content: ""; - position: absolute; - left: 0; - top: var(--card-line-pad); - bottom: var(--card-line-pad); - width: 2px; - border-radius: 2px; - background-color: var(--card-accent); - } - - :where([data-card="title"], [data-slot="card-title"]) { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - } - - :where([data-slot="card-title"]) { - display: flex; - align-items: center; - gap: var(--card-gap); - } - - :where([data-slot="card-title"]) [data-component="icon"] { - color: var(--card-accent); - } - - :where([data-slot="card-title-icon"]) { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--card-icon); - height: var(--card-icon); - flex: 0 0 auto; - } - - :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { - color: var(--text-weak); - } - - :where([data-slot="card-title-icon"]) - [data-slot="icon-svg"] - :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] { - stroke-width: 1.5px !important; - } - - :where([data-card="description"], [data-slot="card-description"]) { - color: var(--text-base); - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - } - - :where([data-card="actions"], [data-slot="card-actions"]) { - padding-left: var(--card-indent); - } -} diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1e74763ae2d8..0ab559499673 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -4,7 +4,9 @@ position: fixed; inset: 0; z-index: 50; - background-color: hsl(from var(--background-base) h s l / 0.2); + background-color: hsl(from var(--background-base) h s l / 0.35); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } [data-component="dialog"] { @@ -50,7 +52,9 @@ border-radius: var(--radius-xl); background: var(--surface-raised-stronger-non-alpha); background-clip: padding-box; - box-shadow: var(--shadow-lg-border-base); + box-shadow: + var(--shadow-lg-border-base), + 0 0 0 1px var(--border-weaker-base); [data-slot="dialog-header"] { display: flex; @@ -136,19 +140,29 @@ } [data-component="dialog"][data-transition] [data-slot="dialog-content"] { - animation: contentHide 100ms ease-in forwards; + animation: contentHide 120ms cubic-bezier(0.4, 0, 1, 1) forwards; &[data-expanded] { - animation: contentShow 150ms ease-out; + animation: contentShow 250ms cubic-bezier(0.16, 1, 0.3, 1); + } +} + +[data-component="dialog"][data-transition] [data-component="dialog-overlay"] { + animation: overlayHide 150ms ease-in forwards; + + &[data-expanded] { + animation: overlayShow 200ms ease-out; } } @keyframes overlayShow { from { opacity: 0; + backdrop-filter: blur(0); } to { opacity: 1; + backdrop-filter: blur(4px); } } @keyframes overlayHide { @@ -162,20 +176,20 @@ @keyframes contentShow { from { opacity: 0; - transform: scale(0.98); + transform: scale(0.96) translateY(4px); } to { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } } @keyframes contentHide { from { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } to { opacity: 0; - transform: scale(0.98); + transform: scale(0.96) translateY(4px); } } diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css index fd3430446405..b2ee97fa4fe8 100644 --- a/packages/ui/src/components/dock-surface.css +++ b/packages/ui/src/components/dock-surface.css @@ -1,10 +1,20 @@ [data-dock-surface="shell"] { background-color: var(--surface-raised-stronger-non-alpha); - box-shadow: var(--shadow-xs-border); + box-shadow: + var(--shadow-xs-border), + 0 -4px 16px -4px hsl(0 0% 0% / 0.06); position: relative; z-index: 10; - border-radius: 12px; + border-radius: 14px; overflow: clip; + transition: box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-dock-surface="shell"]:focus-within { + box-shadow: + var(--shadow-xs-border), + 0 -4px 20px -4px hsl(0 0% 0% / 0.08), + 0 0 0 1px var(--border-interactive-base); } [data-dock-surface="tray"] { @@ -12,8 +22,9 @@ border: 1px solid var(--border-weak-base); position: relative; z-index: 0; - border-radius: 12px; + border-radius: 14px; overflow: clip; + transition: border-color 200ms cubic-bezier(0.4, 0, 0.2, 1); } [data-dock-surface="tray"][data-dock-attach="top"] { diff --git a/packages/ui/src/components/file-icons/sprite.svg b/packages/ui/src/components/file-icons/sprite.svg index 619b2b58cace..622bea89a301 100644 --- a/packages/ui/src/components/file-icons/sprite.svg +++ b/packages/ui/src/components/file-icons/sprite.svg @@ -1,11707 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css index 7a47270fe9ae..8a94ffac72a3 100644 --- a/packages/ui/src/components/icon-button.css +++ b/packages/ui/src/components/icon-button.css @@ -7,6 +7,10 @@ user-select: none; aspect-ratio: 1; flex-shrink: 0; + transition: + background-color 120ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1), + transform 80ms cubic-bezier(0.4, 0, 0.2, 1); &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -77,37 +81,41 @@ &[data-variant="ghost"] { background-color: transparent; - /* color: var(--icon-base); */ [data-slot="icon-svg"] { color: var(--icon-base); + transition: color 120ms cubic-bezier(0.4, 0, 0.2, 1); } &:hover:not(:disabled) { background-color: var(--surface-base-hover); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-hover); */ - /* } */ + [data-slot="icon-svg"] { + color: var(--icon-hover); + } } &:focus-visible:not(:disabled) { background-color: var(--surface-base-hover); } &:active:not(:disabled) { background-color: var(--surface-base-active); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-active); */ - /* } */ + transform: scale(0.92); + + [data-slot="icon-svg"] { + color: var(--icon-active); + } } &:selected:not(:disabled) { background-color: var(--surface-base-active); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-selected); */ - /* } */ + + [data-slot="icon-svg"] { + color: var(--icon-selected); + } } &:disabled { color: var(--icon-invert-base); cursor: not-allowed; + opacity: 0.5; } } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index b12d304151df..011e54b69b67 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -237,6 +237,9 @@ align-items: center; color: var(--text-strong); scroll-margin-top: 28px; + transition: + background-color 100ms cubic-bezier(0.4, 0, 0.2, 1), + border-radius 100ms cubic-bezier(0.4, 0, 0.2, 1); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d6c..e93354c71b89 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -16,18 +16,44 @@ margin-bottom: 0; } - /* Headings: Same size, distinguished by color and spacing */ - h1, - h2, - h3, + /* Headings: Distinct size hierarchy for visual rhythm */ + h1 { + font-size: 1.5em; + color: var(--text-strong); + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.75rem; + line-height: 1.3; + letter-spacing: -0.02em; + } + + h2 { + font-size: 1.25em; + color: var(--text-strong); + font-weight: 600; + margin-top: 1.75rem; + margin-bottom: 0.5rem; + line-height: 1.35; + letter-spacing: -0.01em; + } + + h3 { + font-size: 1.1em; + color: var(--text-strong); + font-weight: var(--font-weight-medium); + margin-top: 1.5rem; + margin-bottom: 0.5rem; + line-height: var(--line-height-large); + } + h4, h5, h6 { font-size: var(--font-size-base); color: var(--text-strong); font-weight: var(--font-weight-medium); - margin-top: 2rem; - margin-bottom: 0.75rem; + margin-top: 1.25rem; + margin-bottom: 0.5rem; line-height: var(--line-height-large); } @@ -35,7 +61,7 @@ strong, b { color: var(--text-strong); - font-weight: var(--font-weight-medium); + font-weight: 600; } /* Paragraphs */ @@ -48,11 +74,13 @@ color: var(--text-interactive-base); text-decoration: none; font-weight: inherit; + transition: color 150ms ease; } a:hover { text-decoration: underline; - text-underline-offset: 2px; + text-underline-offset: 3px; + text-decoration-thickness: 1.5px; } /* Lists */ @@ -89,7 +117,8 @@ } li::marker { - color: var(--text-weak); + color: var(--text-interactive-base); + font-weight: var(--font-weight-medium); } /* Nested lists spacing */ @@ -106,11 +135,13 @@ /* Blockquotes */ blockquote { - border-left: 2px solid var(--border-weak-base); + border-left: 3px solid var(--border-interactive-base); margin: 1.5rem 0; - padding-left: 0.5rem; - color: var(--text-weak); + padding: 0.5rem 0.75rem; + color: var(--text-base); font-style: normal; + background: var(--surface-base); + border-radius: 0 var(--radius-md) var(--radius-md) 0; } /* Horizontal Rule - Invisible spacing only */ @@ -122,9 +153,11 @@ .shiki { font-size: 13px; - padding: 8px 12px; - border-radius: 6px; - border: 0.5px solid var(--border-weak-base); + padding: 12px 16px; + border-radius: 8px; + border: 1px solid var(--border-weaker-base); + background: var(--surface-inset-base) !important; + box-shadow: inset 0 1px 3px 0 hsl(0 0% 0% / 0.04); } [data-component="markdown-code"] { @@ -216,13 +249,12 @@ font-feature-settings: var(--font-family-mono--font-feature-settings); color: var(--syntax-string); font-weight: var(--font-weight-medium); - /* font-size: 13px; */ - - /* padding: 2px 2px; */ - /* margin: 0 1.5px; */ - /* border-radius: 2px; */ - /* background: var(--surface-base); */ - /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ + font-size: 0.9em; + padding: 2px 5px; + margin: 0 1px; + border-radius: 4px; + background: var(--surface-base); + box-shadow: 0 0 0 0.5px var(--border-weaker-base); } /* Tables */ @@ -247,7 +279,12 @@ th { color: var(--text-strong); font-weight: var(--font-weight-medium); - border-bottom: 1px solid var(--border-weak-base); + border-bottom: 2px solid var(--border-weak-base); + background: var(--surface-base); + } + + tr:hover td { + background: var(--surface-base); } /* Images */ diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index aa685392a909..954a2d44cf18 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -130,10 +130,10 @@ white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--surface-base); - border: 1px solid var(--border-weak-base); - padding: 8px 12px; - border-radius: 6px; + background: var(--surface-interactive-weak); + border: 1px solid var(--border-interactive-base); + padding: 10px 14px; + border-radius: 16px 16px 4px 16px; [data-highlight="file"] { color: var(--syntax-property); diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9b8..c09158acf559 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -6,12 +6,12 @@ z-index: 50; min-width: 200px; max-width: 320px; - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background-color: var(--surface-raised-stronger-non-alpha); border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; - box-shadow: var(--shadow-md); + box-shadow: var(--shadow-lg); transform-origin: var(--kb-popover-content-transform-origin); @@ -78,21 +78,21 @@ @keyframes popover-open { from { opacity: 0; - transform: scale(0.96); + transform: scale(0.95) translateY(2px); } to { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } } @keyframes popover-close { from { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } to { opacity: 0; - transform: scale(0.96); + transform: scale(0.95) translateY(2px); } } diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index a0214b40d0a3..934fd4527d57 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -1,1121 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 26d918050d7f..84dff76388d2 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -26,7 +26,7 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 18px; + gap: 24px; overflow-anchor: none; } @@ -35,6 +35,12 @@ width: 100%; min-width: 0; max-width: 100%; + padding: 16px 0; + border-bottom: 1px solid var(--border-weaker-base); + + &:last-child { + border-bottom: none; + } } [data-slot="session-turn-compaction"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx deleted file mode 100644 index f7ba20af5796..000000000000 --- a/packages/ui/src/components/session-turn.tsx +++ /dev/null @@ -1,540 +0,0 @@ -import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" -import type { SessionStatus } from "@opencode-ai/sdk/v2" -import { useData } from "../context" -import { useFileComponent } from "../context/file" - -import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { Dynamic } from "solid-js/web" -import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part" -import { Card } from "./card" -import { Accordion } from "./accordion" -import { StickyAccordionHeader } from "./sticky-accordion-header" -import { Collapsible } from "./collapsible" -import { DiffChanges } from "./diff-changes" -import { Icon } from "./icon" -import { TextShimmer } from "./text-shimmer" -import { SessionRetry } from "./session-retry" -import { TextReveal } from "./text-reveal" -import { createAutoScroll } from "../hooks" -import { useI18n } from "../context/i18n" - -function record(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function unwrap(message: string) { - const text = message.replace(/^Error:\s*/, "").trim() - - const parse = (value: string) => { - try { - return JSON.parse(value) as unknown - } catch { - return undefined - } - } - - const read = (value: string) => { - const first = parse(value) - if (typeof first !== "string") return first - return parse(first.trim()) - } - - let json = read(text) - - if (json === undefined) { - const start = text.indexOf("{") - const end = text.lastIndexOf("}") - if (start !== -1 && end > start) { - json = read(text.slice(start, end + 1)) - } - } - - if (!record(json)) return message - - const err = record(json.error) ? json.error : undefined - if (err) { - const type = typeof err.type === "string" ? err.type : undefined - const msg = typeof err.message === "string" ? err.message : undefined - if (type && msg) return `${type}: ${msg}` - if (msg) return msg - if (type) return type - const code = typeof err.code === "string" ? err.code : undefined - if (code) return code - } - - const msg = typeof json.message === "string" ? json.message : undefined - if (msg) return msg - - const reason = typeof json.error === "string" ? json.error : undefined - if (reason) return reason - - return message -} - -function same(a: readonly T[], b: readonly T[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} - -const hidden = new Set(["todowrite", "todoread"]) - -function partState(part: PartType, showReasoningSummaries: boolean) { - if (part.type === "tool") { - if (hidden.has(part.tool)) return - if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return - return "visible" as const - } - if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined - if (part.type === "reasoning") { - if (showReasoningSummaries && part.text?.trim()) return "visible" as const - return - } - if (PART_MAPPING[part.type]) return "visible" as const - return -} - -function clean(value: string) { - return value - .replace(/`([^`]+)`/g, "$1") - .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") - .replace(/[*_~]+/g, "") - .trim() -} - -function heading(text: string) { - const markdown = text.replace(/\r\n?/g, "\n") - - const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) - if (html?.[1]) { - const value = clean(html[1].replace(/<[^>]+>/g, " ")) - if (value) return value - } - - const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) - if (atx?.[1]) { - const value = clean(atx[1]) - if (value) return value - } - - const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) - if (setext?.[1]) { - const value = clean(setext[1]) - if (value) return value - } - - const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) - if (strong?.[1]) { - const value = clean(strong[1]) - if (value) return value - } -} - -export function SessionTurn( - props: ParentProps<{ - sessionID: string - messageID: string - messages?: MessageType[] - actions?: UserActions - showReasoningSummaries?: boolean - shellToolDefaultOpen?: boolean - editToolDefaultOpen?: boolean - active?: boolean - status?: SessionStatus - onUserInteracted?: () => void - classes?: { - root?: string - content?: string - container?: string - } - }>, -) { - const data = useData() - const i18n = useI18n() - const fileComponent = useFileComponent() - - const emptyMessages: MessageType[] = [] - const emptyParts: PartType[] = [] - const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: FileDiff[] = [] - const idle = { type: "idle" as const } - - const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) - - const messageIndex = createMemo(() => { - const messages = allMessages() ?? emptyMessages - const result = Binary.search(messages, props.messageID, (m) => m.id) - - const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID) - if (index < 0) return -1 - - const msg = messages[index] - if (!msg || msg.role !== "user") return -1 - - return index - }) - - const message = createMemo(() => { - const index = messageIndex() - if (index < 0) return undefined - - const messages = allMessages() ?? emptyMessages - const msg = messages[index] - if (!msg || msg.role !== "user") return undefined - - return msg - }) - - const pending = createMemo(() => { - if (typeof props.active === "boolean") return - const messages = allMessages() ?? emptyMessages - return messages.findLast( - (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", - ) - }) - - const pendingUser = createMemo(() => { - const item = pending() - if (!item?.parentID) return - const messages = allMessages() ?? emptyMessages - const result = Binary.search(messages, item.parentID, (m) => m.id) - const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) - if (!msg || msg.role !== "user") return - return msg - }) - - const active = createMemo(() => { - if (typeof props.active === "boolean") return props.active - const msg = message() - const parent = pendingUser() - if (!msg || !parent) return false - return parent.id === msg.id - }) - - const parts = createMemo(() => { - const msg = message() - if (!msg) return emptyParts - return list(data.store.part?.[msg.id], emptyParts) - }) - - const compaction = createMemo(() => parts().find((part) => part.type === "compaction")) - - const diffs = createMemo(() => { - const files = message()?.summary?.diffs - if (!files?.length) return emptyDiffs - - const seen = new Set() - return files - .reduceRight((result, diff) => { - if (seen.has(diff.file)) return result - seen.add(diff.file) - result.push(diff) - return result - }, []) - .reverse() - }) - const edited = createMemo(() => diffs().length) - const [state, setState] = createStore({ - open: false, - expanded: [] as string[], - }) - const open = () => state.open - const expanded = () => state.expanded - - createEffect( - on( - open, - (value, prev) => { - if (!value && prev) setState("expanded", []) - }, - { defer: true }, - ), - ) - - const assistantMessages = createMemo( - () => { - const msg = message() - if (!msg) return emptyAssistant - - const messages = allMessages() ?? emptyMessages - const index = messageIndex() - if (index < 0) return emptyAssistant - - const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { - const item = messages[i] - if (!item) continue - if (item.role === "user") break - if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) - } - return result - }, - emptyAssistant, - { equals: same }, - ) - - const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) - const divider = createMemo(() => { - if (compaction()) return i18n.t("ui.messagePart.compaction") - if (interrupted()) return i18n.t("ui.message.interrupted") - return "" - }) - const error = createMemo( - () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, - ) - const showAssistantCopyPartID = createMemo(() => { - const messages = assistantMessages() - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (!message) continue - - const parts = list(data.store.part?.[message.id], emptyParts) - for (let j = parts.length - 1; j >= 0; j--) { - const part = parts[j] - if (!part || part.type !== "text" || !part.text?.trim()) continue - return part.id - } - } - - return undefined - }) - const errorText = createMemo(() => { - const msg = error()?.data?.message - if (typeof msg === "string") return unwrap(msg) - if (msg === undefined || msg === null) return "" - return unwrap(String(msg)) - }) - - const status = createMemo(() => { - if (props.status !== undefined) return props.status - if (typeof props.active === "boolean" && !props.active) return idle - return data.store.session_status[props.sessionID] ?? idle - }) - const working = createMemo(() => status().type !== "idle" && active()) - const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - - const assistantCopyPartID = createMemo(() => { - if (working()) return null - return showAssistantCopyPartID() ?? null - }) - const turnDurationMs = createMemo(() => { - const start = message()?.time.created - if (typeof start !== "number") return undefined - - const end = assistantMessages().reduce((max, item) => { - const completed = item.time.completed - if (typeof completed !== "number") return max - if (max === undefined) return completed - return Math.max(max, completed) - }, undefined) - - if (typeof end !== "number") return undefined - if (end < start) return undefined - return end - start - }) - const assistantDerived = createMemo(() => { - let visible = 0 - let tail: "text" | "other" | undefined - let reason: string | undefined - const show = showReasoningSummaries() - for (const message of assistantMessages()) { - for (const part of list(data.store.part?.[message.id], emptyParts)) { - if (partState(part, show) === "visible") { - visible++ - tail = part.type === "text" ? "text" : "other" - } - if (part.type === "reasoning" && part.text) { - const h = heading(part.text) - if (h) reason = h - } - } - } - return { visible, tail, reason } - }) - const assistantVisible = createMemo(() => assistantDerived().visible) - const assistantTailVisible = createMemo(() => assistantDerived().tail) - const reasoningHeading = createMemo(() => assistantDerived().reason) - const showThinking = createMemo(() => { - if (!working() || !!error()) return false - if (status().type === "retry") return false - if (showReasoningSummaries()) return assistantVisible() === 0 - return true - }) - - const autoScroll = createAutoScroll({ - working, - onUserInteracted: props.onUserInteracted, - overflowAnchor: "dynamic", - }) - - return ( -
-
-
- -
-
- -
- -
- -
-
- 0}> -
- -
-
- -
- - - - -
-
- - 0 && !working()}> -
- setState("open", value)} variant="ghost"> - -
-
- {i18n.t("ui.sessionReview.change.modified")} - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - -
-
-
-
- - -
- - setState("expanded", Array.isArray(value) ? value : value ? [value] : []) - } - > - - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - - - -
- - - - {`\u202A${getDirectory(diff.file)}\u202C`} - - - {getFilename(diff.file)} - -
- - - - - - -
-
-
-
- - -
- -
-
-
-
- ) - }} -
-
-
-
-
-
-
-
- - - {errorText()} - - -
-
- {props.children} -
-
-
- ) -} diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 036533c10fb8..ca1c75864f9d 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -48,6 +48,10 @@ align-items: center; gap: 12px; color: var(--text-base); + transition: + color 150ms cubic-bezier(0.4, 0, 0.2, 1), + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index f02c2ca63921..d243f734c27a 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -18,18 +18,18 @@ [data-component="tooltip"] { z-index: 1000; max-width: 320px; - border-radius: var(--radius-sm); + border-radius: var(--radius-md); background-color: var(--surface-float-base); color: var(--text-invert-strong); background: var(--surface-float-base); - padding: 2px 8px; + padding: 4px 10px; border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); box-shadow: var(--shadow-md); pointer-events: none !important; - /* transition: all 150ms ease-out; */ - /* transform: translate3d(0, 0, 0); */ - /* transform-origin: var(--kb-tooltip-content-transform-origin); */ + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translate3d(0, 0, 0); + transform-origin: var(--kb-tooltip-content-transform-origin); /* text-12-medium */ font-family: var(--font-family-sans); @@ -41,34 +41,34 @@ &[data-expanded] { opacity: 1; - /* transform: translate3d(0, 0, 0); */ + transform: translate3d(0, 0, 0); } &[data-closed]:not([data-force-open="true"]) { opacity: 0; } - /* &[data-placement="top"] { */ - /* &[data-closed] { */ - /* transform: translate3d(0, 4px, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="bottom"] { */ - /* &[data-closed] { */ - /* transform: translate3d(0, -4px, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="left"] { */ - /* &[data-closed] { */ - /* transform: translate3d(4px, 0, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="right"] { */ - /* &[data-closed] { */ - /* transform: translate3d(-4px, 0, 0); */ - /* } */ - /* } */ + &[data-placement="top"] { + &[data-closed] { + transform: translate3d(0, 4px, 0); + } + } + + &[data-placement="bottom"] { + &[data-closed] { + transform: translate3d(0, -4px, 0); + } + } + + &[data-placement="left"] { + &[data-closed] { + transform: translate3d(4px, 0, 0); + } + } + + &[data-placement="right"] { + &[data-closed] { + transform: translate3d(-4px, 0, 0); + } + } } diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index f9a09df379e1..09146cb79972 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -1,8 +1,30 @@ +/* ============================================ + OpenCode Animation System + Refined micro-interactions & motion design + ============================================ */ + :root { + /* Animation tokens */ + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); + + /* Duration tokens */ + --duration-instant: 75ms; + --duration-fast: 120ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --duration-slower: 450ms; + + /* Existing pulse animations */ --animate-pulse: pulse-opacity 2s ease-in-out infinite; --animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite; } +/* ---- Pulse animations ---- */ + @keyframes pulse-opacity { 0%, 100% { @@ -33,6 +55,8 @@ } } +/* ---- Entrance animations ---- */ + @keyframes fadeUp { from { opacity: 0; @@ -44,98 +68,145 @@ } } -.fade-up-text { - animation: fadeUp 0.4s ease-out forwards; - opacity: 0; - - &:nth-child(1) { - animation-delay: 0.1s; - } - &:nth-child(2) { - animation-delay: 0.2s; - } - &:nth-child(3) { - animation-delay: 0.3s; - } - &:nth-child(4) { - animation-delay: 0.4s; - } - &:nth-child(5) { - animation-delay: 0.5s; - } - &:nth-child(6) { - animation-delay: 0.6s; - } - &:nth-child(7) { - animation-delay: 0.7s; - } - &:nth-child(8) { - animation-delay: 0.8s; - } - &:nth-child(9) { - animation-delay: 0.9s; - } - &:nth-child(10) { - animation-delay: 1s; - } - &:nth-child(11) { - animation-delay: 1.1s; - } - &:nth-child(12) { - animation-delay: 1.2s; - } - &:nth-child(13) { - animation-delay: 1.3s; +@keyframes fadeIn { + from { + opacity: 0; } - &:nth-child(14) { - animation-delay: 1.4s; + to { + opacity: 1; } - &:nth-child(15) { - animation-delay: 1.5s; +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.95); } - &:nth-child(16) { - animation-delay: 1.6s; + to { + opacity: 1; + transform: scale(1); } - &:nth-child(17) { - animation-delay: 1.7s; +} + +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(8px); } - &:nth-child(18) { - animation-delay: 1.8s; + to { + opacity: 1; + transform: translateX(0); } - &:nth-child(19) { - animation-delay: 1.9s; +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-8px); } - &:nth-child(20) { - animation-delay: 2s; + to { + opacity: 1; + transform: translateX(0); } - &:nth-child(21) { - animation-delay: 2.1s; +} + +@keyframes slideInFromBottom { + from { + opacity: 0; + transform: translateY(12px); } - &:nth-child(22) { - animation-delay: 2.2s; + to { + opacity: 1; + transform: translateY(0); } - &:nth-child(23) { - animation-delay: 2.3s; +} + +/* ---- Subtle glow for active/focus states ---- */ + +@keyframes subtleGlow { + 0%, + 100% { + box-shadow: 0 0 0 0 transparent; } - &:nth-child(24) { - animation-delay: 2.4s; + 50% { + box-shadow: 0 0 8px 2px color-mix(in srgb, var(--border-selected) 25%, transparent); } - &:nth-child(25) { - animation-delay: 2.5s; +} + +/* ---- Shimmer for loading states ---- */ + +@keyframes shimmer { + 0% { + background-position: -200% 0; } - &:nth-child(26) { - animation-delay: 2.6s; + 100% { + background-position: 200% 0; } - &:nth-child(27) { - animation-delay: 2.7s; +} + +/* ---- Spin ---- */ + +@keyframes spin { + from { + transform: rotate(0deg); } - &:nth-child(28) { - animation-delay: 2.8s; + to { + transform: rotate(360deg); } - &:nth-child(29) { - animation-delay: 2.9s; +} + +/* ---- Staggered fade-up text ---- */ + +.fade-up-text { + animation: fadeUp 0.4s var(--ease-out-expo) forwards; + opacity: 0; + + &:nth-child(1) { animation-delay: 0.05s; } + &:nth-child(2) { animation-delay: 0.1s; } + &:nth-child(3) { animation-delay: 0.15s; } + &:nth-child(4) { animation-delay: 0.2s; } + &:nth-child(5) { animation-delay: 0.25s; } + &:nth-child(6) { animation-delay: 0.3s; } + &:nth-child(7) { animation-delay: 0.35s; } + &:nth-child(8) { animation-delay: 0.4s; } + &:nth-child(9) { animation-delay: 0.45s; } + &:nth-child(10) { animation-delay: 0.5s; } + &:nth-child(11) { animation-delay: 0.55s; } + &:nth-child(12) { animation-delay: 0.6s; } + &:nth-child(13) { animation-delay: 0.65s; } + &:nth-child(14) { animation-delay: 0.7s; } + &:nth-child(15) { animation-delay: 0.75s; } + &:nth-child(16) { animation-delay: 0.8s; } + &:nth-child(17) { animation-delay: 0.85s; } + &:nth-child(18) { animation-delay: 0.9s; } + &:nth-child(19) { animation-delay: 0.95s; } + &:nth-child(20) { animation-delay: 1s; } + &:nth-child(21) { animation-delay: 1.05s; } + &:nth-child(22) { animation-delay: 1.1s; } + &:nth-child(23) { animation-delay: 1.15s; } + &:nth-child(24) { animation-delay: 1.2s; } + &:nth-child(25) { animation-delay: 1.25s; } + &:nth-child(26) { animation-delay: 1.3s; } + &:nth-child(27) { animation-delay: 1.35s; } + &:nth-child(28) { animation-delay: 1.4s; } + &:nth-child(29) { animation-delay: 1.45s; } + &:nth-child(30) { animation-delay: 1.5s; } +} + +/* ---- Reduced motion preference ---- */ + +@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; } - &:nth-child(30) { - animation-delay: 3s; + + .fade-up-text { + opacity: 1; + animation: none; } } diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index b5604ad61914..78ce094f4b83 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -34,6 +34,10 @@ html, font-feature-settings: var(--font-family-sans--font-feature-settings, normal); /* 5 */ font-variation-settings: var(--font-family-sans--font-variation-settings, normal); /* 6 */ -webkit-tap-highlight-color: transparent; /* 7 */ + -webkit-font-smoothing: antialiased; /* 8 - Crisp font rendering */ + -moz-osx-font-smoothing: grayscale; /* 8 */ + text-rendering: optimizeLegibility; /* 9 - Better kerning */ + scroll-behavior: smooth; /* 10 - Smooth scrolling */ } /* diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 021f959e4cbc..c2ec65d89edd 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -1,6 +1,6 @@ :root { --font-family-sans: "Inter", "Inter Fallback"; - --font-family-sans--font-feature-settings: "ss03" 1; + --font-family-sans--font-feature-settings: "ss01" 1, "ss03" 1, "cv01" 1, "cv02" 1; --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback"; --font-family-mono--font-feature-settings: "ss01" 1; @@ -48,17 +48,24 @@ --radius-xl: 0.625rem; --shadow-xs: - 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.06)), - 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.08)), - 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.1)); + 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.08)), + 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.1)), + 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.12)); + --shadow-sm: + 0 2px 4px -1px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), + 0 1px 2px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.08)); --shadow-md: - 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.1)), - 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.15)), - 0 1px 2px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.15)); + 0 8px 16px -3px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)), + 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), + 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)); --shadow-lg: - 0 16px 48px -6px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), - 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)), - 0 1px 2.5px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)); + 0 20px 56px -8px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.2)), + 0 8px 16px -4px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.12)), + 0 2px 4px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.08)); + --shadow-xl: + 0 28px 72px -12px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.25)), + 0 12px 24px -4px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), + 0 4px 8px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.1)); --shadow-xxs-border: 0 0 0 0.5px var(--border-weak-base, rgba(0, 0, 0, 0.07)); --shadow-xs-border: 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 3a05a9515fa0..7e9786a8dd80 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -4,25 +4,85 @@ [data-popper-positioner] { pointer-events: none; } +} + +/* ---- Text selection styling ---- */ + +::selection { + background-color: color-mix(in srgb, var(--border-selected, #034cff) 30%, transparent); + color: var(--text-strong); +} + +/* ---- Global transition defaults for interactive elements ---- */ - /* ::selection { */ - /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */ - /* background-color: var(--color-primary); */ - /* color: var(--color-background); */ - /* } */ +button, +a, +[role="button"], +[data-component="button"], +[data-component="icon-button"], +[data-component="card"], +[data-component="list-item"], +[data-component="tab"], +input, +select, +textarea { + transition-property: background-color, border-color, color, box-shadow, opacity, transform; + transition-duration: var(--duration-fast, 120ms); + transition-timing-function: var(--ease-smooth, cubic-bezier(0.4, 0, 0.2, 1)); } +/* ---- Focus ring utility ---- */ + +:focus-visible { + outline: 2px solid var(--border-selected, #034cff); + outline-offset: 1px; + border-radius: var(--radius-sm); +} + +/* Suppress focus ring inside specific components that handle their own */ +[data-component="dialog"] :focus-visible, +[data-component="text-field"] :focus-visible, +[contenteditable]:focus-visible { + outline: none; +} + +/* ---- Scrollbar styling ---- */ + .no-scrollbar { &::-webkit-scrollbar { display: none; } - /* Hide scrollbar for IE, Edge and Firefox */ & { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + scrollbar-width: none; } } +/* Thin scrollbar for scroll areas */ +[data-component="scroll-view"], +.thin-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--surface-weak, #ccc) transparent; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: var(--surface-weak, #ccc); + border-radius: 3px; + + &:hover { + background-color: var(--surface-weaker, #aaa); + } + } +} + +/* ---- Screen reader only ---- */ + .sr-only { position: absolute; width: 1px; @@ -35,6 +95,8 @@ border-width: 0; } +/* ---- Truncation utilities ---- */ + .truncate-start { text-overflow: ellipsis; overflow: hidden; @@ -43,12 +105,14 @@ text-align: left; } +/* ---- Typography scale ---- */ + .text-12-regular { font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -57,7 +121,7 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -67,7 +131,7 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -76,7 +140,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-x-large); /* 171.429% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-normal); } @@ -85,7 +149,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 171.429% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -95,7 +159,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 171.429% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -104,7 +168,7 @@ font-size: var(--font-size-large); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-x-large); /* 150% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-tight); } @@ -113,6 +177,6 @@ font-size: var(--font-size-x-large); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-x-large); /* 120% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-tightest); } diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts deleted file mode 100644 index c14198955812..000000000000 --- a/packages/ui/src/theme/default-themes.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { DesktopTheme } from "./types" -import oc2ThemeJson from "./themes/oc-2.json" -import amoledThemeJson from "./themes/amoled.json" -import auraThemeJson from "./themes/aura.json" -import ayuThemeJson from "./themes/ayu.json" -import carbonfoxThemeJson from "./themes/carbonfox.json" -import catppuccinThemeJson from "./themes/catppuccin.json" -import catppuccinFrappeThemeJson from "./themes/catppuccin-frappe.json" -import catppuccinMacchiatoThemeJson from "./themes/catppuccin-macchiato.json" -import cobalt2ThemeJson from "./themes/cobalt2.json" -import cursorThemeJson from "./themes/cursor.json" -import draculaThemeJson from "./themes/dracula.json" -import everforestThemeJson from "./themes/everforest.json" -import flexokiThemeJson from "./themes/flexoki.json" -import githubThemeJson from "./themes/github.json" -import gruvboxThemeJson from "./themes/gruvbox.json" -import kanagawaThemeJson from "./themes/kanagawa.json" -import lucentOrngThemeJson from "./themes/lucent-orng.json" -import materialThemeJson from "./themes/material.json" -import matrixThemeJson from "./themes/matrix.json" -import mercuryThemeJson from "./themes/mercury.json" -import monokaiThemeJson from "./themes/monokai.json" -import nightowlThemeJson from "./themes/nightowl.json" -import nordThemeJson from "./themes/nord.json" -import oneDarkThemeJson from "./themes/one-dark.json" -import oneDarkProThemeJson from "./themes/onedarkpro.json" -import opencodeThemeJson from "./themes/opencode.json" -import orngThemeJson from "./themes/orng.json" -import osakaJadeThemeJson from "./themes/osaka-jade.json" -import palenightThemeJson from "./themes/palenight.json" -import rosepineThemeJson from "./themes/rosepine.json" -import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json" -import solarizedThemeJson from "./themes/solarized.json" -import synthwave84ThemeJson from "./themes/synthwave84.json" -import tokyonightThemeJson from "./themes/tokyonight.json" -import vercelThemeJson from "./themes/vercel.json" -import vesperThemeJson from "./themes/vesper.json" -import zenburnThemeJson from "./themes/zenburn.json" - -export const oc2Theme = oc2ThemeJson as DesktopTheme -export const amoledTheme = amoledThemeJson as DesktopTheme -export const auraTheme = auraThemeJson as DesktopTheme -export const ayuTheme = ayuThemeJson as DesktopTheme -export const carbonfoxTheme = carbonfoxThemeJson as DesktopTheme -export const catppuccinTheme = catppuccinThemeJson as DesktopTheme -export const catppuccinFrappeTheme = catppuccinFrappeThemeJson as DesktopTheme -export const catppuccinMacchiatoTheme = catppuccinMacchiatoThemeJson as DesktopTheme -export const cobalt2Theme = cobalt2ThemeJson as DesktopTheme -export const cursorTheme = cursorThemeJson as DesktopTheme -export const draculaTheme = draculaThemeJson as DesktopTheme -export const everforestTheme = everforestThemeJson as DesktopTheme -export const flexokiTheme = flexokiThemeJson as DesktopTheme -export const githubTheme = githubThemeJson as DesktopTheme -export const gruvboxTheme = gruvboxThemeJson as DesktopTheme -export const kanagawaTheme = kanagawaThemeJson as DesktopTheme -export const lucentOrngTheme = lucentOrngThemeJson as DesktopTheme -export const materialTheme = materialThemeJson as DesktopTheme -export const matrixTheme = matrixThemeJson as DesktopTheme -export const mercuryTheme = mercuryThemeJson as DesktopTheme -export const monokaiTheme = monokaiThemeJson as DesktopTheme -export const nightowlTheme = nightowlThemeJson as DesktopTheme -export const nordTheme = nordThemeJson as DesktopTheme -export const oneDarkTheme = oneDarkThemeJson as DesktopTheme -export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme -export const opencodeTheme = opencodeThemeJson as DesktopTheme -export const orngTheme = orngThemeJson as DesktopTheme -export const osakaJadeTheme = osakaJadeThemeJson as DesktopTheme -export const palenightTheme = palenightThemeJson as DesktopTheme -export const rosepineTheme = rosepineThemeJson as DesktopTheme -export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme -export const solarizedTheme = solarizedThemeJson as DesktopTheme -export const synthwave84Theme = synthwave84ThemeJson as DesktopTheme -export const tokyonightTheme = tokyonightThemeJson as DesktopTheme -export const vercelTheme = vercelThemeJson as DesktopTheme -export const vesperTheme = vesperThemeJson as DesktopTheme -export const zenburnTheme = zenburnThemeJson as DesktopTheme - -export const DEFAULT_THEMES: Record = { - "oc-2": oc2Theme, - amoled: amoledTheme, - aura: auraTheme, - ayu: ayuTheme, - carbonfox: carbonfoxTheme, - catppuccin: catppuccinTheme, - "catppuccin-frappe": catppuccinFrappeTheme, - "catppuccin-macchiato": catppuccinMacchiatoTheme, - cobalt2: cobalt2Theme, - cursor: cursorTheme, - dracula: draculaTheme, - everforest: everforestTheme, - flexoki: flexokiTheme, - github: githubTheme, - gruvbox: gruvboxTheme, - kanagawa: kanagawaTheme, - "lucent-orng": lucentOrngTheme, - material: materialTheme, - matrix: matrixTheme, - mercury: mercuryTheme, - monokai: monokaiTheme, - nightowl: nightowlTheme, - nord: nordTheme, - "one-dark": oneDarkTheme, - onedarkpro: oneDarkProTheme, - opencode: opencodeTheme, - orng: orngTheme, - "osaka-jade": osakaJadeTheme, - palenight: palenightTheme, - rosepine: rosepineTheme, - shadesofpurple: shadesOfPurpleTheme, - solarized: solarizedTheme, - synthwave84: synthwave84Theme, - tokyonight: tokyonightTheme, - vercel: vercelTheme, - vesper: vesperTheme, - zenburn: zenburnTheme, -} diff --git a/packages/ui/src/theme/themes/everforest.json b/packages/ui/src/theme/themes/everforest.json deleted file mode 100644 index 21c04c8ab38c..000000000000 --- a/packages/ui/src/theme/themes/everforest.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Everforest", - "id": "everforest", - "light": { - "palette": { - "neutral": "#fdf6e3", - "ink": "#5c6a72", - "primary": "#8da101", - "accent": "#df69ba", - "success": "#8da101", - "warning": "#f57d26", - "error": "#f85552", - "info": "#35a77c", - "diffAdd": "#4db380", - "diffDelete": "#f52a65" - }, - "overrides": { - "text-weak": "#a6b0a0", - "syntax-comment": "#a6b0a0", - "syntax-keyword": "#df69ba", - "syntax-string": "#8da101", - "syntax-primitive": "#8da101", - "syntax-variable": "#f85552", - "syntax-property": "#35a77c", - "syntax-type": "#dfa000", - "syntax-constant": "#f57d26", - "syntax-operator": "#35a77c", - "syntax-punctuation": "#5c6a72", - "syntax-object": "#f85552", - "markdown-heading": "#df69ba", - "markdown-text": "#5c6a72", - "markdown-link": "#8da101", - "markdown-link-text": "#35a77c", - "markdown-code": "#8da101", - "markdown-block-quote": "#dfa000", - "markdown-emph": "#dfa000", - "markdown-strong": "#f57d26", - "markdown-horizontal-rule": "#a6b0a0", - "markdown-list-item": "#8da101", - "markdown-list-enumeration": "#35a77c", - "markdown-image": "#8da101", - "markdown-image-text": "#35a77c", - "markdown-code-block": "#5c6a72" - } - }, - "dark": { - "palette": { - "neutral": "#2d353b", - "ink": "#d3c6aa", - "primary": "#a7c080", - "accent": "#d699b6", - "success": "#a7c080", - "warning": "#e69875", - "error": "#e67e80", - "info": "#83c092", - "diffAdd": "#b8db87", - "diffDelete": "#e26a75" - }, - "overrides": { - "text-weak": "#7a8478", - "syntax-comment": "#7a8478", - "syntax-keyword": "#d699b6", - "syntax-string": "#a7c080", - "syntax-primitive": "#a7c080", - "syntax-variable": "#e67e80", - "syntax-property": "#83c092", - "syntax-type": "#dbbc7f", - "syntax-constant": "#e69875", - "syntax-operator": "#83c092", - "syntax-punctuation": "#d3c6aa", - "syntax-object": "#e67e80", - "markdown-heading": "#d699b6", - "markdown-text": "#d3c6aa", - "markdown-link": "#a7c080", - "markdown-link-text": "#83c092", - "markdown-code": "#a7c080", - "markdown-block-quote": "#dbbc7f", - "markdown-emph": "#dbbc7f", - "markdown-strong": "#e69875", - "markdown-horizontal-rule": "#7a8478", - "markdown-list-item": "#a7c080", - "markdown-list-enumeration": "#83c092", - "markdown-image": "#a7c080", - "markdown-image-text": "#83c092", - "markdown-code-block": "#d3c6aa" - } - } -} diff --git a/packages/ui/src/theme/themes/kanagawa.json b/packages/ui/src/theme/themes/kanagawa.json deleted file mode 100644 index e1b308c15e38..000000000000 --- a/packages/ui/src/theme/themes/kanagawa.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Kanagawa", - "id": "kanagawa", - "light": { - "palette": { - "neutral": "#F2E9DE", - "ink": "#54433A", - "primary": "#2D4F67", - "accent": "#D27E99", - "success": "#98BB6C", - "warning": "#D7A657", - "error": "#E82424", - "info": "#76946A", - "diffAdd": "#89AF5B", - "diffDelete": "#D61F1F" - }, - "overrides": { - "text-weak": "#9E9389", - "syntax-comment": "#9E9389", - "syntax-keyword": "#957FB8", - "syntax-string": "#98BB6C", - "syntax-primitive": "#2D4F67", - "syntax-variable": "#54433A", - "syntax-property": "#76946A", - "syntax-type": "#C38D9D", - "syntax-constant": "#D7A657", - "syntax-operator": "#D27E99", - "syntax-punctuation": "#54433A", - "syntax-object": "#54433A", - "markdown-heading": "#957FB8", - "markdown-text": "#54433A", - "markdown-link": "#2D4F67", - "markdown-link-text": "#76946A", - "markdown-code": "#98BB6C", - "markdown-block-quote": "#9E9389", - "markdown-emph": "#C38D9D", - "markdown-strong": "#D7A657", - "markdown-horizontal-rule": "#9E9389", - "markdown-list-item": "#2D4F67", - "markdown-list-enumeration": "#76946A", - "markdown-image": "#2D4F67", - "markdown-image-text": "#76946A", - "markdown-code-block": "#54433A" - } - }, - "dark": { - "palette": { - "neutral": "#1F1F28", - "ink": "#DCD7BA", - "primary": "#7E9CD8", - "accent": "#D27E99", - "success": "#98BB6C", - "warning": "#D7A657", - "error": "#E82424", - "info": "#76946A", - "diffAdd": "#A9D977", - "diffDelete": "#F24A4A" - }, - "overrides": { - "text-weak": "#727169", - "syntax-comment": "#727169", - "syntax-keyword": "#957FB8", - "syntax-string": "#98BB6C", - "syntax-primitive": "#7E9CD8", - "syntax-variable": "#DCD7BA", - "syntax-property": "#76946A", - "syntax-type": "#C38D9D", - "syntax-constant": "#D7A657", - "syntax-operator": "#D27E99", - "syntax-punctuation": "#DCD7BA", - "syntax-object": "#DCD7BA", - "markdown-heading": "#957FB8", - "markdown-text": "#DCD7BA", - "markdown-link": "#7E9CD8", - "markdown-link-text": "#76946A", - "markdown-code": "#98BB6C", - "markdown-block-quote": "#727169", - "markdown-emph": "#C38D9D", - "markdown-strong": "#D7A657", - "markdown-horizontal-rule": "#727169", - "markdown-list-item": "#7E9CD8", - "markdown-list-enumeration": "#76946A", - "markdown-image": "#7E9CD8", - "markdown-image-text": "#76946A", - "markdown-code-block": "#DCD7BA" - } - } -} diff --git a/packages/ui/src/theme/themes/midnight.json b/packages/ui/src/theme/themes/midnight.json new file mode 100644 index 000000000000..95059620e973 --- /dev/null +++ b/packages/ui/src/theme/themes/midnight.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://opencode.ai/desktop-theme.json", + "name": "Midnight", + "id": "midnight", + "light": { + "seeds": { + "neutral": "#F8FAFC", + "primary": "#1E293B", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444", + "info": "#3B82F6", + "interactive": "#3B82F6", + "diffAdd": "#22C55E", + "diffDelete": "#EF4444" + }, + "overrides": { + "background-base": "#F8FAFC", + "background-weak": "#F1F5F9", + "background-strong": "#FFFFFF", + "background-stronger": "#FFFFFF", + "border-weak-base": "#E2E8F0", + "border-weak-hover": "#CBD5E1", + "border-weak-active": "#94A3B8", + "border-weak-selected": "#93C5FD", + "border-weak-disabled": "#F1F5F9", + "border-weak-focus": "#93C5FD", + "border-base": "#CBD5E1", + "border-hover": "#94A3B8", + "border-active": "#64748B", + "border-selected": "#3B82F6", + "border-disabled": "#E2E8F0", + "border-focus": "#3B82F6", + "border-strong-base": "#94A3B8", + "border-strong-hover": "#64748B", + "border-strong-active": "#475569", + "border-strong-selected": "#3B82F6", + "border-strong-disabled": "#CBD5E1", + "border-strong-focus": "#3B82F6", + "surface-diff-add-base": "#DCFCE7", + "surface-diff-delete-base": "#FEE2E2", + "surface-diff-hidden-base": "#DBEAFE", + "text-base": "#334155", + "text-weak": "#64748B", + "text-strong": "#0F172A", + "syntax-string": "#059669", + "syntax-primitive": "#DC2626", + "syntax-property": "#7C3AED", + "syntax-type": "#D97706", + "syntax-constant": "#0284C7", + "syntax-info": "#0284C7", + "markdown-heading": "#1E293B", + "markdown-text": "#334155", + "markdown-link": "#3B82F6", + "markdown-link-text": "#0284C7", + "markdown-code": "#059669", + "markdown-block-quote": "#D97706", + "markdown-emph": "#D97706", + "markdown-strong": "#1E293B", + "markdown-horizontal-rule": "#E2E8F0", + "markdown-list-item": "#3B82F6", + "markdown-list-enumeration": "#0284C7", + "markdown-image": "#3B82F6", + "markdown-image-text": "#0284C7", + "markdown-code-block": "#334155" + } + }, + "dark": { + "seeds": { + "neutral": "#0F172A", + "primary": "#22C55E", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444", + "info": "#3B82F6", + "interactive": "#3B82F6", + "diffAdd": "#22C55E", + "diffDelete": "#EF4444" + }, + "overrides": { + "background-base": "#0F172A", + "background-weak": "#131C31", + "background-strong": "#0B1120", + "background-stronger": "#0D1424", + "border-weak-base": "#1E293B", + "border-weak-hover": "#253347", + "border-weak-active": "#334155", + "border-weak-selected": "#1D4ED8", + "border-weak-disabled": "#0F172A", + "border-weak-focus": "#2563EB", + "border-base": "#334155", + "border-hover": "#475569", + "border-active": "#64748B", + "border-selected": "#3B82F6", + "border-disabled": "#1E293B", + "border-focus": "#3B82F6", + "border-strong-base": "#475569", + "border-strong-hover": "#64748B", + "border-strong-active": "#94A3B8", + "border-strong-selected": "#60A5FA", + "border-strong-disabled": "#1E293B", + "border-strong-focus": "#60A5FA", + "surface-diff-add-base": "#052E16", + "surface-diff-delete-base": "#450A0A", + "surface-diff-hidden-base": "#172554", + "text-base": "#CBD5E1", + "text-weak": "#64748B", + "text-strong": "#F8FAFC", + "syntax-string": "#4ADE80", + "syntax-primitive": "#FB7185", + "syntax-property": "#A78BFA", + "syntax-type": "#FBBF24", + "syntax-constant": "#38BDF8", + "syntax-info": "#38BDF8", + "markdown-heading": "#22C55E", + "markdown-text": "#E2E8F0", + "markdown-link": "#60A5FA", + "markdown-link-text": "#38BDF8", + "markdown-code": "#4ADE80", + "markdown-block-quote": "#FBBF24", + "markdown-emph": "#FBBF24", + "markdown-strong": "#F8FAFC", + "markdown-horizontal-rule": "#1E293B", + "markdown-list-item": "#60A5FA", + "markdown-list-enumeration": "#38BDF8", + "markdown-image": "#60A5FA", + "markdown-image-text": "#38BDF8", + "markdown-code-block": "#E2E8F0" + } + } +} diff --git a/packages/ui/src/theme/themes/rosepine.json b/packages/ui/src/theme/themes/rosepine.json deleted file mode 100644 index a71ad18ce000..000000000000 --- a/packages/ui/src/theme/themes/rosepine.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Rose Pine", - "id": "rosepine", - "light": { - "palette": { - "neutral": "#faf4ed", - "ink": "#575279", - "primary": "#31748f", - "accent": "#d7827e", - "success": "#286983", - "warning": "#ea9d34", - "error": "#b4637a", - "info": "#56949f" - }, - "overrides": { - "text-weak": "#9893a5", - "syntax-comment": "#9893a5", - "syntax-keyword": "#286983", - "syntax-string": "#ea9d34", - "syntax-primitive": "#d7827e", - "syntax-variable": "#575279", - "syntax-property": "#d7827e", - "syntax-type": "#56949f", - "syntax-constant": "#907aa9", - "syntax-operator": "#797593", - "syntax-punctuation": "#797593", - "syntax-object": "#575279", - "markdown-heading": "#907aa9", - "markdown-text": "#575279", - "markdown-link": "#31748f", - "markdown-link-text": "#d7827e", - "markdown-code": "#286983", - "markdown-block-quote": "#9893a5", - "markdown-emph": "#ea9d34", - "markdown-strong": "#b4637a", - "markdown-horizontal-rule": "#dfdad9", - "markdown-list-item": "#31748f", - "markdown-list-enumeration": "#d7827e", - "markdown-image": "#31748f", - "markdown-image-text": "#d7827e", - "markdown-code-block": "#575279" - } - }, - "dark": { - "palette": { - "neutral": "#191724", - "ink": "#e0def4", - "primary": "#9ccfd8", - "accent": "#ebbcba", - "success": "#31748f", - "warning": "#f6c177", - "error": "#eb6f92", - "info": "#9ccfd8" - }, - "overrides": { - "text-weak": "#6e6a86", - "syntax-comment": "#6e6a86", - "syntax-keyword": "#31748f", - "syntax-string": "#f6c177", - "syntax-primitive": "#ebbcba", - "syntax-variable": "#e0def4", - "syntax-property": "#ebbcba", - "syntax-type": "#9ccfd8", - "syntax-constant": "#c4a7e7", - "syntax-operator": "#908caa", - "syntax-punctuation": "#908caa", - "syntax-object": "#e0def4", - "markdown-heading": "#c4a7e7", - "markdown-text": "#e0def4", - "markdown-link": "#9ccfd8", - "markdown-link-text": "#ebbcba", - "markdown-code": "#31748f", - "markdown-block-quote": "#6e6a86", - "markdown-emph": "#f6c177", - "markdown-strong": "#eb6f92", - "markdown-horizontal-rule": "#403d52", - "markdown-list-item": "#9ccfd8", - "markdown-list-enumeration": "#ebbcba", - "markdown-image": "#9ccfd8", - "markdown-image-text": "#ebbcba", - "markdown-code-block": "#e0def4" - } - } -} diff --git a/packages/web/src/assets/lander/check.svg b/packages/web/src/assets/lander/check.svg index 22de6f2a8832..9dea5c0cd178 100644 --- a/packages/web/src/assets/lander/check.svg +++ b/packages/web/src/assets/lander/check.svg @@ -1,2 +1 @@ - - + \ No newline at end of file diff --git a/packages/web/src/assets/lander/copy.svg b/packages/web/src/assets/lander/copy.svg index f1baac30a02a..10fd19376a8e 100644 --- a/packages/web/src/assets/lander/copy.svg +++ b/packages/web/src/assets/lander/copy.svg @@ -1,2 +1 @@ - - + \ No newline at end of file diff --git a/packages/web/src/assets/logo-dark.svg b/packages/web/src/assets/logo-dark.svg index a1582732423a..9c8e076d266c 100644 --- a/packages/web/src/assets/logo-dark.svg +++ b/packages/web/src/assets/logo-dark.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/src/assets/logo-light.svg b/packages/web/src/assets/logo-light.svg index 2a856dccefe8..6e3e80c6a2ed 100644 --- a/packages/web/src/assets/logo-light.svg +++ b/packages/web/src/assets/logo-light.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/src/assets/logo-ornate-dark.svg b/packages/web/src/assets/logo-ornate-dark.svg index a1582732423a..9c8e076d266c 100644 --- a/packages/web/src/assets/logo-ornate-dark.svg +++ b/packages/web/src/assets/logo-ornate-dark.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/web/src/assets/logo-ornate-light.svg b/packages/web/src/assets/logo-ornate-light.svg index 2a856dccefe8..6e3e80c6a2ed 100644 --- a/packages/web/src/assets/logo-ornate-light.svg +++ b/packages/web/src/assets/logo-ornate-light.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/references/AionUi b/references/AionUi new file mode 160000 index 000000000000..8a6c0e10da44 --- /dev/null +++ b/references/AionUi @@ -0,0 +1 @@ +Subproject commit 8a6c0e10da4471e6d59ec42497807d359b2376a2 diff --git a/references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Agent Loop | Boot.dev.html b/references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Agent Loop | Boot.dev.html new file mode 100644 index 000000000000..dcf33d3cfccf --- /dev/null +++ b/references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Agent Loop | Boot.dev.html @@ -0,0 +1,30 @@ + + + Learn backend development the smart way | Boot.dev