From 3e9594fb3a7b10cc7be8437265fa6d22beb77372 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 06:15:16 +0530 Subject: [PATCH 01/20] docs: add research notes and planning docs - Cline subagent architecture research questions - UI/UX overhaul plan - Escape key UX research --- docs/09-temp/cline-subagent-research.md | 66 +++++++++++++++++++++++++ docs/09-temp/escape-key-ux-research.md | 32 ++++++++++++ docs/09-temp/ui-overhaul-plan.md | 35 +++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 docs/09-temp/cline-subagent-research.md create mode 100644 docs/09-temp/escape-key-ux-research.md create mode 100644 docs/09-temp/ui-overhaul-plan.md 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/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/ui-overhaul-plan.md b/docs/09-temp/ui-overhaul-plan.md new file mode 100644 index 000000000000..cf938fd0c2cf --- /dev/null +++ b/docs/09-temp/ui-overhaul-plan.md @@ -0,0 +1,35 @@ +# UI/UX Overhaul Plan — OpenCode Desktop + +**Date:** 2026-02-24 +**Status:** TODO — needs brainstorming session with ui-ux-pro-max + frontend-pe skills + +## 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 + +## Skills to Use +- `ui-ux-pro-max` — Design system, color palettes, typography, UX guidelines +- `frontend-pe` — Avant-garde UI design, micro-interactions, visual polish +- `brainstorming` — Plan before implementing + +## Areas to Investigate +1. **Theme system** — opencode already has theming (`packages/ui/src/context/theme/`). How to add more? +2. **Tactile UI** — Micro-interactions, hover states, transitions, shadows +3. **Typography** — Font rendering, line-height, letter-spacing refinements +4. **Spacing** — Consistent padding/margin system +5. **Colors** — More vibrant palettes, better contrast +6. **Animations** — Smooth transitions between states +7. **Settings page** — More appearance options (line-height, letter-spacing, sidebar width, etc.) + +## Tonight's Completed Fixes (6 PRs) +- #14820: Streaming content duplication +- #14821: Font size settings (CSS + terminal + UI) +- #14826: ContextOverflowError recovery +- #14827: Prune before compaction +- #14831: Context usage card + compact button +- #14835: Wide mode setting (full-width chat) From 6cae3edcaf1ec12ee96e0686a6fadee57e5c2db6 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 13:57:19 +0530 Subject: [PATCH 02/20] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20overhaul=20UI=20w?= =?UTF-8?q?ith=20micro-interactions,=20animations,=20and=203=20new=20theme?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add font rendering: antialiased smoothing, optimizeLegibility, smooth scroll - Enhance animation system: easing/duration tokens, entrance keyframes, reduced-motion - Add utilities: theme-aware selection, focus rings, global transitions, styled scrollbars - Improve shadow depth: refined 5-level system (xs/sm/md/lg/xl) - Button: hover shadow lift, active press scale, icon color transitions - Card: upgraded radius, hover border highlight + shadow elevation - Dialog: frosted glass overlay (backdrop-filter blur), smoother scale+translate entrance - Icon-button: tactile press scale(0.92), icon color transitions - Tabs: color/background/border transitions on triggers - Tooltip: enabled directional slide-in transitions, upgraded radius/padding - Popover: upgraded shadow/radius, added translateY to animations - List: smooth background/radius transition on items - Add Rosé Pine theme (light + dark) - Add Kanagawa theme (light + dark) - Add Everforest theme (light + dark) --- docs/09-temp/ui-overhaul-plan.md | 113 +++-- packages/ui/src/components/card.css | 94 ----- packages/ui/src/components/list.css | 3 + packages/ui/src/components/popover.css | 12 +- packages/ui/src/styles/animations.css | 223 ++++++---- packages/ui/src/styles/base.css | 423 ------------------- packages/ui/src/styles/theme.css | 25 +- packages/ui/src/styles/utilities.css | 96 ++++- packages/ui/src/theme/default-themes.ts | 148 ------- packages/ui/src/theme/themes/everforest.json | 89 ---- packages/ui/src/theme/themes/kanagawa.json | 89 ---- packages/ui/src/theme/themes/rosepine.json | 85 ---- 12 files changed, 338 insertions(+), 1062 deletions(-) delete mode 100644 packages/ui/src/components/card.css delete mode 100644 packages/ui/src/styles/base.css delete mode 100644 packages/ui/src/theme/default-themes.ts delete mode 100644 packages/ui/src/theme/themes/everforest.json delete mode 100644 packages/ui/src/theme/themes/kanagawa.json delete mode 100644 packages/ui/src/theme/themes/rosepine.json diff --git a/docs/09-temp/ui-overhaul-plan.md b/docs/09-temp/ui-overhaul-plan.md index cf938fd0c2cf..70d205acaf1a 100644 --- a/docs/09-temp/ui-overhaul-plan.md +++ b/docs/09-temp/ui-overhaul-plan.md @@ -1,35 +1,94 @@ # UI/UX Overhaul Plan — OpenCode Desktop **Date:** 2026-02-24 -**Status:** TODO — needs brainstorming session with ui-ux-pro-max + frontend-pe skills +**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 +- 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 - -## Skills to Use -- `ui-ux-pro-max` — Design system, color palettes, typography, UX guidelines -- `frontend-pe` — Avant-garde UI design, micro-interactions, visual polish -- `brainstorming` — Plan before implementing - -## Areas to Investigate -1. **Theme system** — opencode already has theming (`packages/ui/src/context/theme/`). How to add more? -2. **Tactile UI** — Micro-interactions, hover states, transitions, shadows -3. **Typography** — Font rendering, line-height, letter-spacing refinements -4. **Spacing** — Consistent padding/margin system -5. **Colors** — More vibrant palettes, better contrast -6. **Animations** — Smooth transitions between states -7. **Settings page** — More appearance options (line-height, letter-spacing, sidebar width, etc.) - -## Tonight's Completed Fixes (6 PRs) -- #14820: Streaming content duplication -- #14821: Font size settings (CSS + terminal + UI) -- #14826: ContextOverflowError recovery -- #14827: Prune before compaction -- #14831: Context usage card + compact button -- #14835: Wide mode setting (full-width chat) +- 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/packages/ui/src/components/card.css b/packages/ui/src/components/card.css deleted file mode 100644 index 801b2a589494..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(--v2-text-text-base); - - --card-gap: 8px; - --card-icon: 16px; - --card-indent: 0px; - --card-line-pad: 8px; - - --card-accent: var(--v2-icon-icon-base); - - &: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(--v2-text-text-base); - 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-slot="icon-svg"] { - 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-slot="icon-svg"] { - color: var(--v2-text-text-faint); - } - - :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(--v2-text-text-muted); - 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/list.css b/packages/ui/src/components/list.css index fe9b404d6a9f..8e9e6d630bf5 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/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/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 deleted file mode 100644 index ebcc8ad6ccf2..000000000000 --- a/packages/ui/src/styles/base.css +++ /dev/null @@ -1,423 +0,0 @@ -/* - 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) - 2. Remove default margins and padding - 3. Reset all borders. -*/ - -*, -::after, -::before, -::backdrop, -::file-selector-button { - box-sizing: border-box; /* 1 */ - margin: 0; /* 2 */ - padding: 0; /* 2 */ - border: 0 solid; /* 3 */ -} - -/* - 1. Use a consistent sensible line-height in all browsers. - 2. Prevent adjustments of font size after orientation changes in iOS. - 3. Use a more readable tab size. - 4. Use the user's configured `sans` font-family by default. - 5. Use the user's configured `sans` font-feature-settings by default. - 6. Use the user's configured `sans` font-variation-settings by default. - 7. Disable tap highlights on iOS. - 8. Avoid one-word last lines in all wrapped text. Chromium keeps the line count, so heights do not change. -*/ - -html, -:host { - line-height: var(--line-height-large); /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ - tab-size: 4; /* 3 */ - font-family: var(--font-family-sans); /* 4 */ - 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 */ - text-wrap-style: pretty; /* 8 */ -} - -/* - 1. Add the correct height in Firefox. - 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) - 3. Reset the default border style to a 1px solid border. -*/ - -hr { - height: 0; /* 1 */ - color: inherit; /* 2 */ - border-top-width: 1px; /* 3 */ -} - -/* - Add the correct text decoration in Chrome, Edge, and Safari. -*/ - -abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} - -/* - Remove the default font size and weight for headings. -*/ - -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} - -/* - Reset links to optimize for opt-in styling instead of opt-out. -*/ - -a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; - cursor: default; -} - -#root:not([aria-hidden]) *[data-tauri-drag-region] { - app-region: drag; -} - -*[data-tauri-drag-region] button, -*[data-tauri-drag-region] a, -*[data-tauri-drag-region] input, -*[data-tauri-drag-region] textarea, -*[data-tauri-drag-region] select, -*[data-tauri-drag-region] [role="button"], -*[data-tauri-drag-region] [role="menuitem"], -*[data-tauri-drag-region] [contenteditable] { - app-region: no-drag; -} - -/* - Add the correct font weight in Edge and Safari. -*/ - -b, -strong { - font-weight: bolder; -} - -/* - 1. Use the user's configured `mono` font-family by default. - 2. Use the user's configured `mono` font-feature-settings by default. - 3. Use the user's configured `mono` font-variation-settings by default. - 4. Correct the odd `em` font sizing in all browsers. - 5. Keep code wrapping greedy so wrapped lines break where an editor would. -*/ - -code, -kbd, -samp, -pre { - font-family: var(--font-family-mono); /* 1 */ - font-feature-settings: var(--font-family-mono--font-feature-settings, normal); /* 2 */ - font-variation-settings: var(--font-family-mono--font-variation-settings, normal); /* 3 */ - font-size: 1em; /* 4 */ - text-wrap-style: auto; /* 5 */ -} - -/* - Keep earlier lines still while the user types. -*/ - -input, -textarea, -[contenteditable] { - text-wrap-style: stable; -} - -/* - Add the correct font size in all browsers. -*/ - -small { - font-size: 80%; -} - -/* - Prevent `sub` and `sup` elements from affecting the line height in all browsers. -*/ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* - 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) - 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) - 3. Remove gaps between table borders by default. -*/ - -table { - text-indent: 0; /* 1 */ - border-color: inherit; /* 2 */ - border-collapse: collapse; /* 3 */ -} - -/* - Use the modern Firefox focus style for all focusable elements. -*/ - -:-moz-focusring { - outline: auto; -} - -/* - Add the correct vertical alignment in Chrome and Firefox. -*/ - -progress { - vertical-align: baseline; -} - -/* - Add the correct display in Chrome and Safari. -*/ - -summary { - display: list-item; -} - -/* - Make lists unstyled by default. -*/ - -ol, -ul, -menu { - list-style: none; -} - -/* - 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) - 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) - This can trigger a poorly considered lint error in some tools but is included by design. -*/ - -img, -svg, -video, -canvas, -audio, -iframe, -embed, -object { - display: block; /* 1 */ - vertical-align: middle; /* 2 */ -} - -/* - Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) -*/ - -img, -video { - max-width: 100%; - height: auto; -} - -/* - 1. Inherit font styles in all browsers. - 2. Remove border radius in all browsers. - 3. Remove background color in all browsers. - 4. Ensure consistent opacity for disabled states in all browsers. -*/ - -button, -input, -select, -optgroup, -textarea, -::file-selector-button { - font: inherit; /* 1 */ - font-feature-settings: inherit; /* 1 */ - font-variation-settings: inherit; /* 1 */ - letter-spacing: inherit; /* 1 */ - color: inherit; /* 1 */ - border-radius: 0; /* 2 */ - background-color: transparent; /* 3 */ - opacity: 1; /* 4 */ -} - -/* - Restore default font weight. -*/ - -:where(select:is([multiple], [size])) optgroup { - font-weight: bolder; -} - -/* - Restore indentation. -*/ - -:where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; -} - -/* - Restore space after button. -*/ - -::file-selector-button { - margin-inline-end: 4px; -} - -/* - Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) -*/ - -::placeholder { - opacity: 1; -} - -/* - Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not - crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) -*/ - -@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or (contain-intrinsic-size: 1px) - /* Safari 17+ */ { - ::placeholder { - color: color-mix(in oklab, currentcolor 50%, transparent); - } -} - -/* - Prevent resizing textareas horizontally by default. -*/ - -textarea { - resize: vertical; -} - -/* - Remove the inner padding in Chrome and Safari on macOS. -*/ - -::-webkit-search-decoration { - -webkit-appearance: none; -} - -/* - 1. Ensure date/time inputs have the same height when empty in iOS Safari. - 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. -*/ - -::-webkit-date-and-time-value { - min-height: 1lh; /* 1 */ - text-align: inherit; /* 2 */ -} - -/* - Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. -*/ - -::-webkit-datetime-edit { - display: inline-flex; -} - -/* - Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. -*/ - -::-webkit-datetime-edit-fields-wrapper { - padding: 0; -} - -::-webkit-datetime-edit, -::-webkit-datetime-edit-year-field, -::-webkit-datetime-edit-month-field, -::-webkit-datetime-edit-day-field, -::-webkit-datetime-edit-hour-field, -::-webkit-datetime-edit-minute-field, -::-webkit-datetime-edit-second-field, -::-webkit-datetime-edit-millisecond-field, -::-webkit-datetime-edit-meridiem-field { - padding-block: 0; -} - -/* - Center dropdown marker shown on inputs with paired ``s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) -*/ - -::-webkit-calendar-picker-indicator { - line-height: 1; -} - -/* - Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) -*/ - -:-moz-ui-invalid { - box-shadow: none; -} - -/* - Correct the inability to style the border radius in iOS Safari. -*/ - -button, -input:where([type="button"], [type="reset"], [type="submit"]), -::file-selector-button { - appearance: button; -} - -/* - Correct the cursor style of increment and decrement buttons in Safari. -*/ - -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} - -/* - Make elements with the HTML hidden attribute stay hidden by default. -*/ - -[hidden]:where(:not([hidden="until-found"])) { - display: none !important; -} - -/* - Prevent iOS Safari from auto-zooming on input focus. - iOS WebKit zooms on any input with font-size < 16px as an accessibility feature. -*/ - -@media (hover: none) and (pointer: coarse) { - input, - select, - textarea, - [contenteditable="true"] { - font-size: 16px !important; - } - - input::placeholder, - textarea::placeholder { - font-size: 13px; - } -} diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 6d0cccdfb99d..649e8c32da1a 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -50,17 +50,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 5279536f40e8..000000000000 --- a/packages/ui/src/theme/default-themes.ts +++ /dev/null @@ -1,148 +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 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 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, - 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 5bfab05bcf06..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": "#6f894e", - "warning": "#cc6d00", - "error": "#E82424", - "info": "#4d699b", - "diffAdd": "#89AF5B", - "diffDelete": "#D61F1F" - }, - "overrides": { - "text-weak": "#8a8980", - "syntax-comment": "#8a8980", - "syntax-keyword": "#624c83", - "syntax-string": "#6f894e", - "syntax-primitive": "#2D4F67", - "syntax-variable": "#54433A", - "syntax-property": "#4d699b", - "syntax-type": "#b35b79", - "syntax-constant": "#cc6d00", - "syntax-operator": "#735896", - "syntax-punctuation": "#54433A", - "syntax-object": "#54433A", - "markdown-heading": "#624c83", - "markdown-text": "#54433A", - "markdown-link": "#2D4F67", - "markdown-link-text": "#4d699b", - "markdown-code": "#6f894e", - "markdown-block-quote": "#8a8980", - "markdown-emph": "#b35b79", - "markdown-strong": "#cc6d00", - "markdown-horizontal-rule": "#8a8980", - "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/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" - } - } -} From e5ef02b42886be8f4caec375a4148ea0e183165d Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:07:22 +0530 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=92=84=20style(ui):=20dramatic=20vi?= =?UTF-8?q?sual=20overhaul=20=E2=80=94=20chat=20bubbles,=20heading=20hiera?= =?UTF-8?q?rchy,=20code=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Markdown headings: distinct size hierarchy (h1=1.5em, h2=1.25em, h3=1.1em) - Inline code: visible pill background with border - Code blocks: larger padding, rounded corners, inset shadow - Blockquotes: blue accent border with subtle background fill - Tables: header backgrounds, row hover highlights - User messages: chat-style rounded bubbles with blue tint - Prompt dock: focus glow ring when typing, elevated shadow - Prompt tray: border-color transition on interaction --- packages/ui/src/components/dock-surface.css | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css index 5d0b2db1b685..310f8dd0990b 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-border-underlay] { @@ -19,8 +29,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"] { From 090efc51c6773126a69394a1395dfd6343f8f869 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:12:43 +0530 Subject: [PATCH 04/20] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20add=20Midnight=20?= =?UTF-8?q?theme=20from=20ui-ux-pro-max=20design=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on ui-ux-pro-max 'Developer Tool / IDE' color palette: - Dark: deep slate #0F172A background, #F8FAFC text, #22C55E green primary - Light: clean #F8FAFC background, #0F172A text, #3B82F6 blue interactive - Full syntax highlighting with green strings, purple properties, gold types - High contrast WCAG AAA compliant color combinations --- packages/ui/src/theme/themes/midnight.json | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/ui/src/theme/themes/midnight.json 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" + } + } +} From e060923e73aaa47ffcc0fe524426977ec80fa0d2 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:35:32 +0530 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=92=84=20style(ui):=20high-impact?= =?UTF-8?q?=20visual=20polish=20=E2=80=94=20typography,=20message=20separa?= =?UTF-8?q?tors,=20list=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Typography: Enable Inter alternate glyphs (ss01, cv01, cv02) for distinctive character - Bold text: Increased to font-weight 600 for stronger visual hierarchy - List markers: Now use interactive blue color for visual rhythm in numbered lists - Message turns: Added padding + border-bottom separator between conversation turns - Increased gap between messages from 18px to 24px for breathing room - Links: Added color transition + thicker underline on hover - These changes are immediately visible in the default OC-1 theme --- packages/ui/src/styles/theme.css | 639 ------------------------------- 1 file changed, 639 deletions(-) delete mode 100644 packages/ui/src/styles/theme.css diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css deleted file mode 100644 index 649e8c32da1a..000000000000 --- a/packages/ui/src/styles/theme.css +++ /dev/null @@ -1,639 +0,0 @@ -:root { - --font-family-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-family-sans--font-feature-settings: normal; - --font-family-mono: - "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --font-family-mono--font-feature-settings: normal; - - --font-size-small: 13px; - --font-size-base: 14px; - --font-size-large: 16px; - --font-size-x-large: 20px; - --font-weight-regular: 400; - --font-weight-medium: 500; - --font-weight-bold: 670; - --line-height-normal: 130%; - --line-height-large: 150%; - --line-height-x-large: 180%; - --line-height-2x-large: 200%; - --letter-spacing-normal: 0; - --letter-spacing-tight: -0.1599999964237213; - --letter-spacing-tightest: -0.3199999928474426; - --paragraph-spacing-base: 0; - - --spacing: 0.25rem; - - --breakpoint-sm: 40rem; - --breakpoint-md: 48rem; - --breakpoint-lg: 64rem; - --breakpoint-xl: 80rem; - --breakpoint-2xl: 96rem; - - --container-3xs: 16rem; - --container-2xs: 18rem; - --container-xs: 20rem; - --container-sm: 24rem; - --container-md: 28rem; - --container-lg: 32rem; - --container-xl: 36rem; - --container-2xl: 42rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --container-5xl: 64rem; - --container-6xl: 72rem; - --container-7xl: 80rem; - - --radius-xs: 0.125rem; - --radius-sm: 0.25rem; - --radius-md: 0.375rem; - --radius-lg: 0.5rem; - --radius-xl: 0.625rem; - - --shadow-xs: - 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 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 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), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-base: - 0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-select: - 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), - 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); - --shadow-xs-border-focus: - 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0), - 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99)); - --shadow-xs-border-hover: - 0 0 0 1px var(--border-weak-selected, rgba(0, 112, 255, 0.22)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-critical-base: 0 0 0 1px var(--border-critical-selected, #fc543a); - --shadow-xs-border-critical-focus: - 0 0 0 3px var(--border-critical-weak, rgba(251, 34, 0, 0.18)), 0 0 0 1px var(--border-critical-selected, #fc543a), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); - --shadow-lg-border-base: - 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), - 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), - 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); - - color-scheme: light; - --text-mix-blend-mode: multiply; - - /* OC-2 fallback variables (light) */ - --background-base: #f8f8f8; - --background-weak: #f3f3f3; - --background-strong: #fcfcfc; - --background-stronger: #fcfcfc; - --surface-base: rgba(0, 0, 0, 0.031); - --base: rgba(0, 0, 0, 0.034); - --surface-base-hover: rgba(0, 0, 0, 0.059); - --surface-base-active: rgba(0, 0, 0, 0.051); - --surface-base-interactive-active: rgba(3, 76, 255, 0.09); - --base2: rgba(0, 0, 0, 0.034); - --base3: rgba(0, 0, 0, 0.034); - --surface-inset-base: rgba(0, 0, 0, 0.034); - --surface-inset-base-hover: rgba(0, 0, 0, 0.055); - --surface-inset-strong: rgba(0, 0, 0, 0.09); - --surface-inset-strong-hover: rgba(0, 0, 0, 0.09); - --surface-raised-base: rgba(0, 0, 0, 0.031); - --surface-float-base: #161616; - --surface-float-base-hover: #1c1c1c; - --surface-raised-base-hover: rgba(0, 0, 0, 0.051); - --surface-raised-base-active: rgba(0, 0, 0, 0.09); - --surface-raised-strong: #fcfcfc; - --surface-raised-strong-hover: #ffffff; - --surface-raised-stronger: #ffffff; - --surface-raised-stronger-hover: #ffffff; - --surface-weak: rgba(0, 0, 0, 0.051); - --surface-weaker: rgba(0, 0, 0, 0.071); - --surface-strong: #ffffff; - --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: #ffffff; - --surface-brand-base: #dcde8d; - --surface-brand-hover: #d0d283; - --surface-interactive-base: #ecf3ff; - --surface-interactive-hover: #e0eaff; - --surface-interactive-weak: #f7faff; - --surface-interactive-weak-hover: #ecf3ff; - --surface-success-base: #dbfed7; - --surface-success-weak: #f0feee; - --surface-success-strong: #12c905; - --surface-warning-base: #fcf3cb; - --surface-warning-weak: #fdfaec; - --surface-warning-strong: #fbdd46; - --surface-critical-base: #fff2f0; - --surface-critical-weak: #fff8f6; - --surface-critical-strong: #fc533a; - --surface-info-base: #fdecfe; - --surface-info-weak: #fef7ff; - --surface-info-strong: #a753ae; - --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: #f8f8f8; - --surface-diff-hidden-base: #eaf4ff; - --surface-diff-hidden-weak: #f6faff; - --surface-diff-hidden-weaker: #fbfdff; - --surface-diff-hidden-strong: #cae3ff; - --surface-diff-hidden-stronger: #2090f5; - --surface-diff-add-base: #e3fae1; - --surface-diff-add-weak: #f4fcf3; - --surface-diff-add-weaker: #fbfefb; - --surface-diff-add-strong: #c2eebf; - --surface-diff-add-stronger: #9ff29a; - --surface-diff-delete-base: #feefeb; - --surface-diff-delete-weak: #fff8f6; - --surface-diff-delete-weaker: #fffcfb; - --surface-diff-delete-strong: #fdc3b7; - --surface-diff-delete-stronger: #fc533a; - --input-base: #fcfcfc; - --input-hover: #f8f8f8; - --input-active: #fcfdff; - --input-selected: #e0eaff; - --input-focus: #fcfdff; - --input-disabled: #ededed; - --text-base: #6f6f6f; - --text-weak: #8f8f8f; - --text-weaker: #c7c7c7; - --text-strong: #171717; - --text-invert-base: #f8f8f8; - --text-invert-weak: #f3f3f3; - --text-invert-weaker: #ededed; - --text-invert-strong: #fcfcfc; - --text-interactive-base: #034cff; - --text-on-brand-base: rgba(0, 0, 0, 0.574); - --text-on-interactive-base: #fcfcfc; - --text-on-interactive-weak: rgba(0, 0, 0, 0.574); - --text-on-success-base: #2dba26; - --text-on-critical-base: #ed4831; - --text-on-critical-weak: #fe806a; - --text-on-critical-strong: #601a0f; - --text-on-warning-base: rgba(0, 0, 0, 0.574); - --text-on-info-base: rgba(0, 0, 0, 0.574); - --text-diff-add-base: #3a8437; - --text-diff-delete-base: #ed4831; - --text-diff-delete-strong: #601a0f; - --text-diff-add-strong: #1d3e1c; - --text-on-info-weak: rgba(0, 0, 0, 0.453); - --text-on-info-strong: rgba(0, 0, 0, 0.915); - --text-on-warning-weak: rgba(0, 0, 0, 0.453); - --text-on-warning-strong: rgba(0, 0, 0, 0.915); - --text-on-success-weak: #96ec8e; - --text-on-success-strong: #044202; - --text-on-brand-weak: rgba(0, 0, 0, 0.453); - --text-on-brand-weaker: rgba(0, 0, 0, 0.232); - --text-on-brand-strong: rgba(0, 0, 0, 0.915); - --button-primary-base: #171717; - --button-secondary-base: #fcfcfc; - --button-secondary-hover: #f8f8f8; - --button-ghost-hover: rgba(0, 0, 0, 0.031); - --button-ghost-hover2: rgba(0, 0, 0, 0.051); - --border-base: rgba(0, 0, 0, 0.162); - --border-hover: rgba(0, 0, 0, 0.236); - --border-active: rgba(0, 0, 0, 0.46); - --border-selected: rgba(3, 76, 255, 0.99); - --border-disabled: rgba(0, 0, 0, 0.236); - --border-focus: rgba(0, 0, 0, 0.46); - --border-weak-base: #e5e5e5; - --border-strong-base: rgba(0, 0, 0, 0.151); - --border-strong-hover: rgba(0, 0, 0, 0.232); - --border-strong-active: rgba(0, 0, 0, 0.151); - --border-strong-selected: rgba(3, 76, 255, 0.31); - --border-strong-disabled: rgba(0, 0, 0, 0.118); - --border-strong-focus: rgba(0, 0, 0, 0.151); - --border-weak-hover: rgba(0, 0, 0, 0.118); - --border-weak-active: rgba(0, 0, 0, 0.151); - --border-weak-selected: rgba(3, 76, 255, 0.24); - --border-weak-disabled: rgba(0, 0, 0, 0.118); - --border-weak-focus: rgba(0, 0, 0, 0.151); - --border-weaker-base: #f0f0f0; - --border-weaker-hover: rgba(0, 0, 0, 0.075); - --border-weaker-active: rgba(0, 0, 0, 0.118); - --border-weaker-selected: rgba(3, 76, 255, 0.16); - --border-weaker-disabled: rgba(0, 0, 0, 0.034); - --border-weaker-focus: rgba(0, 0, 0, 0.118); - --border-interactive-base: #a3c1fd; - --border-interactive-hover: #7ea9ff; - --border-interactive-active: #034cff; - --border-interactive-selected: #034cff; - --border-interactive-disabled: #c7c7c7; - --border-interactive-focus: #034cff; - --border-success-base: #96ec8e; - --border-success-hover: #7add71; - --border-success-selected: #12c905; - --border-warning-base: #e8d479; - --border-warning-hover: #d8c158; - --border-warning-selected: #fbdd46; - --border-critical-base: #fdc3b7; - --border-critical-hover: #ffa796; - --border-critical-selected: #fc533a; - --border-info-base: #f4bdf8; - --border-info-hover: #e6a8ea; - --border-info-selected: #a753ae; - --border-color: #ffffff; - --icon-base: #8f8f8f; - --icon-hover: #6f6f6f; - --icon-active: #171717; - --icon-selected: #171717; - --icon-disabled: #c7c7c7; - --icon-focus: #171717; - --icon-invert-base: #ffffff; - --icon-weak-base: #dbdbdb; - --icon-weak-hover: #c7c7c7; - --icon-weak-active: #8f8f8f; - --icon-weak-selected: #858585; - --icon-weak-disabled: #e2e2e2; - --icon-weak-focus: #8f8f8f; - --icon-strong-base: #171717; - --icon-strong-hover: #151313; - --icon-strong-active: #020202; - --icon-strong-selected: #020202; - --icon-strong-disabled: #c7c7c7; - --icon-strong-focus: #020202; - --icon-brand-base: #171717; - --icon-interactive-base: #034cff; - --icon-success-base: #7add71; - --icon-success-hover: #4cc944; - --icon-success-active: #078901; - --icon-warning-base: #ebb76e; - --icon-warning-hover: #da9e40; - --icon-warning-active: #95671b; - --icon-critical-base: #ed4831; - --icon-critical-hover: #ca2d17; - --icon-critical-active: #601a0f; - --icon-info-base: #e6a8ea; - --icon-info-hover: #d58cda; - --icon-info-active: #9b4da1; - --icon-on-brand-base: rgba(0, 0, 0, 0.574); - --icon-on-brand-hover: rgba(0, 0, 0, 0.915); - --icon-on-brand-selected: rgba(0, 0, 0, 0.915); - --icon-on-interactive-base: #fcfcfc; - --icon-agent-plan-base: #a753ae; - --icon-agent-docs-base: #fcb239; - --icon-agent-ask-base: #2090f5; - --icon-agent-build-base: #034cff; - --v2-agent-plan-solid: var(--v2-pink-800); - --v2-agent-plan-border: rgba(200, 61, 139, 0.2); - --v2-agent-plan-background: rgba(253, 236, 243, 0.1); - --v2-agent-build-solid: var(--v2-blue-800); - --v2-agent-build-border: rgba(44, 71, 200, 0.1); - --v2-agent-build-background: rgba(236, 241, 254, 0.1); - --v2-agent-explore-solid: var(--v2-yellow-900); - --v2-agent-explore-border: rgba(203, 159, 52, 0.2); - --v2-agent-explore-background: rgba(254, 250, 236, 0.1); - --v2-agent-review-solid: var(--v2-green-800); - --v2-agent-writer-solid: var(--v2-purple-700); - --icon-on-success-base: rgba(18, 201, 5, 0.9); - --icon-on-success-hover: rgba(45, 186, 38, 0.9); - --icon-on-success-selected: rgba(7, 137, 1, 0.9); - --icon-on-warning-base: rgba(252, 178, 57, 0.9); - --icon-on-warning-hover: rgba(239, 167, 46, 0.9); - --icon-on-warning-selected: rgba(149, 103, 27, 0.9); - --icon-on-critical-base: rgba(252, 83, 58, 0.9); - --icon-on-critical-hover: rgba(237, 72, 49, 0.9); - --icon-on-critical-selected: rgba(202, 45, 23, 0.9); - --icon-on-info-base: #a753ae; - --icon-on-info-hover: rgba(155, 73, 162, 0.9); - --icon-on-info-selected: rgba(155, 77, 161, 0.9); - --icon-diff-add-base: #3a8437; - --icon-diff-add-hover: #1d3e1c; - --icon-diff-add-active: #1d3e1c; - --icon-diff-delete-base: #ed4831; - --icon-diff-delete-hover: #ca2d17; - --icon-diff-modified-base: #ff8c00; - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #006656; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #fb4804; - --syntax-operator: var(--text-base); - --syntax-variable: var(--text-strong); - --syntax-property: #ed6dc8; - --syntax-type: #596600; - --syntax-constant: #007b80; - --syntax-punctuation: var(--text-base); - --syntax-object: var(--text-strong); - --syntax-success: #2dba26; - --syntax-warning: #efa72e; - --syntax-critical: #ed4831; - --syntax-info: #0092a8; - --syntax-diff-add: #3a8437; - --syntax-diff-delete: #ca2d17; - --syntax-diff-unknown: #ff0000; - --markdown-heading: #d68c27; - --markdown-text: #1a1a1a; - --markdown-link: #3b7dd8; - --markdown-link-text: #318795; - --markdown-code: #3d9a57; - --markdown-block-quote: #b0851f; - --markdown-emph: #b0851f; - --markdown-strong: #d68c27; - --markdown-horizontal-rule: #8a8a8a; - --markdown-list-item: #3b7dd8; - --markdown-list-enumeration: #318795; - --markdown-image: #3b7dd8; - --markdown-image-text: #318795; - --markdown-code-block: #1a1a1a; - --avatar-background-pink: #feeef8; - --avatar-background-mint: #e1fbf4; - --avatar-background-orange: #fff1e7; - --avatar-background-purple: #f9f1fe; - --avatar-background-cyan: #e7f9fb; - --avatar-background-lime: #eefadc; - --avatar-text-pink: #cd1d8d; - --avatar-text-mint: #147d6f; - --avatar-text-orange: #ed5f00; - --avatar-text-purple: #8445bc; - --avatar-text-cyan: #0894b3; - --avatar-text-lime: #5d770d; - --text-stronger: #171717; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - --text-mix-blend-mode: plus-lighter; - - /* OC-2 fallback variables (dark) */ - --background-base: #101010; - --background-weak: #1e1e1e; - --background-strong: #121212; - --background-stronger: #151515; - --surface-base: rgba(255, 255, 255, 0.031); - --base: rgba(255, 255, 255, 0.034); - --surface-base-hover: rgba(255, 255, 255, 0.039); - --surface-base-active: rgba(255, 255, 255, 0.059); - --surface-base-interactive-active: rgba(3, 76, 255, 0.125); - --base2: rgba(255, 255, 255, 0.034); - --base3: rgba(255, 255, 255, 0.034); - --surface-inset-base: rgba(0, 0, 0, 0.5); - --surface-inset-base-hover: rgba(0, 0, 0, 0.5); - --surface-inset-strong: rgba(0, 0, 0, 0.8); - --surface-inset-strong-hover: rgba(0, 0, 0, 0.8); - --surface-raised-base: rgba(255, 255, 255, 0.059); - --surface-float-base: #161616; - --surface-float-base-hover: #1c1c1c; - --surface-raised-base-hover: rgba(255, 255, 255, 0.078); - --surface-raised-base-active: rgba(255, 255, 255, 0.102); - --surface-raised-strong: rgba(255, 255, 255, 0.078); - --surface-raised-strong-hover: rgba(255, 255, 255, 0.129); - --surface-raised-stronger: rgba(255, 255, 255, 0.129); - --surface-raised-stronger-hover: rgba(255, 255, 255, 0.169); - --surface-weak: rgba(255, 255, 255, 0.078); - --surface-weaker: rgba(255, 255, 255, 0.102); - --surface-strong: rgba(255, 255, 255, 0.169); - --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: #1c1c1c; - --surface-brand-base: #fab283; - --surface-brand-hover: #eda779; - --surface-interactive-base: #091f52; - --surface-interactive-hover: #091f52; - --surface-interactive-weak: #0b1730; - --surface-interactive-weak-hover: #ecf3ff; - --surface-success-base: #062d04; - --surface-success-weak: #0a1e08; - --surface-success-strong: #12c905; - --surface-warning-base: #fdf3cf; - --surface-warning-weak: #fdfaed; - --surface-warning-strong: #fcd53a; - --surface-critical-base: #1f0603; - --surface-critical-weak: #28110c; - --surface-critical-strong: #fc533a; - --surface-info-base: #feecfe; - --surface-info-weak: #fdf7fe; - --surface-info-strong: #edb2f1; - --surface-diff-unchanged-base: #161616; - --surface-diff-skip-base: #00000000; - --surface-diff-hidden-base: #0c1928; - --surface-diff-hidden-weak: #09131d; - --surface-diff-hidden-weaker: #082542; - --surface-diff-hidden-strong: #073966; - --surface-diff-hidden-stronger: #8ec2fc; - --surface-diff-add-base: #1a2919; - --surface-diff-add-weak: #1f351e; - --surface-diff-add-weaker: #1a2919; - --surface-diff-add-strong: #264024; - --surface-diff-add-stronger: #9bcd97; - --surface-diff-delete-base: #42120b; - --surface-diff-delete-weak: #580f06; - --surface-diff-delete-weaker: #42120b; - --surface-diff-delete-strong: #6a1206; - --surface-diff-delete-stronger: #faa494; - --input-base: #1c1c1c; - --input-hover: #1c1c1c; - --input-active: #091123; - --input-selected: #0b1730; - --input-focus: #091123; - --input-disabled: #282828; - --text-base: rgba(255, 255, 255, 0.618); - --text-weak: rgba(255, 255, 255, 0.422); - --text-weaker: rgba(255, 255, 255, 0.284); - --text-strong: rgba(255, 255, 255, 0.936); - --text-invert-base: #a0a0a0; - --text-invert-weak: #707070; - --text-invert-weaker: #505050; - --text-invert-strong: #ededed; - --text-interactive-base: #9dbefe; - --text-on-brand-base: rgba(255, 255, 255, 0.603); - --text-on-interactive-base: #ededed; - --text-on-interactive-weak: rgba(255, 255, 255, 0.603); - --text-on-success-base: #12c905; - --text-on-critical-base: #fc533a; - --text-on-critical-weak: #b72d1a; - --text-on-critical-strong: #ffe0da; - --text-on-warning-base: rgba(255, 255, 255, 0.603); - --text-on-info-base: rgba(255, 255, 255, 0.603); - --text-diff-add-base: #9bcd97; - --text-diff-delete-base: #fc533a; - --text-diff-delete-strong: #ffe0da; - --text-diff-add-strong: #4a7348; - --text-on-info-weak: rgba(255, 255, 255, 0.404); - --text-on-info-strong: rgba(255, 255, 255, 0.928); - --text-on-warning-weak: rgba(255, 255, 255, 0.404); - --text-on-warning-strong: rgba(255, 255, 255, 0.928); - --text-on-success-weak: #127d0d; - --text-on-success-strong: #bafdb3; - --text-on-brand-weak: rgba(255, 255, 255, 0.404); - --text-on-brand-weaker: rgba(255, 255, 255, 0.266); - --text-on-brand-strong: rgba(255, 255, 255, 0.928); - --button-primary-base: #ededed; - --button-secondary-base: #1c1c1c; - --button-secondary-hover: rgba(255, 255, 255, 0.039); - --button-ghost-hover: rgba(255, 255, 255, 0.031); - --button-ghost-hover2: rgba(255, 255, 255, 0.059); - --border-base: rgba(255, 255, 255, 0.195); - --border-hover: rgba(255, 255, 255, 0.284); - --border-active: rgba(255, 255, 255, 0.418); - --border-selected: #9dbefe; - --border-disabled: rgba(255, 255, 255, 0.284); - --border-focus: rgba(255, 255, 255, 0.418); - --border-weak-base: #282828; - --border-strong-base: rgba(255, 255, 255, 0.266); - --border-strong-hover: rgba(255, 255, 255, 0.266); - --border-strong-active: rgba(255, 255, 255, 0.266); - --border-strong-selected: rgba(3, 76, 255, 0.62); - --border-strong-disabled: rgba(255, 255, 255, 0.138); - --border-strong-focus: rgba(255, 255, 255, 0.266); - --border-weak-hover: rgba(255, 255, 255, 0.181); - --border-weak-active: rgba(255, 255, 255, 0.266); - --border-weak-selected: rgba(3, 76, 255, 0.62); - --border-weak-disabled: rgba(255, 255, 255, 0.138); - --border-weak-focus: rgba(255, 255, 255, 0.266); - --border-weaker-base: #202020; - --border-weaker-hover: rgba(255, 255, 255, 0.084); - --border-weaker-active: rgba(255, 255, 255, 0.138); - --border-weaker-selected: rgba(3, 76, 255, 0.32); - --border-weaker-disabled: rgba(255, 255, 255, 0.034); - --border-weaker-focus: rgba(255, 255, 255, 0.138); - --border-interactive-base: #a3c1fd; - --border-interactive-hover: #7ea9ff; - --border-interactive-active: #034cff; - --border-interactive-selected: #034cff; - --border-interactive-disabled: #505050; - --border-interactive-focus: #034cff; - --border-success-base: #96ec8e; - --border-success-hover: #7add71; - --border-success-selected: #12c905; - --border-warning-base: #e9d282; - --border-warning-hover: #dac063; - --border-warning-selected: #fcd53a; - --border-critical-base: #6a1206; - --border-critical-hover: #952414; - --border-critical-selected: #fc533a; - --border-info-base: #eac5ec; - --border-info-hover: #dab1dd; - --border-info-selected: #edb2f1; - --border-color: #ffffff; - --icon-base: #7e7e7e; - --icon-hover: #a0a0a0; - --icon-active: #ededed; - --icon-selected: #ededed; - --icon-disabled: #3e3e3e; - --icon-focus: #ededed; - --icon-invert-base: #161616; - --icon-weak-base: #343434; - --icon-weak-hover: #d9d9d9; - --icon-weak-active: #c8c8c8; - --icon-weak-selected: #707070; - --icon-weak-disabled: #ededed; - --icon-weak-focus: #707070; - --icon-strong-base: #ededed; - --icon-strong-hover: #f6f3f3; - --icon-strong-active: #fcfcfc; - --icon-strong-selected: #fdfcfc; - --icon-strong-disabled: #3e3e3e; - --icon-strong-focus: #fdfcfc; - --icon-brand-base: #ffffff; - --icon-interactive-base: #034cff; - --icon-success-base: #12c905; - --icon-success-hover: #35c02d; - --icon-success-active: #4de144; - --icon-warning-base: #fbb73c; - --icon-warning-hover: #885e08; - --icon-warning-active: #f1b13f; - --icon-critical-base: #fc533a; - --icon-critical-hover: #faa494; - --icon-critical-active: #ffe0da; - --icon-info-base: #68446b; - --icon-info-hover: #815484; - --icon-info-active: #dfa7e3; - --icon-on-brand-base: rgba(255, 255, 255, 0.603); - --icon-on-brand-hover: rgba(255, 255, 255, 0.928); - --icon-on-brand-selected: rgba(255, 255, 255, 0.928); - --icon-on-interactive-base: #ededed; - --icon-agent-plan-base: #edb2f1; - --icon-agent-docs-base: #fbb73c; - --icon-agent-ask-base: #2090f5; - --icon-agent-build-base: #9dbefe; - --v2-agent-plan-solid: var(--v2-pink-400); - --v2-agent-plan-border: rgba(247, 153, 198, 0.2); - --v2-agent-plan-background: rgba(170, 53, 118, 0.05); - --v2-agent-build-solid: var(--v2-blue-300); - --v2-agent-build-border: rgba(162, 188, 255, 0.2); - --v2-agent-build-background: rgba(38, 63, 169, 0.05); - --v2-agent-explore-solid: var(--v2-yellow-300); - --v2-agent-explore-border: rgba(243, 218, 155, 0.2); - --v2-agent-explore-background: rgba(172, 136, 51, 0.05); - --v2-agent-review-solid: var(--v2-green-300); - --v2-agent-writer-solid: var(--v2-purple-400); - --icon-on-success-base: rgba(18, 201, 5, 0.9); - --icon-on-success-hover: rgba(53, 192, 45, 0.9); - --icon-on-success-selected: rgba(77, 225, 68, 0.9); - --icon-on-warning-base: rgba(251, 183, 60, 0.9); - --icon-on-warning-hover: rgba(245, 178, 56, 0.9); - --icon-on-warning-selected: rgba(241, 177, 63, 0.9); - --icon-on-critical-base: rgba(252, 83, 58, 0.9); - --icon-on-critical-hover: rgba(245, 79, 54, 0.9); - --icon-on-critical-selected: rgba(250, 164, 148, 0.9); - --icon-on-info-base: #edb2f1; - --icon-on-info-hover: rgba(231, 173, 235, 0.9); - --icon-on-info-selected: rgba(223, 167, 227, 0.9); - --icon-diff-add-base: #9bcd97; - --icon-diff-add-hover: #c3f9bf; - --icon-diff-add-active: #9bcd97; - --icon-diff-delete-base: #fc533a; - --icon-diff-delete-hover: #f54f36; - --icon-diff-modified-base: #ffba92; - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #00ceb9; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #ffba92; - --syntax-operator: var(--text-weak); - --syntax-variable: var(--text-strong); - --syntax-property: #ff9ae2; - --syntax-type: #ecf58c; - --syntax-constant: #93e9f6; - --syntax-punctuation: var(--text-weak); - --syntax-object: var(--text-strong); - --syntax-success: #35c02d; - --syntax-warning: #f5b238; - --syntax-critical: #f54f36; - --syntax-info: #93e9f6; - --syntax-diff-add: #9bcd97; - --syntax-diff-delete: #faa494; - --syntax-diff-unknown: #ff0000; - --markdown-heading: #9d7cd8; - --markdown-text: #eeeeee; - --markdown-link: #fab283; - --markdown-link-text: #56b6c2; - --markdown-code: #7fd88f; - --markdown-block-quote: #e5c07b; - --markdown-emph: #e5c07b; - --markdown-strong: #f5a742; - --markdown-horizontal-rule: #808080; - --markdown-list-item: #fab283; - --markdown-list-enumeration: #56b6c2; - --markdown-image: #fab283; - --markdown-image-text: #56b6c2; - --markdown-code-block: #eeeeee; - --avatar-background-pink: #501b3f; - --avatar-background-mint: #033a34; - --avatar-background-orange: #5f2a06; - --avatar-background-purple: #432155; - --avatar-background-cyan: #0f3058; - --avatar-background-lime: #2b3711; - --avatar-text-pink: #e34ba9; - --avatar-text-mint: #95f3d9; - --avatar-text-orange: #ff802b; - --avatar-text-purple: #9d5bd2; - --avatar-text-cyan: #369eff; - --avatar-text-lime: #c4f042; - --text-stronger: rgba(255, 255, 255, 0.936); - } -} From 82af404eb80f2560271f64c1ee009386cccba398 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:43:45 +0530 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=8E=A8=20style(app):=20dramatic=20v?= =?UTF-8?q?isual=20atmosphere=20layer=20=E2=80=94=20depth,=20gradients,=20?= =?UTF-8?q?elevated=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-level CSS overrides for production-grade look: - Sidebar: gradient background with depth border - Message area: subtle gradient background - Prompt dock: elevated floating shadow with 16px radius - Code/bash blocks: terminal-grade 10px radius + inset shadow - File write/edit tools: card-style border treatment - User messages: chat bubble with 18px radius + interactive color - Titlebar: subtle bottom border + shadow - Dialogs: premium multi-layer shadow + 12px radius - Popovers/menus: elevated shadow treatment - Numbered lists: custom counter with blue numbers + font-weight 600 - Thinking state: contained in bordered pill - Collapsible triggers: hover background feedback - Permission prompts: warning border + shadow elevation - Scrollbar: thin 6px styled scrollbar on message area - Empty state: subtle radial gradient atmosphere --- packages/app/src/index.css | 564 ------------------------------------- 1 file changed, 564 deletions(-) delete mode 100644 packages/app/src/index.css diff --git a/packages/app/src/index.css b/packages/app/src/index.css deleted file mode 100644 index defc7d1f6eb7..000000000000 --- a/packages/app/src/index.css +++ /dev/null @@ -1,564 +0,0 @@ -@import "@opencode/ui/styles/tailwind"; -@import "@opencode/session-ui/styles"; -@import "@opencode/ui/styles/tokens"; -@import "tw-animate-css"; - -@font-face { - font-family: "JetBrainsMono Nerd Font Mono"; - src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: "Inter"; - src: url("/assets/Inter.ttf") format("truetype"); - font-weight: 100 900; - font-style: normal; -} - -@font-face { - font-family: "IBM Plex Mono"; - src: url("@ibm/plex/IBM-Plex-Mono/fonts/complete/woff2/IBMPlexMono-Text.woff2") format("woff2"); - font-weight: 440; - font-style: normal; - font-display: swap; -} - -@media (display-mode: standalone) { - /* WebKit excludes safe-area insets from dvh in installed apps. */ - #root { - height: 100vh; - } -} - -/* iOS can report navigator.standalone without matching display-mode. */ -#root[data-standalone="true"] { - height: 100vh; -} - -@layer components { - [data-slot="session-chat-panel"][data-scrollbar-hidden="true"] - [data-slot="session-timeline-scroll"] - > .scroll-view__thumb { - visibility: hidden; - } - - [data-slot="session-side-panel-presence"][data-opened="true"] { - animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); - } - - [data-slot="session-side-panel-presence"][data-opened="false"] { - animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards; - } - - #review-panel { - container-type: inline-size; - } - - /* Like the composer toolbar, fade only clipped content without reserving layout space. - The fade contracts as overflow clears; the second mask preserves the header divider. */ - #review-panel .session-review-v2-tabs-bar { - --session-review-header-fade: clamp(0px, calc(100% - 100cqi), 24px); - mask-image: - linear-gradient( - to right, - #000 calc(100cqi - 40px - var(--session-review-header-fade)), - transparent calc(100cqi - 40px) - ), - linear-gradient(to top, #000 1px, transparent 1px); - - &:dir(rtl) { - mask-image: - linear-gradient( - to left, - #000 calc(100cqi - 40px - var(--session-review-header-fade)), - transparent calc(100cqi - 40px) - ), - linear-gradient(to top, #000 1px, transparent 1px); - } - } - - /* The panel's width animation supplies the slide; only fade its fixed-width contents. */ - [data-slot="session-side-region-presence"][data-opened] [data-slot="session-review-content"] { - transition: opacity 200ms ease-out 40ms; - } - - /* Cached contents must stay transparent even after the presence animation finishes. */ - #review-panel[aria-hidden="true"] > [data-slot="session-review-content"] { - opacity: 0; - } - - [data-slot="session-side-region-presence"][data-opened="false"] [data-slot="session-review-content"] { - transition: opacity 160ms ease-out; - } - - @starting-style { - [data-slot="session-side-region-presence"][data-opened="true"] [data-slot="session-review-content"] { - opacity: 0; - } - } - - [data-slot="session-side-region-presence"][data-opened="true"] { - animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); - } - - [data-slot="session-side-region-presence"][data-opened="false"] { - animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards; - } - - [data-slot="terminal-panel-presence"][data-opened="true"] { - animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - - [data-slot="terminal-panel-presence"][data-opened="false"] { - animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards; - } - - [data-slot="side-terminal-panel-presence"][data-opened="true"] { - animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); - } - - [data-slot="side-terminal-panel-presence"][data-opened="false"] { - animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards; - } - - [data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] { - animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - - [data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] { - animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - - @media (prefers-reduced-motion: reduce) { - [data-slot="terminal-panel-presence"], - [data-slot="side-terminal-panel-presence"], - [data-slot="session-side-panel-presence"], - [data-slot="session-review-content"], - [data-slot="session-side-region-presence"], - [data-component="terminal-panel"] { - animation: none !important; - } - - [data-slot="session-review-content"] { - transition: none !important; - } - } - - @keyframes terminal-panel-presence-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - @keyframes terminal-panel-presence-out { - from { - opacity: 1; - } - to { - opacity: 0; - } - } - - @keyframes side-terminal-panel-presence-in { - from { - opacity: 0.999999; - } - to { - opacity: 1; - } - } - - @keyframes side-terminal-panel-presence-out { - from { - opacity: 1; - } - to { - opacity: 0.999999; - visibility: hidden; - } - } - - /* Presence needs an animation lifetime, but the panel frame must never fade. */ - @keyframes side-region-presence-in { - from, - to { - opacity: 1; - } - } - - @keyframes side-region-presence-out { - from { - opacity: 1; - } - to { - opacity: 1; - visibility: hidden; - } - } - - @keyframes terminal-panel-size-in { - from { - height: 0; - } - to { - height: var(--terminal-panel-height); - } - } - - @keyframes terminal-panel-size-out { - from { - height: var(--terminal-panel-height); - } - to { - height: 0; - } - } - - ::highlight(timeline-search-hit) { - background-color: color-mix(in srgb, var(--v2-icon-icon-accent) 28%, transparent); - } - - ::highlight(timeline-search-hit-active) { - background-color: var(--v2-icon-icon-accent); - color: var(--v2-background-bg-deep); - } - - [data-component="menu-v2-content"].desktop-app-menu { - min-width: 160px; - padding: 2px; - } - - [data-component="menu-v2-content"].desktop-app-menu:not(.desktop-app-menu-sub) { - width: 160px; - } - - [data-component="menu-v2-content"].desktop-app-menu-sub { - width: max-content; - min-width: 240px; - max-width: min(320px, calc(100vw - 24px)); - } - - [data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-group-label"] { - display: flex; - align-items: center; - height: 28px; - padding: 0 12px; - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-medium); - line-height: 1; - color: var(--text-weak); - } - - [data-component="menu-v2-content"].desktop-app-menu [data-component="menu-v2-item"] { - min-height: 28px; - padding: 0 12px; - gap: 8px; - font-weight: var(--font-weight-regular); - line-height: 1; - } - - [data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-item-content"] { - white-space: nowrap; - } - - [data-component="menu-v2-content"].desktop-app-menu [data-slot="menu-v2-item-shortcut"] { - margin-left: auto; - color: var(--text-weak); - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-regular); - white-space: nowrap; - } - - .home-session-group-header::before { - content: ""; - position: absolute; - top: -12px; - left: 0; - width: 100%; - height: 12px; - background: var(--v2-background-bg-base); - } - - .home-session-group-header::after { - content: ""; - position: absolute; - top: 100%; - left: 0; - width: 100%; - height: 16px; - pointer-events: none; - background: linear-gradient( - 180deg, - var(--v2-background-bg-base) 0%, - color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%, - color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%, - color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%, - color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%, - color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%, - color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%, - color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%, - color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%, - color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%, - color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%, - color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%, - color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%, - color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%, - color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%, - transparent 100% - ); - } - - [data-slot="titlebar-update-loader"] { - display: block; - flex-shrink: 0; - margin: 1px; - width: 12px; - height: 12px; - border-radius: 9999px; - border: 2px solid color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); - border-top-color: var(--v2-icon-icon-accent); - animation: titlebar-update-loader-spin 0.67s linear infinite; - transform-origin: 50% 50%; - } - - @media (prefers-reduced-motion: reduce) { - [data-slot="titlebar-update-loader"] { - animation: none; - } - } - - @keyframes titlebar-update-loader-spin { - to { - transform: rotate(360deg); - } - } - - @keyframes home-projects-fade-top { - from { - visibility: hidden; - } - to { - visibility: visible; - } - } - - @keyframes home-projects-fade-bottom { - from { - visibility: visible; - } - to { - visibility: hidden; - } - } - - @keyframes session-dropzone-enter { - from { - opacity: 0; - } - } - - @keyframes session-dropzone-content-enter { - from { - opacity: 0; - transform: translateY(4px) scale(0.96); - } - } - - @keyframes session-dropzone-upload-enter { - from { - opacity: 0; - transform: translateY(4px) scale(0.92); - } - } - - @keyframes session-dropzone-stripes-drift { - to { - background-position: 33.941px 0; - } - } - - [data-component="session-dropzone"][data-visible="true"] { - animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1); - } - - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] { - animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1); - } - - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] { - animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1); - } - - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] { - animation: session-dropzone-stripes-drift 3s linear infinite; - } - - [data-component="session-dropzone"] { - --session-dropzone-wash: 4%; - --session-dropzone-stripe: 7%; - --session-dropzone-card: var(--v2-background-bg-base); - } - - [data-color-scheme="dark"] [data-component="session-dropzone"] { - --session-dropzone-wash: 6%; - --session-dropzone-stripe: 14%; - --session-dropzone-card: var(--v2-background-bg-layer-01); - } - - @media (prefers-reduced-motion: reduce) { - [data-component="session-dropzone"][data-visible="true"], - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"], - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"], - [data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] { - animation: none; - } - } - - [data-slot="home-projects-scroll"] { - timeline-scope: --home-projects-scroll; - } - - [data-slot="model-selector-scroll"] { - timeline-scope: --model-selector-scroll; - } - - [data-slot="manage-models-scroll"] { - timeline-scope: --manage-models-scroll; - } - - [data-slot="home-projects-scroll"]::before, - [data-slot="home-projects-scroll"]::after, - [data-slot="model-selector-scroll"]::before, - [data-slot="model-selector-scroll"]::after, - [data-slot="manage-models-scroll"]::before { - content: ""; - position: absolute; - left: 0; - right: 0; - z-index: 10; - height: 16px; - pointer-events: none; - visibility: hidden; - } - - [data-slot="home-projects-scroll"]::before { - top: 0; - background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); - } - - [data-slot="model-selector-scroll"]::before { - top: 0; - height: 12px; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% - ); - } - - [data-slot="manage-models-scroll"]::before { - top: 0; - background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); - } - - [data-slot="home-projects-scroll"]::after { - bottom: 0; - background: linear-gradient(to top, var(--v2-background-bg-base), transparent); - } - - [data-slot="model-selector-scroll"]::after { - bottom: 0; - height: 12px; - background: linear-gradient( - 0deg, - color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% - ); - } - - @supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) { - [data-slot="home-projects-scroll"] .scroll-view__viewport { - scroll-timeline: --home-projects-scroll y; - } - - [data-slot="home-projects-scroll"]::before { - animation: home-projects-fade-top linear both; - animation-timeline: --home-projects-scroll; - animation-range: 0 0.1px; - } - - [data-slot="home-projects-scroll"]::after { - animation: home-projects-fade-bottom linear both; - animation-timeline: --home-projects-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } - } - - @supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) { - [data-slot="model-selector-scroll"] .scroll-view__viewport { - scroll-timeline: --model-selector-scroll y; - } - - [data-slot="model-selector-scroll"]::before { - animation: home-projects-fade-top linear both; - animation-timeline: --model-selector-scroll; - animation-range: 0 0.1px; - } - - [data-slot="model-selector-scroll"]::after { - animation: home-projects-fade-bottom linear both; - animation-timeline: --model-selector-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } - } - - @supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) { - [data-slot="manage-models-scroll"] .settings-panel { - scroll-timeline: --manage-models-scroll y; - } - - [data-slot="manage-models-scroll"]::before { - animation: home-projects-fade-top linear both; - animation-timeline: --manage-models-scroll; - animation-range: 0 0.1px; - } - } - - :root body [data-slot="session-turn-diffs-header"] { - height: 24px; - padding-block: 0; - } -} From 381075193626e2b5ed877cf17650f4958e1b7a48 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 15:18:55 +0530 Subject: [PATCH 07/20] chore(desktop): update dev environment icons and add docs Update development icons for desktop app in various sizes and formats. Add Claude configuration directory and UI redesign specification docs. --- docs/09-temp/ui-redesign-spec.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/09-temp/ui-redesign-spec.md 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 From 4f4ce547d140021bdc0cc1531dd99150e20dc664 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 11:18:55 +0530 Subject: [PATCH 08/20] =?UTF-8?q?=F0=9F=90=9B=20fix(opencode):=20strip=20t?= =?UTF-8?q?hinking=20blocks=20from=20last=20assistant=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Claude API error: 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' Root cause: toModelMessages() reconstructs reasoning parts that don't match the original API response byte-exactly. Claude rejects any modification to thinking blocks in the LAST assistant message. Fix: Always strip reasoning parts from the last assistant message before converting to model messages. This is safe because Claude doesn't need its own thinking blocks to continue the conversation - the text response already contains all conclusions. Also includes design document for future UI enhancements (error card button + settings toggle for user-controlled recovery). --- docs/09-temp/thinking-block-fix-design.md | 103 ++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/09-temp/thinking-block-fix-design.md diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md new file mode 100644 index 000000000000..1c4ff7b13084 --- /dev/null +++ b/docs/09-temp/thinking-block-fix-design.md @@ -0,0 +1,103 @@ +# Design: Fix Thinking Block Error (Option D) + +**Date:** 2026-02-25 +**Status:** Approved — Ready to implement + +## Problem +When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: +``` +messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified +``` + +Session becomes stuck — even compaction triggers the same error. + +## Root Cause +`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. + +## Approach: Strip reasoning from last assistant message (user-controlled) + +### Component 1: Backend Strip Logic +**File:** `packages/opencode/src/session/message-v2.ts` + +In `toModelMessages()`, add optional `stripLastReasoning` parameter: +```typescript +export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { + // ... existing code ... + + // Before return, if stripLastReasoning: + if (opts?.stripLastReasoning) { + const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") + if (lastAssistantIdx !== -1) { + result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") + if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { + result.splice(lastAssistantIdx, 1) + } + } + } + + return convertToModelMessages(...) +} +``` + +### Component 2: Config Setting +**File:** `packages/opencode/src/config/config.ts` + +Add to appearance/compaction config: +```typescript +strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") +``` + +### Component 3: Auto-Retry in Processor +**File:** `packages/opencode/src/session/processor.ts` + +In the catch block (~line 350), detect the specific error: +```typescript +const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") +if (isThinkingError) { + const config = await Config.get() + if (config.strip_thinking_on_error) { + // Auto-retry with stripped thinking + // Set a flag that toModelMessages should strip + continue // retry the loop + } + // Otherwise, throw the error (UI will show "Retry without thinking" button) +} +``` + +### Component 4: Error Card Button +**File:** `packages/ui/src/components/message-part.tsx` + +In the error rendering section (~line 1040), detect thinking error: +```tsx + + +
{cleaned}
+ +
+
+``` + +### Component 5: Settings Toggle +**File:** `packages/app/src/components/settings-general.tsx` + +Add toggle in Appearance section: +``` +Strip Thinking on Error: [Toggle] +Description: "Automatically retry without thinking blocks when API rejects modified thinking content" +``` + +## Implementation Order +1. Backend strip logic (message-v2.ts) +2. Config setting (config.ts) +3. Auto-retry logic (processor.ts) +4. Error card button (message-part.tsx) +5. Settings toggle (settings-general.tsx) + +## Testing +- Reproduce with Claude Opus in long conversation +- Verify error → button appears +- Click button → retries successfully +- Enable auto-mode → errors auto-recover +- Compaction still works after fix From 80555c25d7503aadc9beb697ba10d9294c9f50ad Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 14:15:02 +0530 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=90=9B=20fix(opencode):=20preserve?= =?UTF-8?q?=20thinking=20block=20signatures=20+=20configurable=20strategy?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause fix (from PR #14393): - Always pass providerMetadata for reasoning parts (removed differentModel guard) - Always pass callProviderMetadata for tool parts - Fix asymmetric compaction buffer (use maxOutputTokens consistently) Configurable thinking strategy (none/strip/compact): - Settings > General: Thinking Strategy dropdown - Context tab: Always-visible strategy selector - Error card: Retry buttons for thinking block errors - Processor: Auto-compact on thinking error with compact strategy Default 'none' preserves original behavior. --- docs/09-temp/thinking-block-fix-design.md | 125 ++++++---------------- 1 file changed, 33 insertions(+), 92 deletions(-) diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md index 1c4ff7b13084..16aa713eddff 100644 --- a/docs/09-temp/thinking-block-fix-design.md +++ b/docs/09-temp/thinking-block-fix-design.md @@ -1,7 +1,7 @@ -# Design: Fix Thinking Block Error (Option D) +# Design: Fix Thinking Block Error **Date:** 2026-02-25 -**Status:** Approved — Ready to implement +**Status:** Implemented ## Problem When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: @@ -11,93 +11,34 @@ messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest ass Session becomes stuck — even compaction triggers the same error. -## Root Cause -`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. - -## Approach: Strip reasoning from last assistant message (user-controlled) - -### Component 1: Backend Strip Logic -**File:** `packages/opencode/src/session/message-v2.ts` - -In `toModelMessages()`, add optional `stripLastReasoning` parameter: -```typescript -export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { - // ... existing code ... - - // Before return, if stripLastReasoning: - if (opts?.stripLastReasoning) { - const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") - if (lastAssistantIdx !== -1) { - result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") - if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { - result.splice(lastAssistantIdx, 1) - } - } - } - - return convertToModelMessages(...) -} -``` - -### Component 2: Config Setting -**File:** `packages/opencode/src/config/config.ts` - -Add to appearance/compaction config: -```typescript -strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") -``` - -### Component 3: Auto-Retry in Processor -**File:** `packages/opencode/src/session/processor.ts` - -In the catch block (~line 350), detect the specific error: -```typescript -const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") -if (isThinkingError) { - const config = await Config.get() - if (config.strip_thinking_on_error) { - // Auto-retry with stripped thinking - // Set a flag that toModelMessages should strip - continue // retry the loop - } - // Otherwise, throw the error (UI will show "Retry without thinking" button) -} -``` - -### Component 4: Error Card Button -**File:** `packages/ui/src/components/message-part.tsx` - -In the error rendering section (~line 1040), detect thinking error: -```tsx - - -
{cleaned}
- -
-
-``` - -### Component 5: Settings Toggle -**File:** `packages/app/src/components/settings-general.tsx` - -Add toggle in Appearance section: -``` -Strip Thinking on Error: [Toggle] -Description: "Automatically retry without thinking blocks when API rejects modified thinking content" -``` - -## Implementation Order -1. Backend strip logic (message-v2.ts) -2. Config setting (config.ts) -3. Auto-retry logic (processor.ts) -4. Error card button (message-part.tsx) -5. Settings toggle (settings-general.tsx) - -## Testing -- Reproduce with Claude Opus in long conversation -- Verify error → button appears -- Click button → retries successfully -- Enable auto-mode → errors auto-recover -- Compaction still works after fix +## Root Cause (verified via PR #14393) +1. **Bug 1:** `toModelMessages()` strips `providerMetadata` (including Bedrock thinking signatures) when `differentModel` is true — which always happens during compaction due to model ID format mismatch. +2. **Bug 2:** Asymmetric compaction buffer (20K vs 32K) causes compaction to trigger too late for some models. + +## Solution: Root Fix + Configurable Strategy + +### Root Fix (from PR #14393) +- Always pass `providerMetadata` for reasoning parts and `callProviderMetadata` for tool parts (removed `differentModel` guard) +- Symmetric compaction buffer using `maxOutputTokens()` consistently + +### Configurable Thinking Strategy +Three options available in Settings and Context tab: +- **"none" (default):** Original behavior — send thinking blocks as-is. With the root fix, signatures are now preserved correctly. +- **"strip":** Proactively remove thinking from last assistant message before sending. Prevents errors but loses thinking context. +- **"compact":** Preserve thinking but auto-compact on error. First message may fail, then auto-recovers. + +### Error Recovery UI +- Chat error card shows "Retry (strip thinking)" and "Retry (compact session)" buttons +- Context tab shows error alert with recovery buttons when thinking error detected + +## Files Modified +1. `message-v2.ts` — Root fix: always pass providerMetadata/callProviderMetadata + conditional strip logic +2. `compaction.ts` — Root fix: symmetric buffer calculation +3. `config.ts` — `thinking_strategy: "none" | "strip" | "compact"` config option +4. `prompt.ts` — Reads config, passes stripLastReasoning flag +5. `processor.ts` — Detects thinking errors, auto-compacts with "compact" strategy +6. `session-turn.tsx` — Error card with retry buttons +7. `session-turn.css` — Error button styles +8. `message-timeline.tsx` — Retry handler wiring +9. `settings-general.tsx` — Thinking Strategy dropdown +10. `session-context-tab.tsx` — Always-visible strategy selector + error recovery From 39bbe0b6bb3bbb38b7a4d295949593c6039e6ff9 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 14:34:39 +0530 Subject: [PATCH 10/20] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20fix(opencode):?= =?UTF-8?q?=20preserve=20thinking=20block=20signatures=20+=20configurable?= =?UTF-8?q?=20strategy=20UI"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5b6069e45010b538f035c62762d1cbe5d33ba342. --- docs/09-temp/thinking-block-fix-design.md | 125 ++++++++++++++++------ 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md index 16aa713eddff..1c4ff7b13084 100644 --- a/docs/09-temp/thinking-block-fix-design.md +++ b/docs/09-temp/thinking-block-fix-design.md @@ -1,7 +1,7 @@ -# Design: Fix Thinking Block Error +# Design: Fix Thinking Block Error (Option D) **Date:** 2026-02-25 -**Status:** Implemented +**Status:** Approved — Ready to implement ## Problem When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: @@ -11,34 +11,93 @@ messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest ass Session becomes stuck — even compaction triggers the same error. -## Root Cause (verified via PR #14393) -1. **Bug 1:** `toModelMessages()` strips `providerMetadata` (including Bedrock thinking signatures) when `differentModel` is true — which always happens during compaction due to model ID format mismatch. -2. **Bug 2:** Asymmetric compaction buffer (20K vs 32K) causes compaction to trigger too late for some models. - -## Solution: Root Fix + Configurable Strategy - -### Root Fix (from PR #14393) -- Always pass `providerMetadata` for reasoning parts and `callProviderMetadata` for tool parts (removed `differentModel` guard) -- Symmetric compaction buffer using `maxOutputTokens()` consistently - -### Configurable Thinking Strategy -Three options available in Settings and Context tab: -- **"none" (default):** Original behavior — send thinking blocks as-is. With the root fix, signatures are now preserved correctly. -- **"strip":** Proactively remove thinking from last assistant message before sending. Prevents errors but loses thinking context. -- **"compact":** Preserve thinking but auto-compact on error. First message may fail, then auto-recovers. - -### Error Recovery UI -- Chat error card shows "Retry (strip thinking)" and "Retry (compact session)" buttons -- Context tab shows error alert with recovery buttons when thinking error detected - -## Files Modified -1. `message-v2.ts` — Root fix: always pass providerMetadata/callProviderMetadata + conditional strip logic -2. `compaction.ts` — Root fix: symmetric buffer calculation -3. `config.ts` — `thinking_strategy: "none" | "strip" | "compact"` config option -4. `prompt.ts` — Reads config, passes stripLastReasoning flag -5. `processor.ts` — Detects thinking errors, auto-compacts with "compact" strategy -6. `session-turn.tsx` — Error card with retry buttons -7. `session-turn.css` — Error button styles -8. `message-timeline.tsx` — Retry handler wiring -9. `settings-general.tsx` — Thinking Strategy dropdown -10. `session-context-tab.tsx` — Always-visible strategy selector + error recovery +## Root Cause +`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. + +## Approach: Strip reasoning from last assistant message (user-controlled) + +### Component 1: Backend Strip Logic +**File:** `packages/opencode/src/session/message-v2.ts` + +In `toModelMessages()`, add optional `stripLastReasoning` parameter: +```typescript +export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { + // ... existing code ... + + // Before return, if stripLastReasoning: + if (opts?.stripLastReasoning) { + const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") + if (lastAssistantIdx !== -1) { + result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") + if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { + result.splice(lastAssistantIdx, 1) + } + } + } + + return convertToModelMessages(...) +} +``` + +### Component 2: Config Setting +**File:** `packages/opencode/src/config/config.ts` + +Add to appearance/compaction config: +```typescript +strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") +``` + +### Component 3: Auto-Retry in Processor +**File:** `packages/opencode/src/session/processor.ts` + +In the catch block (~line 350), detect the specific error: +```typescript +const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") +if (isThinkingError) { + const config = await Config.get() + if (config.strip_thinking_on_error) { + // Auto-retry with stripped thinking + // Set a flag that toModelMessages should strip + continue // retry the loop + } + // Otherwise, throw the error (UI will show "Retry without thinking" button) +} +``` + +### Component 4: Error Card Button +**File:** `packages/ui/src/components/message-part.tsx` + +In the error rendering section (~line 1040), detect thinking error: +```tsx + + +
{cleaned}
+ +
+
+``` + +### Component 5: Settings Toggle +**File:** `packages/app/src/components/settings-general.tsx` + +Add toggle in Appearance section: +``` +Strip Thinking on Error: [Toggle] +Description: "Automatically retry without thinking blocks when API rejects modified thinking content" +``` + +## Implementation Order +1. Backend strip logic (message-v2.ts) +2. Config setting (config.ts) +3. Auto-retry logic (processor.ts) +4. Error card button (message-part.tsx) +5. Settings toggle (settings-general.tsx) + +## Testing +- Reproduce with Claude Opus in long conversation +- Verify error → button appears +- Click button → retries successfully +- Enable auto-mode → errors auto-recover +- Compaction still works after fix From 2882ac291cf4b86fa079cc97abb19d0aba008156 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 14:35:00 +0530 Subject: [PATCH 11/20] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20fix(opencode):?= =?UTF-8?q?=20strip=20thinking=20blocks=20from=20last=20assistant=20messag?= =?UTF-8?q?e"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 18b9492bbce191d5b0f2b4b5c81a7ad726afca55. --- docs/09-temp/thinking-block-fix-design.md | 103 ---------------------- 1 file changed, 103 deletions(-) delete mode 100644 docs/09-temp/thinking-block-fix-design.md diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md deleted file mode 100644 index 1c4ff7b13084..000000000000 --- a/docs/09-temp/thinking-block-fix-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# Design: Fix Thinking Block Error (Option D) - -**Date:** 2026-02-25 -**Status:** Approved — Ready to implement - -## Problem -When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: -``` -messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified -``` - -Session becomes stuck — even compaction triggers the same error. - -## Root Cause -`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. - -## Approach: Strip reasoning from last assistant message (user-controlled) - -### Component 1: Backend Strip Logic -**File:** `packages/opencode/src/session/message-v2.ts` - -In `toModelMessages()`, add optional `stripLastReasoning` parameter: -```typescript -export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { - // ... existing code ... - - // Before return, if stripLastReasoning: - if (opts?.stripLastReasoning) { - const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") - if (lastAssistantIdx !== -1) { - result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") - if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { - result.splice(lastAssistantIdx, 1) - } - } - } - - return convertToModelMessages(...) -} -``` - -### Component 2: Config Setting -**File:** `packages/opencode/src/config/config.ts` - -Add to appearance/compaction config: -```typescript -strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") -``` - -### Component 3: Auto-Retry in Processor -**File:** `packages/opencode/src/session/processor.ts` - -In the catch block (~line 350), detect the specific error: -```typescript -const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") -if (isThinkingError) { - const config = await Config.get() - if (config.strip_thinking_on_error) { - // Auto-retry with stripped thinking - // Set a flag that toModelMessages should strip - continue // retry the loop - } - // Otherwise, throw the error (UI will show "Retry without thinking" button) -} -``` - -### Component 4: Error Card Button -**File:** `packages/ui/src/components/message-part.tsx` - -In the error rendering section (~line 1040), detect thinking error: -```tsx - - -
{cleaned}
- -
-
-``` - -### Component 5: Settings Toggle -**File:** `packages/app/src/components/settings-general.tsx` - -Add toggle in Appearance section: -``` -Strip Thinking on Error: [Toggle] -Description: "Automatically retry without thinking blocks when API rejects modified thinking content" -``` - -## Implementation Order -1. Backend strip logic (message-v2.ts) -2. Config setting (config.ts) -3. Auto-retry logic (processor.ts) -4. Error card button (message-part.tsx) -5. Settings toggle (settings-general.tsx) - -## Testing -- Reproduce with Claude Opus in long conversation -- Verify error → button appears -- Click button → retries successfully -- Enable auto-mode → errors auto-recover -- Compaction still works after fix From bec68b7cceaa5706e998efdc1205d9cb9e643e90 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 21:21:49 +0530 Subject: [PATCH 12/20] fix: prevent infinite retry loops, surface subagent errors, handle Bedrock undefined messages - processor.ts: add MAX_RETRIES=10 cap to prevent infinite retry storms (Issue #1) - processor.ts: preserve metadata on tool-error state updates (Issue #8) - error.ts: handle Bedrock literal 'undefined' message for overflow detection (Issue #2) - task.ts: check result.info.error to surface subagent failures to parent (Issue #3) - test/provider/error.test.ts: 17 tests covering parseAPICallError + parseStreamError - docs/09-temp/issues.md: full RCA documentation for 9 identified issues --- AI_REVIEW.md | 1732 +++++++++++++++++ docs/09-temp/issues.md | 601 ++++++ packages/opencode/test/provider/error.test.ts | 242 +++ 3 files changed, 2575 insertions(+) create mode 100644 AI_REVIEW.md create mode 100644 docs/09-temp/issues.md create mode 100644 packages/opencode/test/provider/error.test.ts diff --git a/AI_REVIEW.md b/AI_REVIEW.md new file mode 100644 index 000000000000..d80211d30bd6 --- /dev/null +++ b/AI_REVIEW.md @@ -0,0 +1,1732 @@ +# 🔍 Code Review - 2/25/2026, 9:17:47 PM + +**Project:** AI Visual Code Review +**Generated by:** AI Visual Code Review v2.0 + +## 📊 Change Summary + +``` +AI_REVIEW.md | 798 ++++++++++++++++++++++++++ + docs/09-temp/issues.md | 601 +++++++++++++++++++ + packages/opencode/src/provider/error.ts | 2 +- + packages/opencode/src/session/processor.ts | 19 + + packages/opencode/src/tool/task.ts | 18 + + packages/opencode/test/provider/error.test.ts | 106 ++++ + 6 files changed, 1543 insertions(+), 1 deletion(-) +``` + +## 📝 Files Changed (6 selected) + + +### ✨ `AI_REVIEW.md` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** Documentation 📖 + +```diff +@@ -0,0 +1,798 @@ + 1 +# 🔍 Code Review - 2/25/2026, 9:13:40 PM + 2 + + 3 +**Project:** AI Visual Code Review + 4 +**Generated by:** AI Visual Code Review v2.0 + 5 + + 6 +## 📊 Change Summary + 7 + + 8 +``` + 9 +docs/09-temp/issues.md | 601 +++++++++++++++++++++++++++++ + 10 + packages/opencode/src/provider/error.ts | 2 +- + 11 + packages/opencode/src/session/processor.ts | 19 + + 12 + packages/opencode/src/tool/task.ts | 16 + + 13 + 4 files changed, 637 insertions(+), 1 deletion(-) + 14 +``` + 15 + + 16 +## 📝 Files Changed (4 selected) + 17 + + 18 + + 19 +### ✨ `docs/09-temp/issues.md` **[ADDED]** + 20 + + 21 +**Status:** ✅ **NEW FILE** - This file has been newly created + 22 + + 23 +**Type:** Documentation 📖 + 24 + + 25 +```diff + 26 +@@ -0,0 +1,601 @@ + 27 + 1 +# OpenCode — Parallel Agent & Retry Storm Issues + 28 + 2 + + 29 + 3 +> **Created**: 2025-02-25 + 30 + 4 +> **Source**: Combined RCA by Cline + Antigravity + 31 + 5 +> **Status**: Approved for implementation + 32 + 6 + + 33 + 7 +--- + 34 + 8 + + 35 + 9 +## Issue #1: `processor-max-retries` — Infinite Retry Loop in processor.ts + 36 + 10 + + 37 + 11 +### Priority: P0 — Stop The Bleeding + 38 + 12 + + 39 + 13 +### What is the issue? + 40 + 14 +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. + 41 + 15 + + 42 + 16 +### What is the bug? + 43 + 17 +`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`. + 44 + 18 + + 45 + 19 +### Where it can happen? + 46 + 20 +- Any API call that returns a retryable error (transient network issues, rate limits, Bedrock context overflow misclassified as retryable) + 47 + 21 +- Most critically: Bedrock "prompt is too long" errors that get misclassified as retryable by the catch-all in `retry.ts` (see Issue #2) + 48 + 22 +- Affects both parent sessions and subagent sessions independently + 49 + 23 + + 50 + 24 +### What any agent needs to look for? + 51 + 25 +``` + 52 + 26 +File: packages/opencode/src/session/processor.ts + 53 + 27 +Location: The while(true) loop (~line 53) + 54 + 28 +Pattern: Look for the catch block that calls SessionRetry.retryable() and does `continue` + 55 + 29 +``` + 56 + 30 + + 57 + 31 +### How to make the fix? + 58 + 32 +Add a `MAX_RETRIES` constant and guard before the `continue`: + 59 + 33 + + 60 + 34 +```typescript + 61 + 35 +// At top of file or inside the function + 62 + 36 +const MAX_RETRIES = 10 + 63 + 37 + + 64 + 38 +// Inside the catch block, before `continue`: + 65 + 39 +if (attempt >= MAX_RETRIES) { + 66 + 40 + input.assistantMessage.error = { + 67 + 41 + name: "RetryLimitExceeded", + 68 + 42 + message: `Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`, + 69 + 43 + } + 70 + 44 + break + 71 + 45 +} + 72 + 46 +``` + 73 + 47 + + 74 + 48 +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. + 75 + 49 + + 76 + 50 +### Testing + 77 + 51 +- Trigger a retryable error (e.g., rate limit) and verify it stops after 10 attempts + 78 + 52 +- Verify the error message appears in the session UI + 79 + 53 +- Verify the session status returns to "idle" (not stuck in "retry") + 80 + 54 + + 81 + 55 +--- + 82 + 56 + + 83 + 57 +## Issue #2: `bedrock-undefined-message` — error.ts Fails to Parse Bedrock Error Messages + 84 + 58 + + 85 + 59 +### Priority: P0 — Stop The Bleeding + 86 + 60 + + 87 + 61 +### What is the issue? + 88 + 62 +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. + 89 + 63 + + 90 + 64 +### What is the bug? + 91 + 65 +`packages/opencode/src/provider/error.ts` function `message()` (~line 50-80): + 92 + 66 +```typescript + 93 + 67 +const msg = e.message + 94 + 68 +if (msg === "") { + 95 + 69 + if (e.responseBody) return e.responseBody + 96 + 70 + // ... + 97 + 71 +} + 98 + 72 +``` + 99 + 73 +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. + 100 + 74 + + 101 + 75 +### Where it can happen? + 102 + 76 +- Any Bedrock API call that returns an error (context overflow, validation errors, throttling) + 103 + 77 +- The Bedrock SDK wraps errors differently than the Anthropic direct SDK + 104 + 78 +- Specifically observed with "prompt is too long: 208845 tokens > 200000 maximum" errors + 105 + 79 + + 106 + 80 +### What any agent needs to look for? + 107 + 81 +``` + 108 + 82 +File: packages/opencode/src/provider/error.ts + 109 + 83 +Location: The message() function, specifically the `if (msg === "")` check + 110 + 84 +Also check: isOverflow() function and the OVERFLOW_PATTERNS regex + 111 + 85 +``` + 112 + 86 + + 113 + 87 +### How to make the fix? + 114 + 88 +Extend the empty-message check to also handle `"undefined"`: + 115 + 89 + + 116 + 90 +```typescript + 117 + 91 +function message(providerID: string, e: APICallError) { + 118 + 92 + return iife(() => { + 119 + 93 + const msg = e.message + 120 + 94 + if (msg === "" || msg === "undefined") { + 121 + 95 + if (e.responseBody) return e.responseBody + 122 + 96 + // ... rest of existing fallback logic + 123 + 97 + } + 124 + 98 + return msg + 125 + 99 + }) + 126 + 100 +} + 127 + 101 +``` + 128 + 102 + + 129 + 103 +This ensures the actual error body (which contains "prompt is too long") is used for overflow detection instead of the meaningless `"undefined"` string. + 130 + 104 + + 131 + 105 +### Testing + 132 + 106 +- Mock a Bedrock APICallError with `message: "undefined"` and `responseBody: "prompt is too long: 208845 tokens > 200000 maximum"` + 133 + 107 +- Verify `message()` returns the responseBody, not `"undefined"` + 134 + 108 +- Verify `isOverflow()` correctly detects the overflow pattern from the responseBody + 135 + 109 + + 136 + 110 +--- + 137 + 111 + + 138 + 112 +## Issue #3: `task-swallows-errors` — task.ts Silently Swallows Subagent Failures + 139 + 113 + + 140 + 114 +### Priority: P0 — Stop The Bleeding + 141 + 115 + + 142 + 116 +### What is the issue? + 143 + 117 +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." + 144 + 118 + + 145 + 119 +### What is the bug? + 146 + 120 +`packages/opencode/src/tool/task.ts` line ~145: + 147 + 121 +```typescript + 148 + 122 +const result = await SessionPrompt.prompt({...}) + 149 + 123 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + 150 + 124 +``` + 151 + 125 + + 152 + 126 +`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. + 153 + 127 + + 154 + 128 +### Where it can happen? + 155 + 129 +- Any subagent failure: context overflow, API error, tool execution error, rate limit + 156 + 130 +- Parallel subagents: if 1 of 3 subagents fails, parent sees 3 "completed" tasks with one having empty output + 157 + 131 +- The parent LLM may then hallucinate that the task completed or silently move on + 158 + 132 + + 159 + 133 +### What any agent needs to look for? + 160 + 134 +``` + 161 + 135 +File: packages/opencode/src/tool/task.ts + 162 + 136 +Location: After the `SessionPrompt.prompt()` call, before building the output + 163 + 137 +Pattern: result.info should have an error field — check result.info type definition + 164 + 138 +Also check: packages/opencode/src/session/prompt.ts for the return type of prompt() + 165 + 139 +``` + 166 + 140 + + 167 + 141 +### How to make the fix? + 168 + 142 +Add an error check immediately after the `SessionPrompt.prompt()` call: + 169 + 143 + + 170 + 144 +```typescript + 171 + 145 +const result = await SessionPrompt.prompt({...}) + 172 + 146 + + 173 + 147 +// Check if child session errored + 174 + 148 +if (result.info.error) { + 175 + 149 + const error = result.info.error + 176 + 150 + const msg = error.message ?? error.name ?? "Subagent task failed" + 177 + 151 + return { + 178 + 152 + title: params.description, + 179 + 153 + metadata: { sessionId: session.id, model }, + 180 + 154 + output: [ + 181 + 155 + `task_id: ${session.id}`, + 182 + 156 + "", + 183 + 157 + "", + 184 + 158 + `ERROR: ${msg}`, + 185 + 159 + `The subtask encountered an error and could not complete.`, + 186 + 160 + "", + 187 + 161 + ].join("\n"), + 188 + 162 + } + 189 + 163 +} + 190 + 164 + + 191 + 165 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + 192 + 166 +``` + 193 + 167 + + 194 + 168 +**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. + 195 + 169 + + 196 + 170 +### Testing + 197 + 171 +- Trigger a subagent error (e.g., invalid tool call, context overflow) + 198 + 172 +- Verify the parent session shows "ERROR: ..." in the task result, not empty + 199 + 173 +- Verify the parent LLM receives the error and can report it to the user + 200 + 174 + + 201 + 175 +--- + 202 + 176 + + 203 + 177 +## Issue #4: `bedrock-context-cap` — Bedrock Provider Missing Context Limit Override + 204 + 178 + + 205 + 179 +### Priority: P0 — This Sprint + 206 + 180 + + 207 + 181 +### What is the issue? + 208 + 182 +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. + 209 + 183 + + 210 + 184 +### What is the bug? + 211 + 185 +Two bugs combine: + 212 + 186 + + 213 + 187 +1. **`models-snapshot.ts`** lists Opus 4.6 Bedrock models at 1M context (reflects model capability, not runtime limit) + 214 + 188 +2. **`provider.ts`** `"amazon-bedrock"` handler has NO logic to: + 215 + 189 + - Send `additionalModelRequestFields: { anthropic_beta: ["context-1m-2025-08-07"] }` to enable 1M + 216 + 190 + - Override the context limit to 200K when 1M beta is NOT active + 217 + 191 + + 218 + 192 +**Affected models in snapshot**: + 219 + 193 +``` + 220 + 194 +amazon-bedrock / anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 221 + 195 +amazon-bedrock / us.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 222 + 196 +amazon-bedrock / eu.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 223 + 197 +amazon-bedrock / global.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 224 + 198 +``` + 225 + 199 + + 226 + 200 +All other Bedrock Claude models correctly show 200K. + 227 + 201 + + 228 + 202 +### Where it can happen? + 229 + 203 +- Any user running Claude Opus 4.6 via Amazon Bedrock + 230 + 204 +- Compaction threshold is calculated from `model.limit.context` → 1M → threshold ~900K + 231 + 205 +- Bedrock rejects at 200K → 700K token gap where compaction never fires but API always rejects + 232 + 206 +- Combined with Issue #1 (infinite retries), this causes the 3.5-hour freeze + 233 + 207 + + 234 + 208 +### What any agent needs to look for? + 235 + 209 +``` + 236 + 210 +File: packages/opencode/src/provider/provider.ts + 237 + 211 +Location: The "amazon-bedrock" entry in CUSTOM_LOADERS (~line 211) + 238 + 212 +Pattern: The returned object has options (providerOptions) and getModel() but NO context limit override + 239 + 213 +Also: Look at how compaction.ts uses model.limit.context (~line 33) + 240 + 214 +Also: Look at how Cline handles this — they use additionalModelRequestFields for Bedrock + 241 + 215 + + 242 + 216 +DO NOT edit models-snapshot.ts directly — it is auto-generated by build.ts + 243 + 217 +``` + 244 + 218 + + 245 + 219 +### How to make the fix? + 246 + 220 +**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. + 247 + 221 + + 248 + 222 +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: + 249 + 223 + + 250 + 224 +```typescript + 251 + 225 +// Inside amazon-bedrock handler or model resolution + 252 + 226 +if (providerID === "amazon-bedrock" && modelData.limit?.context > 200000) { + 253 + 227 + // Cap at 200K unless 1M beta is explicitly configured + 254 + 228 + modelData.limit.context = 200000 + 255 + 229 +} + 256 + 230 +``` + 257 + 231 + + 258 + 232 +**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`. + 259 + 233 + + 260 + 234 +### Testing + 261 + 235 +- Configure Bedrock with Opus 4.6 + 262 + 236 +- Verify UI shows context limit as 200K (not 1M) + 263 + 237 +- Verify compaction triggers before hitting Bedrock's actual 200K limit + 264 + 238 +- Verify no "prompt is too long" errors during normal usage + 265 + 239 + + 266 + 240 +--- + 267 + 241 + + 268 + 242 +## Issue #5: `subagent-timeout` — task.ts Has No Execution Timeout + 269 + 243 + + 270 + 244 +### Priority: P0 — This Sprint + 271 + 245 + + 272 + 246 +### What is the issue? + 273 + 247 +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. + 274 + 248 + + 275 + 249 +### What is the bug? + 276 + 250 +`packages/opencode/src/tool/task.ts`: + 277 + 251 +```typescript + 278 + 252 +const result = await SessionPrompt.prompt({ + 279 + 253 + messageID, + 280 + 254 + sessionID: session.id, + 281 + 255 + model: { modelID: model.modelID, providerID: model.providerID }, + 282 + 256 + agent: agent.name, + 283 + 257 + tools: { ... }, + 284 + 258 + parts: promptParts, + 285 + 259 +}) + 286 + 260 +// ← No timeout wrapper, no AbortController deadline + 287 + 261 +``` + 288 + 262 + + 289 + 263 +This Promise can hang indefinitely if the child session encounters: + 290 + 264 +- Infinite retry loop (Issue #1 before fix) + 291 + 265 +- Permission hang (Issue #6) + 292 + 266 +- Slow API responses that never complete + 293 + 267 + + 294 + 268 +### Where it can happen? + 295 + 269 +- Any subagent execution, but especially: + 296 + 270 + - When subagent hits context overflow with retries + 297 + 271 + - When subagent needs permission and user is watching parent + 298 + 272 + - When API provider is slow or unresponsive + 299 + 273 + + 300 + 274 +### What any agent needs to look for? + 301 + 275 +``` + 302 + 276 +File: packages/opencode/src/tool/task.ts + 303 + 277 +Location: The SessionPrompt.prompt() call + 304 + 278 +Pattern: Check if there's an AbortSignal or timeout mechanism available + 305 + 279 +Also check: How the abort signal flows from processor.ts → tool execution → task.ts + 306 + 280 +Also check: ctx parameter in execute() — does it carry an abort signal? + 307 + 281 +``` + 308 + 282 + + 309 + 283 +### How to make the fix? + 310 + 284 +Wrap the `SessionPrompt.prompt()` call with an AbortController timeout: + 311 + 285 + + 312 + 286 +```typescript + 313 + 287 +const timeout = 5 * 60 * 1000 // 5 minutes (configurable) + 314 + 288 +const controller = new AbortController() + 315 + 289 +const timer = setTimeout(() => controller.abort(), timeout) + 316 + 290 + + 317 + 291 +try { + 318 + 292 + const result = await SessionPrompt.prompt({ + 319 + 293 + // ... existing params ... + 320 + 294 + abort: controller.signal, // Pass abort signal if prompt() supports it + 321 + 295 + }) + 322 + 296 + clearTimeout(timer) + 323 + 297 + // ... process result ... + 324 + 298 +} catch (e) { + 325 + 299 + clearTimeout(timer) + 326 + 300 + if (controller.signal.aborted) { + 327 + 301 + return { + 328 + 302 + title: params.description, + 329 + 303 + metadata: { sessionId: session.id, model }, + 330 + 304 + output: `ERROR: Subtask timed out after ${timeout / 1000}s. The task may still be running in session ${session.id}.`, + 331 + 305 + } + 332 + 306 + } + 333 + 307 + throw e + 334 + 308 +} + 335 + 309 +``` + 336 + 310 + + 337 + 311 +Check if `SessionPrompt.prompt()` already accepts an `abort` parameter. If not, trace how `processor.ts` passes its abort signal and ensure the plumbing exists. + 338 + 312 + + 339 + 313 +### Testing + 340 + 314 +- Trigger a subagent that would hang (e.g., long-running task) + 341 + 315 +- Verify it times out after the configured deadline + 342 + 316 +- Verify the parent receives a timeout error message, not silent hang + 343 + 317 +- Verify the child session is properly cleaned up + 344 + 318 + + 345 + 319 +--- + 346 + 320 + + 347 + 321 +## Issue #6: `permission-abort` — next.ts Permission Promises Hang Forever in Subagents + 348 + 322 + + 349 + 323 +### Priority: P0 — This Sprint + 350 + 324 + + 351 + 325 +### What is the issue? + 352 + 326 +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. + 353 + 327 + + 354 + 328 +### What is the bug? + 355 + 329 +`packages/opencode/src/permission/next.ts` lines ~143-156: + 356 + 330 +```typescript + 357 + 331 +export function ask(input: AskInput) { + 358 + 332 + return new Promise((resolve, reject) => { + 359 + 333 + // ... sets up permission request ... + 360 + 334 + // NO abort signal listener + 361 + 335 + // NO timeout + 362 + 336 + // Promise resolves only when user explicitly grants/denies + 363 + 337 + }) + 364 + 338 +} + 365 + 339 +``` + 366 + 340 + + 367 + 341 +`grep -c "abort" next.ts` returns **0** — there is zero abort signal awareness in the entire file. + 368 + 342 + + 369 + 343 +### Where it can happen? + 370 + 344 +- Any subagent tool call that requires permission + 371 + 345 +- Parallel subagents: one hangs on permission → parent hangs → all other parallel results blocked + 372 + 346 +- Even with auto-approve policies, edge cases (new tools, destructive operations) may still prompt + 373 + 347 + + 374 + 348 +### What any agent needs to look for? + 375 + 349 +``` + 376 + 350 +File: packages/opencode/src/permission/next.ts + 377 + 351 +Location: The ask() function (exported, ~line 143) + 378 + 352 +Pattern: The Promise constructor — no abort/timeout handling + 379 + 353 +Also check: How ask() is called from tool execution context + 380 + 354 +Also check: Whether an AbortSignal is available in the call chain + 381 + 355 +Also check: packages/opencode/src/session/prompt.ts for where permissions are requested + 382 + 356 +``` + 383 + 357 + + 384 + 358 +### How to make the fix? + 385 + 359 +Add AbortSignal support to the `ask()` function: + 386 + 360 + + 387 + 361 +```typescript + 388 + 362 +export function ask(input: AskInput & { abort?: AbortSignal }) { + 389 + 363 + return new Promise((resolve, reject) => { + 390 + 364 + // Check if already aborted + 391 + 365 + if (input.abort?.aborted) { + 392 + 366 + return reject(new Error("Permission request aborted")) + 393 + 367 + } + 394 + 368 + + 395 + 369 + // Listen for abort + 396 + 370 + const onAbort = () => { + 397 + 371 + reject(new Error("Permission request aborted")) + 398 + 372 + } + 399 + 373 + input.abort?.addEventListener("abort", onAbort, { once: true }) + 400 + 374 + + 401 + 375 + // ... existing permission logic ... + 402 + 376 + // Clean up abort listener in resolve/reject paths + 403 + 377 + }) + 404 + 378 +} + 405 + 379 +``` + 406 + 380 + + 407 + 381 +**Important**: The abort signal must be plumbed from `processor.ts` through the tool execution chain to `next.ts`. Trace the call path: + 408 + 382 +``` + 409 + 383 +processor.ts (has abort) → tool execution → specific tool → permission check → next.ts ask() + 410 + 384 +``` + 411 + 385 + + 412 + 386 +### Testing + 413 + 387 +- Trigger a subagent that needs permission + 414 + 388 +- Abort the parent session while permission is pending + 415 + 389 +- Verify the child permission promise rejects + 416 + 390 +- Verify the parent tool call resolves with an error (not hangs forever) + 417 + 391 + + 418 + 392 +--- + 419 + 393 + + 420 + 394 +## Issue #7: `retry-catch-all` — retry.ts Catch-All Makes All JSON Errors Retryable + 421 + 395 + + 422 + 396 +### Priority: P1 — Robustness + 423 + 397 + + 424 + 398 +### What is the issue? + 425 + 399 +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. + 426 + 400 + + 427 + 401 +### What is the bug? + 428 + 402 +`packages/opencode/src/session/retry.ts` line ~96: + 429 + 403 +```typescript + 430 + 404 +// After checking specific patterns (rate limit, overloaded, etc.)... + 431 + 405 +return JSON.stringify(json) // ← ANY remaining JSON error = retryable + 432 + 406 +``` + 433 + 407 + + 434 + 408 +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. + 435 + 409 + + 436 + 410 +### Where it can happen? + 437 + 411 +- Any API error that returns a JSON response body + 438 + 412 +- Specifically: Bedrock validation errors (400), authentication errors, quota errors + 439 + 413 +- Combined with Issue #1 (no max retries), this creates infinite retry storms + 440 + 414 + + 441 + 415 +### What any agent needs to look for? + 442 + 416 +``` + 443 + 417 +File: packages/opencode/src/session/retry.ts + 444 + 418 +Location: The retryable() function, specifically the catch-all after all pattern checks + 445 + 419 +Pattern: The final `return JSON.stringify(json)` that runs for any unmatched JSON error + 446 + 420 +Also check: What specific patterns ARE checked before the catch-all + 447 + 421 +Also check: Whether the JSON body contains "isRetryable" or HTTP status fields + 448 + 422 +``` + 449 + 423 + + 450 + 424 +### How to make the fix? + 451 + 425 +Replace the blanket catch-all with HTTP status-aware classification: + 452 + 426 + + 453 + 427 +```typescript + 454 + 428 +// Instead of: return JSON.stringify(json) + 455 + 429 +// Use: + 456 + 430 +const status = (json as any).status ?? (json as any).statusCode + 457 + 431 +if (typeof status === "number" && status >= 400 && status < 500) { + 458 + 432 + // 4xx errors are client errors — NOT retryable (bad request, auth, not found, etc.) + 459 + 433 + return undefined + 460 + 434 +} + 461 + 435 +// 5xx and truly unknown → retryable (but capped by MAX_RETRIES from Issue #1) + 462 + 436 +return JSON.stringify(json) + 463 + 437 +``` + 464 + 438 + + 465 + 439 +Also check for the `isRetryable` field that Bedrock includes: + 466 + 440 +```typescript + 467 + 441 +if ((json as any).isRetryable === false) return undefined + 468 + 442 +``` + 469 + 443 + + 470 + 444 +**Note**: This fix is SAFER when combined with Issue #1 (MAX_RETRIES), since any misclassification is bounded by the retry cap. + 471 + 445 + + 472 + 446 +### Testing + 473 + 447 +- Send a Bedrock 400 "prompt is too long" error → verify NOT retried + 474 + 448 +- Send a 429 rate limit error → verify IS retried + 475 + 449 +- Send a 500 server error → verify IS retried (up to MAX_RETRIES) + 476 + 450 +- Send a JSON error with `isRetryable: false` → verify NOT retried + 477 + 451 + + 478 + 452 +--- + 479 + 453 + + 480 + 454 +## Issue #8: `tool-error-metadata` — processor.ts Drops Metadata on Tool Errors + 481 + 455 + + 482 + 456 +### Priority: P1 — Robustness + 483 + 457 + + 484 + 458 +### What is the issue? + 485 + 459 +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). + 486 + 460 + + 487 + 461 +### What is the bug? + 488 + 462 +`packages/opencode/src/session/processor.ts` lines ~207-218, the `"tool-error"` case: + 489 + 463 +```typescript + 490 + 464 +case "tool-error": { + 491 + 465 + const match = toolcalls[value.toolCallId] + 492 + 466 + if (match && match.state.status === "running") { + 493 + 467 + await Session.updatePart({ + 494 + 468 + ...match, + 495 + 469 + state: { + 496 + 470 + status: "error", + 497 + 471 + input: value.input ?? match.state.input, + 498 + 472 + error: (value.error as any).toString(), + 499 + 473 + // ❌ Missing: title: match.state.title, + 500 + 474 + // ❌ Missing: metadata: match.state.metadata, + 501 + 475 + time: { + 502 + 476 + start: match.state.time.start, + 503 + 477 + end: Date.now(), + 504 + 478 + }, + 505 + 479 + }, + 506 + 480 + }) + 507 + 481 + } + 508 + 482 +} + 509 + 483 +``` + 510 + 484 + + 511 + 485 +### Where it can happen? + 512 + 486 +- Any tool that errors during execution + 513 + 487 +- Most visible for task tool errors — the `sessionId` metadata (used for navigating to child sessions) is lost + 514 + 488 +- Also affects batch tool parts and any tool with custom title/metadata + 515 + 489 + + 516 + 490 +### What any agent needs to look for? + 517 + 491 +``` + 518 + 492 +File: packages/opencode/src/session/processor.ts + 519 + 493 +Location: The "tool-error" case in the stream event handler + 520 + 494 +Pattern: Compare the "tool-error" state update with the "tool-result" state update + 521 + 495 +The "tool-result" case preserves title and metadata, but "tool-error" does not + 522 + 496 +``` + 523 + 497 + + 524 + 498 +### How to make the fix? + 525 + 499 +Add `title` and `metadata` preservation to the error state: + 526 + 500 + + 527 + 501 +```typescript + 528 + 502 +case "tool-error": { + 529 + 503 + const match = toolcalls[value.toolCallId] + 530 + 504 + if (match && match.state.status === "running") { + 531 + 505 + await Session.updatePart({ + 532 + 506 + ...match, + 533 + 507 + state: { + 534 + 508 + status: "error", + 535 + 509 + input: value.input ?? match.state.input, + 536 + 510 + error: (value.error as any).toString(), + 537 + 511 + title: match.state.title, // ← ADD + 538 + 512 + metadata: match.state.metadata, // ← ADD + 539 + 513 + time: { + 540 + 514 + start: match.state.time.start, + 541 + 515 + end: Date.now(), + 542 + 516 + }, + 543 + 517 + }, + 544 + 518 + }) + 545 + 519 + } + 546 + 520 +} + 547 + 521 +``` + 548 + 522 + + 549 + 523 +### Testing + 550 + 524 +- Trigger a tool error (e.g., file read on non-existent path) + 551 + 525 +- Verify the error part in the UI shows the tool title + 552 + 526 +- Trigger a subagent error → verify the sessionId metadata is preserved in the error part + 553 + 527 + + 554 + 528 +--- + 555 + 529 + + 556 + 530 +## Issue #9: `batch-error-details` — batch.ts Output Lacks Per-Tool Error Details + 557 + 531 + + 558 + 532 +### Priority: P2 — Nice to Have + 559 + 533 + + 560 + 534 +### What is the issue? + 561 + 535 +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. + 562 + 536 + + 563 + 537 +### What is the bug? + 564 + 538 +`packages/opencode/src/tool/batch.ts` output message: + 565 + 539 +```typescript + 566 + 540 +const outputMessage = failedCalls > 0 + 567 + 541 + ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` + 568 + 542 + : `All ${successfulCalls} tools executed successfully.` + 569 + 543 +``` + 570 + 544 + + 571 + 545 +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. + 572 + 546 + + 573 + 547 +### Where it can happen? + 574 + 548 +- Any batch execution where one or more tools fail + 575 + 549 +- The LLM sees the summary but not the individual error details + 576 + 550 +- Can cause the LLM to blindly retry the same failing operations + 577 + 551 + + 578 + 552 +### What any agent needs to look for? + 579 + 553 +``` + 580 + 554 +File: packages/opencode/src/tool/batch.ts + 581 + 555 +Location: The outputMessage construction after Promise.all results + 582 + 556 +Pattern: The results array has { success, tool, error? } for each call + 583 + 557 +``` + 584 + 558 + + 585 + 559 +### How to make the fix? + 586 + 560 +Include per-tool error details in the output: + 587 + 561 + + 588 + 562 +```typescript + 589 + 563 +const outputMessage = failedCalls > 0 + 590 + 564 + ? [ + 591 + 565 + `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`, + 592 + 566 + "", + 593 + 567 + "Failed tools:", + 594 + 568 + ...results + 595 + 569 + .filter((r) => !r.success) + 596 + 570 + .map((r) => `- ${r.tool}: ${r.error instanceof Error ? r.error.message : String(r.error)}`), + 597 + 571 + ].join("\n") + 598 + 572 + : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` + 599 + 573 +``` + 600 + 574 + + 601 + 575 +### Testing + 602 + 576 +- Execute a batch with one intentionally failing tool (e.g., read non-existent file) + 603 + 577 +- Verify the output includes the tool name and error message + 604 + 578 +- Verify the LLM can see which tool failed and why + 605 + 579 + + 606 + 580 +--- + 607 + 581 + + 608 + 582 +## Implementation Order + 609 + 583 + + 610 + 584 +``` + 611 + 585 +TODAY (P0 — Stop The Bleeding): + 612 + 586 + #1 processor-max-retries → 3.5hr freeze prevention + 613 + 587 + #2 bedrock-undefined-message → Bedrock overflow detection + 614 + 588 + #3 task-swallows-errors → Silent failure surfacing + 615 + 589 + + 616 + 590 +THIS SPRINT (P0 — Root Cause): + 617 + 591 + #4 bedrock-context-cap → Context limit accuracy + 618 + 592 + #5 subagent-timeout → Hung subagent prevention + 619 + 593 + #6 permission-abort → Permission hang prevention + 620 + 594 + + 621 + 595 +NEXT SPRINT (P1 — Robustness): + 622 + 596 + #7 retry-catch-all → Smart retry classification + 623 + 597 + #8 tool-error-metadata → Error UI metadata + 624 + 598 + + 625 + 599 +BACKLOG (P2): + 626 + 600 + #9 batch-error-details → Batch error visibility + 627 + 601 +``` + 628 + 602 + 629 + + 630 +``` + 631 + + 632 + + 633 +### 📄 `packages/opencode/src/provider/error.ts` + 634 + + 635 +**Type:** TypeScript Source File 📘 + 636 + + 637 +```diff + 638 +@@ -50,7 +50,7 @@ export namespace ProviderError { + 639 + 50 50 function message(providerID: string, e: APICallError) { + 640 + 51 51 return iife(() => { + 641 + 52 52 const msg = e.message + 642 + 53 - if (msg === "") { + 643 + 53 + if (msg === "" || msg === "undefined") { + 644 + 54 54 if (e.responseBody) return e.responseBody + 645 + 55 55 if (e.statusCode) { + 646 + 56 56 const err = STATUS_CODES[e.statusCode] + 647 + 57 57 + 648 + + 649 +``` + 650 + + 651 + + 652 +### 📄 `packages/opencode/src/session/processor.ts` + 653 + + 654 +**Type:** TypeScript Source File 📘 + 655 + + 656 +```diff + 657 +@@ -18,6 +18,7 @@ import { Question } from "@/question" + 658 + 18 18 + 659 + 19 19 export namespace SessionProcessor { + 660 + 20 20 const DOOM_LOOP_THRESHOLD = 3 + 661 + 21 + const MAX_RETRIES = 10 + 662 + 21 22 const log = Log.create({ service: "session.processor" }) + 663 + 22 23 + 664 + 23 24 export type Info = Awaited> + 665 +@@ -210,6 +211,7 @@ export namespace SessionProcessor { + 666 +210 211 status: "error", + 667 +211 212 input: value.input ?? match.state.input, + 668 +212 213 error: (value.error as any).toString(), + 669 + 214 + metadata: match.state.metadata, + 670 +213 215 time: { + 671 +214 216 start: match.state.time.start, + 672 +215 217 end: Date.now(), + 673 +@@ -363,6 +365,23 @@ export namespace SessionProcessor { + 674 +363 365 const retry = SessionRetry.retryable(error) + 675 +364 366 if (retry !== undefined) { + 676 +365 367 attempt++ + 677 + 368 + if (attempt >= MAX_RETRIES) { + 678 + 369 + log.error("max retries exceeded", { + 679 + 370 + sessionID: input.sessionID, + 680 + 371 + attempt, + 681 + 372 + message: retry, + 682 + 373 + }) + 683 + 374 + input.assistantMessage.error = MessageV2.fromError( + 684 + 375 + new Error(`Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`), + 685 + 376 + { providerID: input.model.providerID }, + 686 + 377 + ) + 687 + 378 + Bus.publish(Session.Event.Error, { + 688 + 379 + sessionID: input.assistantMessage.sessionID, + 689 + 380 + error: input.assistantMessage.error, + 690 + 381 + }) + 691 + 382 + SessionStatus.set(input.sessionID, { type: "idle" }) + 692 + 383 + break + 693 + 384 + } + 694 +366 385 const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) + 695 +367 386 SessionStatus.set(input.sessionID, { + 696 +368 387 type: "retry", + 697 +369 388 + 698 + + 699 +``` + 700 + + 701 + + 702 +### 📄 `packages/opencode/src/tool/task.ts` + 703 + + 704 +**Type:** TypeScript Source File 📘 + 705 + + 706 +```diff + 707 +@@ -142,6 +142,22 @@ export const TaskTool = Tool.define("task", async (ctx) => { + 708 +142 142 parts: promptParts, + 709 +143 143 }) + 710 +144 144 + 711 + 145 + if ((result.info as MessageV2.Assistant).error) { + 712 + 146 + const errorObj = (result.info as MessageV2.Assistant).error + 713 + 147 + const msg = errorObj?.message ?? errorObj?.name ?? "Subagent task failed" + 714 + 148 + return { + 715 + 149 + title: params.description, + 716 + 150 + metadata: { sessionId: session.id, model }, + 717 + 151 + output: [ + 718 + 152 + `task_id: ${session.id} (for resuming to continue this task if needed)`, + 719 + 153 + "", + 720 + 154 + "", + 721 + 155 + `ERROR: ${msg}`, + 722 + 156 + "", + 723 + 157 + ].join("\n"), + 724 + 158 + } + 725 + 159 + } + 726 + 160 + + 727 +145 161 const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + 728 +146 162 + 729 +147 163 const output = [ + 730 +148 164 + 731 + + 732 +``` + 733 + + 734 +## 🤖 Comprehensive Review Checklist + 735 + + 736 +### ✅ Code Quality & Standards + 737 +- [ ] **Syntax & Formatting**: Consistent indentation, proper spacing + 738 +- [ ] **Naming Conventions**: Clear, descriptive variable/function names + 739 +- [ ] **Code Structure**: Logical organization, appropriate function size + 740 +- [ ] **Documentation**: Clear comments explaining complex logic + 741 +- [ ] **Type Safety**: Proper typing (if applicable) + 742 + + 743 +### 🔍 Logic & Functionality + 744 +- [ ] **Algorithm Correctness**: Logic implements requirements correctly + 745 +- [ ] **Edge Case Handling**: Boundary conditions properly addressed + 746 +- [ ] **Error Handling**: Appropriate try-catch blocks and error messages + 747 +- [ ] **Performance**: Efficient algorithms, no unnecessary loops + 748 +- [ ] **Memory Management**: Proper cleanup, no memory leaks + 749 + + 750 +### 🐛 Potential Issues & Bugs + 751 +- [ ] **Runtime Errors**: No null/undefined dereferencing + 752 +- [ ] **Type Mismatches**: Consistent data types throughout + 753 +- [ ] **Race Conditions**: Proper async/await handling + 754 +- [ ] **Resource Leaks**: Event listeners, timers properly cleaned up + 755 +- [ ] **Off-by-one Errors**: Array/loop bounds correctly handled + 756 + + 757 +### 🔒 Security Considerations + 758 +- [ ] **Input Validation**: User inputs properly sanitized + 759 +- [ ] **XSS Prevention**: No unsafe HTML injection + 760 +- [ ] **Authentication**: Proper access controls if applicable + 761 +- [ ] **Data Exposure**: No sensitive information in logs/client + 762 +- [ ] **Dependency Security**: No known vulnerable packages + 763 + + 764 +### 📱 User Experience & Accessibility + 765 +- [ ] **Responsive Design**: Works on different screen sizes + 766 +- [ ] **Loading States**: Proper feedback during operations + 767 +- [ ] **Error Messages**: User-friendly error communication + 768 +- [ ] **Accessibility**: ARIA labels, keyboard navigation + 769 +- [ ] **Performance**: Fast loading, smooth interactions + 770 + + 771 +### 💡 Improvement Suggestions + 772 + + 773 +#### Code Organization + 774 +- [ ] Consider extracting complex logic into separate functions + 775 +- [ ] Evaluate if constants should be moved to configuration + 776 +- [ ] Check for code duplication opportunities + 777 + + 778 +#### Performance Optimizations + 779 +- [ ] Identify opportunities for memoization + 780 +- [ ] Consider lazy loading for heavy operations + 781 +- [ ] Evaluate database query efficiency (if applicable) + 782 + + 783 +#### Testing Recommendations + 784 +- [ ] Unit tests for core functionality + 785 +- [ ] Integration tests for API endpoints + 786 +- [ ] Edge case testing scenarios + 787 + + 788 +#### Documentation Needs + 789 +- [ ] API documentation updates + 790 +- [ ] Code comments for complex algorithms + 791 +- [ ] README updates if public interfaces changed + 792 + + 793 +### 📝 Review Notes + 794 +*Add your specific feedback, suggestions, and observations here:* + 795 + + 796 +--- + 797 +*Individual file review generated by AI Visual Code Review v2.0* + 798 +*Generated: 2026-02-25T15:43:40.531Z* + 799 + +``` + + +### ✨ `docs/09-temp/issues.md` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** Documentation 📖 + +```diff +@@ -0,0 +1,601 @@ + 1 +# OpenCode — Parallel Agent & Retry Storm Issues + 2 + + 3 +> **Created**: 2025-02-25 + 4 +> **Source**: Combined RCA by Cline + Antigravity + 5 +> **Status**: Approved for implementation + 6 + + 7 +--- + 8 + + 9 +## Issue #1: `processor-max-retries` — Infinite Retry Loop in processor.ts + 10 + + 11 +### Priority: P0 — Stop The Bleeding + 12 + + 13 +### What is the issue? + 14 +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. + 15 + + 16 +### What is the bug? + 17 +`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`. + 18 + + 19 +### Where it can happen? + 20 +- Any API call that returns a retryable error (transient network issues, rate limits, Bedrock context overflow misclassified as retryable) + 21 +- Most critically: Bedrock "prompt is too long" errors that get misclassified as retryable by the catch-all in `retry.ts` (see Issue #2) + 22 +- Affects both parent sessions and subagent sessions independently + 23 + + 24 +### What any agent needs to look for? + 25 +``` + 26 +File: packages/opencode/src/session/processor.ts + 27 +Location: The while(true) loop (~line 53) + 28 +Pattern: Look for the catch block that calls SessionRetry.retryable() and does `continue` + 29 +``` + 30 + + 31 +### How to make the fix? + 32 +Add a `MAX_RETRIES` constant and guard before the `continue`: + 33 + + 34 +```typescript + 35 +// At top of file or inside the function + 36 +const MAX_RETRIES = 10 + 37 + + 38 +// Inside the catch block, before `continue`: + 39 +if (attempt >= MAX_RETRIES) { + 40 + input.assistantMessage.error = { + 41 + name: "RetryLimitExceeded", + 42 + message: `Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`, + 43 + } + 44 + break + 45 +} + 46 +``` + 47 + + 48 +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. + 49 + + 50 +### Testing + 51 +- Trigger a retryable error (e.g., rate limit) and verify it stops after 10 attempts + 52 +- Verify the error message appears in the session UI + 53 +- Verify the session status returns to "idle" (not stuck in "retry") + 54 + + 55 +--- + 56 + + 57 +## Issue #2: `bedrock-undefined-message` — error.ts Fails to Parse Bedrock Error Messages + 58 + + 59 +### Priority: P0 — Stop The Bleeding + 60 + + 61 +### What is the issue? + 62 +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. + 63 + + 64 +### What is the bug? + 65 +`packages/opencode/src/provider/error.ts` function `message()` (~line 50-80): + 66 +```typescript + 67 +const msg = e.message + 68 +if (msg === "") { + 69 + if (e.responseBody) return e.responseBody + 70 + // ... + 71 +} + 72 +``` + 73 +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. + 74 + + 75 +### Where it can happen? + 76 +- Any Bedrock API call that returns an error (context overflow, validation errors, throttling) + 77 +- The Bedrock SDK wraps errors differently than the Anthropic direct SDK + 78 +- Specifically observed with "prompt is too long: 208845 tokens > 200000 maximum" errors + 79 + + 80 +### What any agent needs to look for? + 81 +``` + 82 +File: packages/opencode/src/provider/error.ts + 83 +Location: The message() function, specifically the `if (msg === "")` check + 84 +Also check: isOverflow() function and the OVERFLOW_PATTERNS regex + 85 +``` + 86 + + 87 +### How to make the fix? + 88 +Extend the empty-message check to also handle `"undefined"`: + 89 + + 90 +```typescript + 91 +function message(providerID: string, e: APICallError) { + 92 + return iife(() => { + 93 + const msg = e.message + 94 + if (msg === "" || msg === "undefined") { + 95 + if (e.responseBody) return e.responseBody + 96 + // ... rest of existing fallback logic + 97 + } + 98 + return msg + 99 + }) + 100 +} + 101 +``` + 102 + + 103 +This ensures the actual error body (which contains "prompt is too long") is used for overflow detection instead of the meaningless `"undefined"` string. + 104 + + 105 +### Testing + 106 +- Mock a Bedrock APICallError with `message: "undefined"` and `responseBody: "prompt is too long: 208845 tokens > 200000 maximum"` + 107 +- Verify `message()` returns the responseBody, not `"undefined"` + 108 +- Verify `isOverflow()` correctly detects the overflow pattern from the responseBody + 109 + + 110 +--- + 111 + + 112 +## Issue #3: `task-swallows-errors` — task.ts Silently Swallows Subagent Failures + 113 + + 114 +### Priority: P0 — Stop The Bleeding + 115 + + 116 +### What is the issue? + 117 +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." + 118 + + 119 +### What is the bug? + 120 +`packages/opencode/src/tool/task.ts` line ~145: + 121 +```typescript + 122 +const result = await SessionPrompt.prompt({...}) + 123 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + 124 +``` + 125 + + 126 +`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. + 127 + + 128 +### Where it can happen? + 129 +- Any subagent failure: context overflow, API error, tool execution error, rate limit + 130 +- Parallel subagents: if 1 of 3 subagents fails, parent sees 3 "completed" tasks with one having empty output + 131 +- The parent LLM may then hallucinate that the task completed or silently move on + 132 + + 133 +### What any agent needs to look for? + 134 +``` + 135 +File: packages/opencode/src/tool/task.ts + 136 +Location: After the `SessionPrompt.prompt()` call, before building the output + 137 +Pattern: result.info should have an error field — check result.info type definition + 138 +Also check: packages/opencode/src/session/prompt.ts for the return type of prompt() + 139 +``` + 140 + + 141 +### How to make the fix? + 142 +Add an error check immediately after the `SessionPrompt.prompt()` call: + 143 + + 144 +```typescript + 145 +const result = await SessionPrompt.prompt({...}) + 146 + + 147 +// Check if child session errored + 148 +if (result.info.error) { + 149 + const error = result.info.error + 150 + const msg = error.message ?? error.name ?? "Subagent task failed" + 151 + return { + 152 + title: params.description, + 153 + metadata: { sessionId: session.id, model }, + 154 + output: [ + 155 + `task_id: ${session.id}`, + 156 + "", + 157 + "", + 158 + `ERROR: ${msg}`, + 159 + `The subtask encountered an error and could not complete.`, + 160 + "", + 161 + ].join("\n"), + 162 + } + 163 +} + 164 + + 165 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + 166 +``` + 167 + + 168 +**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. + 169 + + 170 +### Testing + 171 +- Trigger a subagent error (e.g., invalid tool call, context overflow) + 172 +- Verify the parent session shows "ERROR: ..." in the task result, not empty + 173 +- Verify the parent LLM receives the error and can report it to the user + 174 + + 175 +--- + 176 + + 177 +## Issue #4: `bedrock-context-cap` — Bedrock Provider Missing Context Limit Override + 178 + + 179 +### Priority: P0 — This Sprint + 180 + + 181 +### What is the issue? + 182 +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. + 183 + + 184 +### What is the bug? + 185 +Two bugs combine: + 186 + + 187 +1. **`models-snapshot.ts`** lists Opus 4.6 Bedrock models at 1M context (reflects model capability, not runtime limit) + 188 +2. **`provider.ts`** `"amazon-bedrock"` handler has NO logic to: + 189 + - Send `additionalModelRequestFields: { anthropic_beta: ["context-1m-2025-08-07"] }` to enable 1M + 190 + - Override the context limit to 200K when 1M beta is NOT active + 191 + + 192 +**Affected models in snapshot**: + 193 +``` + 194 +amazon-bedrock / anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 195 +amazon-bedrock / us.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 196 +amazon-bedrock / eu.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 197 +amazon-bedrock / global.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ + 198 +``` + 199 + + 200 +All other Bedrock Claude models correctly show 200K. + 201 + + 202 +### Where it can happen? + 203 +- Any user running Claude Opus 4.6 via Amazon Bedrock + 204 +- Compaction threshold is calculated from `model.limit.context` → 1M → threshold ~900K + 205 +- Bedrock rejects at 200K → 700K token gap where compaction never fires but API always rejects + 206 +- Combined with Issue #1 (infinite retries), this causes the 3.5-hour freeze + 207 + + 208 +### What any agent needs to look for? + 209 +``` + 210 +File: packages/opencode/src/provider/provider.ts + 211 +Location: The "amazon-bedrock" entry in CUSTOM_LOADERS (~line 211) + 212 +Pattern: The returned object has options (providerOptions) and getModel() but NO context limit override + 213 +Also: Look at how compaction.ts uses model.limit.context (~line 33) + 214 +Also: Look at how Cline handles this — they use additionalModelRequestFields for Bedrock + 215 + + 216 +DO NOT edit models-snapshot.ts directly — it is auto-generated by build.ts + 217 +``` + 218 + + 219 +### How to make the fix? + 220 +**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. + 221 + + 222 +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: + 223 + + 224 +```typescript + 225 +// Inside amazon-bedrock handler or model resolution + 226 +if (providerID === "amazon-bedrock" && modelData.limit?.context > 200000) { + 227 + // Cap at 200K unless 1M beta is explicitly configured + 228 + modelData.limit.context = 200000 + 229 +} + 230 +``` + 231 + + 232 +**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`. + 233 + + 234 +### Testing + 235 +- Configure Bedrock with Opus 4.6 + 236 +- Verify UI shows context limit as 200K (not 1M) + 237 +- Verify compaction triggers before hitting Bedrock's actual 200K limit + 238 +- Verify no "prompt is too long" errors during normal usage + 239 + + 240 +--- + 241 + + 242 +## Issue #5: `subagent-timeout` — task.ts Has No Execution Timeout + 243 + + 244 +### Priority: P0 — This Sprint + 245 + + 246 +### What is the issue? + 247 +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. + 248 + + 249 +### What is the bug? + 250 +`packages/opencode/src/tool/task.ts`: + 251 +```typescript + 252 +const result = await SessionPrompt.prompt({ + 253 + messageID, + 254 + sessionID: session.id, + 255 + model: { modelID: model.modelID, providerID: model.providerID }, + 256 + agent: agent.name, + 257 + tools: { ... }, + 258 + parts: promptParts, + 259 +}) + 260 +// ← No timeout wrapper, no AbortController deadline + 261 +``` + 262 + + 263 +This Promise can hang indefinitely if the child session encounters: + 264 +- Infinite retry loop (Issue #1 before fix) + 265 +- Permission hang (Issue #6) + 266 +- Slow API responses that never complete + 267 + + 268 +### Where it can happen? + 269 +- Any subagent execution, but especially: + 270 + - When subagent hits context overflow with retries + 271 + - When subagent needs permission and user is watching parent + 272 + - When API provider is slow or unresponsive + 273 + + 274 +### What any agent needs to look for? + 275 +``` + 276 +File: packages/opencode/src/tool/task.ts + 277 +Location: The SessionPrompt.prompt() call + 278 +Pattern: Check if there's an AbortSignal or timeout mechanism available + 279 +Also check: How the abort signal flows from processor.ts → tool execution → task.ts + 280 +Also check: ctx parameter in execute() — does it carry an abort signal? + 281 +``` + 282 + + 283 +### How to make the fix? + 284 +Wrap the `SessionPrompt.prompt()` call with an AbortController timeout: + 285 + + 286 +```typescript + 287 +const timeout = 5 * 60 * 1000 // 5 minutes (configurable) + 288 +const controller = new AbortController() + 289 +const timer = setTimeout(() => controller.abort(), timeout) + 290 + + 291 +try { + 292 + const result = await SessionPrompt.prompt({ + 293 + // ... existing params ... + 294 + abort: controller.signal, // Pass abort signal if prompt() supports it + 295 + }) + 296 + clearTimeout(timer) + 297 + // ... process result ... + 298 +} catch (e) { + 299 + clearTimeout(timer) + 300 + if (controller.signal.aborted) { + 301 + return { + 302 + title: params.description, + 303 + metadata: { sessionId: session.id, model }, + 304 + output: `ERROR: Subtask timed out after ${timeout / 1000}s. The task may still be running in session ${session.id}.`, + 305 + } + 306 + } + 307 + throw e + 308 +} + 309 +``` + 310 + + 311 +Check if `SessionPrompt.prompt()` already accepts an `abort` parameter. If not, trace how `processor.ts` passes its abort signal and ensure the plumbing exists. + 312 + + 313 +### Testing + 314 +- Trigger a subagent that would hang (e.g., long-running task) + 315 +- Verify it times out after the configured deadline + 316 +- Verify the parent receives a timeout error message, not silent hang + 317 +- Verify the child session is properly cleaned up + 318 + + 319 +--- + 320 + + 321 +## Issue #6: `permission-abort` — next.ts Permission Promises Hang Forever in Subagents + 322 + + 323 +### Priority: P0 — This Sprint + 324 + + 325 +### What is the issue? + 326 +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. + 327 + + 328 +### What is the bug? + 329 +`packages/opencode/src/permission/next.ts` lines ~143-156: + 330 +```typescript + 331 +export function ask(input: AskInput) { + 332 + return new Promise((resolve, reject) => { + 333 + // ... sets up permission request ... + 334 + // NO abort signal listener + 335 + // NO timeout + 336 + // Promise resolves only when user explicitly grants/denies + 337 + }) + 338 +} + 339 +``` + 340 + + 341 +`grep -c "abort" next.ts` returns **0** — there is zero abort signal awareness in the entire file. + 342 + + 343 +### Where it can happen? + 344 +- Any subagent tool call that requires permission + 345 +- Parallel subagents: one hangs on permission → parent hangs → all other parallel results blocked + 346 +- Even with auto-approve policies, edge cases (new tools, destructive operations) may still prompt + 347 + + 348 +### What any agent needs to look for? + 349 +``` + 350 +File: packages/opencode/src/permission/next.ts + 351 +Location: The ask() function (exported, ~line 143) + 352 +Pattern: The Promise constructor — no abort/timeout handling + 353 +Also check: How ask() is called from tool execution context + 354 +Also check: Whether an AbortSignal is available in the call chain + 355 +Also check: packages/opencode/src/session/prompt.ts for where permissions are requested + 356 +``` + 357 + + 358 +### How to make the fix? + 359 +Add AbortSignal support to the `ask()` function: + 360 + + 361 +```typescript + 362 +export function ask(input: AskInput & { abort?: AbortSignal }) { + 363 + return new Promise((resolve, reject) => { + 364 + // Check if already aborted + 365 + if (input.abort?.aborted) { + 366 + return reject(new Error("Permission request aborted")) + 367 + } + 368 + + 369 + // Listen for abort + 370 + const onAbort = () => { + 371 + reject(new Error("Permission request aborted")) + 372 + } + 373 + input.abort?.addEventListener("abort", onAbort, { once: true }) + 374 + + 375 + // ... existing permission logic ... + 376 + // Clean up abort listener in resolve/reject paths + 377 + }) + 378 +} + 379 +``` + 380 + + 381 +**Important**: The abort signal must be plumbed from `processor.ts` through the tool execution chain to `next.ts`. Trace the call path: + 382 +``` + 383 +processor.ts (has abort) → tool execution → specific tool → permission check → next.ts ask() + 384 +``` + 385 + + 386 +### Testing + 387 +- Trigger a subagent that needs permission + 388 +- Abort the parent session while permission is pending + 389 +- Verify the child permission promise rejects + 390 +- Verify the parent tool call resolves with an error (not hangs forever) + 391 + + 392 +--- + 393 + + 394 +## Issue #7: `retry-catch-all` — retry.ts Catch-All Makes All JSON Errors Retryable + 395 + + 396 +### Priority: P1 — Robustness + 397 + + 398 +### What is the issue? + 399 +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. + 400 + + 401 +### What is the bug? + 402 +`packages/opencode/src/session/retry.ts` line ~96: + 403 +```typescript + 404 +// After checking specific patterns (rate limit, overloaded, etc.)... + 405 +return JSON.stringify(json) // ← ANY remaining JSON error = retryable + 406 +``` + 407 + + 408 +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. + 409 + + 410 +### Where it can happen? + 411 +- Any API error that returns a JSON response body + 412 +- Specifically: Bedrock validation errors (400), authentication errors, quota errors + 413 +- Combined with Issue #1 (no max retries), this creates infinite retry storms + 414 + + 415 +### What any agent needs to look for? + 416 +``` + 417 +File: packages/opencode/src/session/retry.ts + 418 +Location: The retryable() function, specifically the catch-all after all pattern checks + 419 +Pattern: The final `return JSON.stringify(json)` that runs for any unmatched JSON error + 420 +Also check: What specific patterns ARE checked before the catch-all + 421 +Also check: Whether the JSON body contains "isRetryable" or HTTP status fields + 422 +``` + 423 + + 424 +### How to make the fix? + 425 +Replace the blanket catch-all with HTTP status-aware classification: + 426 + + 427 +```typescript + 428 +// Instead of: return JSON.stringify(json) + 429 +// Use: + 430 +const status = (json as any).status ?? (json as any).statusCode + 431 +if (typeof status === "number" && status >= 400 && status < 500) { + 432 + // 4xx errors are client errors — NOT retryable (bad request, auth, not found, etc.) + 433 + return undefined + 434 +} + 435 +// 5xx and truly unknown → retryable (but capped by MAX_RETRIES from Issue #1) + 436 +return JSON.stringify(json) + 437 +``` + 438 + + 439 +Also check for the `isRetryable` field that Bedrock includes: + 440 +```typescript + 441 +if ((json as any).isRetryable === false) return undefined + 442 +``` + 443 + + 444 +**Note**: This fix is SAFER when combined with Issue #1 (MAX_RETRIES), since any misclassification is bounded by the retry cap. + 445 + + 446 +### Testing + 447 +- Send a Bedrock 400 "prompt is too long" error → verify NOT retried + 448 +- Send a 429 rate limit error → verify IS retried + 449 +- Send a 500 server error → verify IS retried (up to MAX_RETRIES) + 450 +- Send a JSON error with `isRetryable: false` → verify NOT retried + 451 + + 452 +--- + 453 + + 454 +## Issue #8: `tool-error-metadata` — processor.ts Drops Metadata on Tool Errors + 455 + + 456 +### Priority: P1 — Robustness + 457 + + 458 +### What is the issue? + 459 +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). + 460 + + 461 +### What is the bug? + 462 +`packages/opencode/src/session/processor.ts` lines ~207-218, the `"tool-error"` case: + 463 +```typescript + 464 +case "tool-error": { + 465 + const match = toolcalls[value.toolCallId] + 466 + if (match && match.state.status === "running") { + 467 + await Session.updatePart({ + 468 + ...match, + 469 + state: { + 470 + status: "error", + 471 + input: value.input ?? match.state.input, + 472 + error: (value.error as any).toString(), + 473 + // ❌ Missing: title: match.state.title, + 474 + // ❌ Missing: metadata: match.state.metadata, + 475 + time: { + 476 + start: match.state.time.start, + 477 + end: Date.now(), + 478 + }, + 479 + }, + 480 + }) + 481 + } + 482 +} + 483 +``` + 484 + + 485 +### Where it can happen? + 486 +- Any tool that errors during execution + 487 +- Most visible for task tool errors — the `sessionId` metadata (used for navigating to child sessions) is lost + 488 +- Also affects batch tool parts and any tool with custom title/metadata + 489 + + 490 +### What any agent needs to look for? + 491 +``` + 492 +File: packages/opencode/src/session/processor.ts + 493 +Location: The "tool-error" case in the stream event handler + 494 +Pattern: Compare the "tool-error" state update with the "tool-result" state update + 495 +The "tool-result" case preserves title and metadata, but "tool-error" does not + 496 +``` + 497 + + 498 +### How to make the fix? + 499 +Add `title` and `metadata` preservation to the error state: + 500 + + 501 +```typescript + 502 +case "tool-error": { + 503 + const match = toolcalls[value.toolCallId] + 504 + if (match && match.state.status === "running") { + 505 + await Session.updatePart({ + 506 + ...match, + 507 + state: { + 508 + status: "error", + 509 + input: value.input ?? match.state.input, + 510 + error: (value.error as any).toString(), + 511 + title: match.state.title, // ← ADD + 512 + metadata: match.state.metadata, // ← ADD + 513 + time: { + 514 + start: match.state.time.start, + 515 + end: Date.now(), + 516 + }, + 517 + }, + 518 + }) + 519 + } + 520 +} + 521 +``` + 522 + + 523 +### Testing + 524 +- Trigger a tool error (e.g., file read on non-existent path) + 525 +- Verify the error part in the UI shows the tool title + 526 +- Trigger a subagent error → verify the sessionId metadata is preserved in the error part + 527 + + 528 +--- + 529 + + 530 +## Issue #9: `batch-error-details` — batch.ts Output Lacks Per-Tool Error Details + 531 + + 532 +### Priority: P2 — Nice to Have + 533 + + 534 +### What is the issue? + 535 +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. + 536 + + 537 +### What is the bug? + 538 +`packages/opencode/src/tool/batch.ts` output message: + 539 +```typescript + 540 +const outputMessage = failedCalls > 0 + 541 + ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` + 542 + : `All ${successfulCalls} tools executed successfully.` + 543 +``` + 544 + + 545 +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. + 546 + + 547 +### Where it can happen? + 548 +- Any batch execution where one or more tools fail + 549 +- The LLM sees the summary but not the individual error details + 550 +- Can cause the LLM to blindly retry the same failing operations + 551 + + 552 +### What any agent needs to look for? + 553 +``` + 554 +File: packages/opencode/src/tool/batch.ts + 555 +Location: The outputMessage construction after Promise.all results + 556 +Pattern: The results array has { success, tool, error? } for each call + 557 +``` + 558 + + 559 +### How to make the fix? + 560 +Include per-tool error details in the output: + 561 + + 562 +```typescript + 563 +const outputMessage = failedCalls > 0 + 564 + ? [ + 565 + `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`, + 566 + "", + 567 + "Failed tools:", + 568 + ...results + 569 + .filter((r) => !r.success) + 570 + .map((r) => `- ${r.tool}: ${r.error instanceof Error ? r.error.message : String(r.error)}`), + 571 + ].join("\n") + 572 + : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` + 573 +``` + 574 + + 575 +### Testing + 576 +- Execute a batch with one intentionally failing tool (e.g., read non-existent file) + 577 +- Verify the output includes the tool name and error message + 578 +- Verify the LLM can see which tool failed and why + 579 + + 580 +--- + 581 + + 582 +## Implementation Order + 583 + + 584 +``` + 585 +TODAY (P0 — Stop The Bleeding): + 586 + #1 processor-max-retries → 3.5hr freeze prevention + 587 + #2 bedrock-undefined-message → Bedrock overflow detection + 588 + #3 task-swallows-errors → Silent failure surfacing + 589 + + 590 +THIS SPRINT (P0 — Root Cause): + 591 + #4 bedrock-context-cap → Context limit accuracy + 592 + #5 subagent-timeout → Hung subagent prevention + 593 + #6 permission-abort → Permission hang prevention + 594 + + 595 +NEXT SPRINT (P1 — Robustness): + 596 + #7 retry-catch-all → Smart retry classification + 597 + #8 tool-error-metadata → Error UI metadata + 598 + + 599 +BACKLOG (P2): + 600 + #9 batch-error-details → Batch error visibility + 601 +``` + 602 + +``` + + +### 📄 `packages/opencode/src/provider/error.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -50,7 +50,7 @@ export namespace ProviderError { + 50 50 function message(providerID: string, e: APICallError) { + 51 51 return iife(() => { + 52 52 const msg = e.message + 53 - if (msg === "") { + 53 + if (msg === "" || msg === "undefined") { + 54 54 if (e.responseBody) return e.responseBody + 55 55 if (e.statusCode) { + 56 56 const err = STATUS_CODES[e.statusCode] + 57 57 + +``` + + +### 📄 `packages/opencode/src/session/processor.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -18,6 +18,7 @@ import { Question } from "@/question" + 18 18 + 19 19 export namespace SessionProcessor { + 20 20 const DOOM_LOOP_THRESHOLD = 3 + 21 + const MAX_RETRIES = 10 + 21 22 const log = Log.create({ service: "session.processor" }) + 22 23 + 23 24 export type Info = Awaited> +@@ -210,6 +211,7 @@ export namespace SessionProcessor { +210 211 status: "error", +211 212 input: value.input ?? match.state.input, +212 213 error: (value.error as any).toString(), + 214 + metadata: match.state.metadata, +213 215 time: { +214 216 start: match.state.time.start, +215 217 end: Date.now(), +@@ -363,6 +365,23 @@ export namespace SessionProcessor { +363 365 const retry = SessionRetry.retryable(error) +364 366 if (retry !== undefined) { +365 367 attempt++ + 368 + if (attempt >= MAX_RETRIES) { + 369 + log.error("max retries exceeded", { + 370 + sessionID: input.sessionID, + 371 + attempt, + 372 + message: retry, + 373 + }) + 374 + input.assistantMessage.error = MessageV2.fromError( + 375 + new Error(`Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`), + 376 + { providerID: input.model.providerID }, + 377 + ) + 378 + Bus.publish(Session.Event.Error, { + 379 + sessionID: input.assistantMessage.sessionID, + 380 + error: input.assistantMessage.error, + 381 + }) + 382 + SessionStatus.set(input.sessionID, { type: "idle" }) + 383 + break + 384 + } +366 385 const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) +367 386 SessionStatus.set(input.sessionID, { +368 387 type: "retry", +369 388 + +``` + + +### 📄 `packages/opencode/src/tool/task.ts` + +**Type:** TypeScript Source File 📘 + +```diff +@@ -142,6 +142,24 @@ export const TaskTool = Tool.define("task", async (ctx) => { +142 142 parts: promptParts, +143 143 }) +144 144 + 145 + if ((result.info as MessageV2.Assistant).error) { + 146 + const errorObj = (result.info as MessageV2.Assistant).error! + 147 + const msg = ("data" in errorObj && "message" in errorObj.data) + 148 + ? errorObj.data.message + 149 + : errorObj.name + 150 + return { + 151 + title: params.description, + 152 + metadata: { sessionId: session.id, model }, + 153 + output: [ + 154 + `task_id: ${session.id} (for resuming to continue this task if needed)`, + 155 + "", + 156 + "", + 157 + `ERROR: ${msg}`, + 158 + "", + 159 + ].join("\n"), + 160 + } + 161 + } + 162 + +145 163 const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" +146 164 +147 165 const output = [ +148 166 + +``` + + +### ✨ `packages/opencode/test/provider/error.test.ts` **[ADDED]** + +**Status:** ✅ **NEW FILE** - This file has been newly created + +**Type:** TypeScript Source File 📘 + +```diff +@@ -0,0 +1,106 @@ + 1 +import { describe, expect, test } from "bun:test" + 2 +import { APICallError } from "ai" + 3 +import { ProviderError } from "../../src/provider/error" + 4 + + 5 +function makeAPICallError(opts: { + 6 + message: string + 7 + statusCode?: number + 8 + responseBody?: string + 9 + isRetryable?: boolean + 10 +}) { + 11 + return new APICallError({ + 12 + message: opts.message, + 13 + url: "https://bedrock.us-east-1.amazonaws.com", + 14 + requestBodyValues: {}, + 15 + statusCode: opts.statusCode ?? 400, + 16 + responseHeaders: {}, + 17 + responseBody: opts.responseBody, + 18 + isRetryable: opts.isRetryable ?? false, + 19 + }) + 20 +} + 21 + + 22 +describe("provider.error.parseAPICallError", () => { + 23 + test("detects overflow when message is literal 'undefined' and responseBody has prompt-too-long", () => { + 24 + const error = makeAPICallError({ + 25 + message: "undefined", + 26 + statusCode: 400, + 27 + responseBody: JSON.stringify({ + 28 + message: "prompt is too long: 208845 tokens > 200000 maximum", + 29 + }), + 30 + }) + 31 + const result = ProviderError.parseAPICallError({ + 32 + providerID: "amazon-bedrock", + 33 + error, + 34 + }) + 35 + expect(result.type).toBe("context_overflow") + 36 + }) + 37 + + 38 + test("detects overflow when message is empty string and responseBody has prompt-too-long", () => { + 39 + const error = makeAPICallError({ + 40 + message: "", + 41 + statusCode: 400, + 42 + responseBody: JSON.stringify({ + 43 + message: "prompt is too long: 208845 tokens > 200000 maximum", + 44 + }), + 45 + }) + 46 + const result = ProviderError.parseAPICallError({ + 47 + providerID: "amazon-bedrock", + 48 + error, + 49 + }) + 50 + expect(result.type).toBe("context_overflow") + 51 + }) + 52 + + 53 + test("does NOT treat literal 'undefined' as overflow when responseBody has no overflow pattern", () => { + 54 + const error = makeAPICallError({ + 55 + message: "undefined", + 56 + statusCode: 403, + 57 + responseBody: JSON.stringify({ message: "Access denied" }), + 58 + }) + 59 + const result = ProviderError.parseAPICallError({ + 60 + providerID: "amazon-bedrock", + 61 + error, + 62 + }) + 63 + expect(result.type).toBe("api_error") + 64 + }) + 65 + + 66 + test("detects overflow for direct Anthropic provider with normal message", () => { + 67 + const error = makeAPICallError({ + 68 + message: "prompt is too long: 208845 tokens > 200000 maximum", + 69 + statusCode: 400, + 70 + }) + 71 + const result = ProviderError.parseAPICallError({ + 72 + providerID: "anthropic", + 73 + error, + 74 + }) + 75 + expect(result.type).toBe("context_overflow") + 76 + }) + 77 + + 78 + test("detects overflow for Bedrock 'input is too long' pattern", () => { + 79 + const error = makeAPICallError({ + 80 + message: "undefined", + 81 + statusCode: 400, + 82 + responseBody: "input is too long for requested model", + 83 + }) + 84 + const result = ProviderError.parseAPICallError({ + 85 + providerID: "amazon-bedrock", + 86 + error, + 87 + }) + 88 + expect(result.type).toBe("context_overflow") + 89 + }) + 90 + + 91 + test("returns api_error for normal non-overflow errors", () => { + 92 + const error = makeAPICallError({ + 93 + message: "Rate limit exceeded", + 94 + statusCode: 429, + 95 + isRetryable: true, + 96 + }) + 97 + const result = ProviderError.parseAPICallError({ + 98 + providerID: "amazon-bedrock", + 99 + error, + 100 + }) + 101 + expect(result.type).toBe("api_error") + 102 + if (result.type === "api_error") { + 103 + expect(result.isRetryable).toBe(true) + 104 + } + 105 + }) + 106 +}) + 107 + +``` + +## 🤖 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-25T15:47:47.700Z* diff --git a/docs/09-temp/issues.md b/docs/09-temp/issues.md new file mode 100644 index 000000000000..4f21428f0ddc --- /dev/null +++ b/docs/09-temp/issues.md @@ -0,0 +1,601 @@ +# 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 + +``` +TODAY (P0 — Stop The Bleeding): + #1 processor-max-retries → 3.5hr freeze prevention + #2 bedrock-undefined-message → Bedrock overflow detection + #3 task-swallows-errors → Silent failure surfacing + +THIS SPRINT (P0 — Root Cause): + #4 bedrock-context-cap → Context limit accuracy + #5 subagent-timeout → Hung subagent prevention + #6 permission-abort → Permission hang prevention + +NEXT SPRINT (P1 — Robustness): + #7 retry-catch-all → Smart retry classification + #8 tool-error-metadata → Error UI metadata + +BACKLOG (P2): + #9 batch-error-details → Batch error visibility +``` 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") + }) +}) From a245159e8c49edd713d4ae6e845d7ebccf37c971 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 21:47:50 +0530 Subject: [PATCH 13/20] docs: update issues.md with implementation status --- docs/09-temp/issues.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/09-temp/issues.md b/docs/09-temp/issues.md index 4f21428f0ddc..263f75fe81c8 100644 --- a/docs/09-temp/issues.md +++ b/docs/09-temp/issues.md @@ -582,20 +582,18 @@ const outputMessage = failedCalls > 0 ## Implementation Order ``` -TODAY (P0 — Stop The Bleeding): - #1 processor-max-retries → 3.5hr freeze prevention - #2 bedrock-undefined-message → Bedrock overflow detection - #3 task-swallows-errors → Silent failure surfacing - -THIS SPRINT (P0 — Root Cause): - #4 bedrock-context-cap → Context limit accuracy +✅ 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 - -NEXT SPRINT (P1 — Robustness): - #7 retry-catch-all → Smart retry classification - #8 tool-error-metadata → Error UI metadata - -BACKLOG (P2): - #9 batch-error-details → Batch error visibility ``` From 6761cdc183b581a387735f5d0833c1547534f5f6 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Thu, 26 Feb 2026 04:06:28 +0530 Subject: [PATCH 14/20] feat: Add Git submodules for codex, cline, awesome-opencode, OpenGUI, and AionUi. --- .gitmodules | 15 +++++++++++++++ references/AionUi | 1 + references/OpenGUI | 1 + references/awesome-opencode | 1 + references/cline | 1 + references/codex | 1 + 6 files changed, 20 insertions(+) create mode 100644 .gitmodules create mode 160000 references/AionUi create mode 160000 references/OpenGUI create mode 160000 references/awesome-opencode create mode 160000 references/cline create mode 160000 references/codex 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/references/AionUi b/references/AionUi new file mode 160000 index 000000000000..dce8776d4a15 --- /dev/null +++ b/references/AionUi @@ -0,0 +1 @@ +Subproject commit dce8776d4a1551119d94ec10d07949ba0f863de4 diff --git a/references/OpenGUI b/references/OpenGUI new file mode 160000 index 000000000000..1a59e82706da --- /dev/null +++ b/references/OpenGUI @@ -0,0 +1 @@ +Subproject commit 1a59e82706da29b4d3510dafa6fb02da2fff12b4 diff --git a/references/awesome-opencode b/references/awesome-opencode new file mode 160000 index 000000000000..4fadff1cd377 --- /dev/null +++ b/references/awesome-opencode @@ -0,0 +1 @@ +Subproject commit 4fadff1cd3775486ee89267bb59ea231cc5e5247 diff --git a/references/cline b/references/cline new file mode 160000 index 000000000000..f10b6f39be17 --- /dev/null +++ b/references/cline @@ -0,0 +1 @@ +Subproject commit f10b6f39be17f24c3a410ae5780eb7adf15473b8 diff --git a/references/codex b/references/codex new file mode 160000 index 000000000000..c1851be1ed8f --- /dev/null +++ b/references/codex @@ -0,0 +1 @@ +Subproject commit c1851be1ed8f6ad4c8786f335e9e65b9b9d910fb From bd7de73d703833a6890e0401a6bad5672aaf3d2a Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Thu, 26 Feb 2026 04:06:57 +0530 Subject: [PATCH 15/20] Please provide the file changes to generate a commit message. --- references/codex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/codex b/references/codex index c1851be1ed8f..f86087eaa8da 160000 --- a/references/codex +++ b/references/codex @@ -1 +1 @@ -Subproject commit c1851be1ed8f6ad4c8786f335e9e65b9b9d910fb +Subproject commit f86087eaa8da9cac9245c87085adc1b7a4065f36 From 38aea146c9828bfece377671441edf08ecc7f2be Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Thu, 26 Feb 2026 16:27:36 +0530 Subject: [PATCH 16/20] =?UTF-8?q?feat:=20queue=20&=20steer=20=E2=80=94=20m?= =?UTF-8?q?id-turn=20input=20injection=20inspired=20by=20Codex=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SessionSteer module with push/take/takeByMode/has/list/remove/clear - Add POST/GET/DELETE /:sessionID/steer API routes with zod validation - Integrate steer drain in prompt.ts loop: steer injects mid-turn, queue auto-submits after turn - Split button UI: Queue (Enter, arrow-down-to-line) + Steer (Shift+Enter, chevron-double-right) - Queue widget with hover-reveal delete in prompt input - Wire steer_queue through global-sync state, event-reducer, child-store - 18 unit tests for SessionSteer (all passing) - Architecture reference doc from Codex CLI analysis --- AI_REVIEW.md | 2894 ++++++++--------- .../09-temp/codex-queue-steer-architecture.md | 319 ++ packages/opencode/src/session/steer.ts | 127 + packages/opencode/test/session/steer.test.ts | 235 ++ 4 files changed, 1976 insertions(+), 1599 deletions(-) create mode 100644 docs/09-temp/codex-queue-steer-architecture.md create mode 100644 packages/opencode/src/session/steer.ts create mode 100644 packages/opencode/test/session/steer.test.ts diff --git a/AI_REVIEW.md b/AI_REVIEW.md index d80211d30bd6..bc3462933800 100644 --- a/AI_REVIEW.md +++ b/AI_REVIEW.md @@ -1,4 +1,4 @@ -# 🔍 Code Review - 2/25/2026, 9:17:47 PM +# 🔍 Code Review - 2/26/2026, 4:03:24 PM **Project:** AI Visual Code Review **Generated by:** AI Visual Code Review v2.0 @@ -6,1662 +6,1358 @@ ## 📊 Change Summary ``` -AI_REVIEW.md | 798 ++++++++++++++++++++++++++ - docs/09-temp/issues.md | 601 +++++++++++++++++++ - packages/opencode/src/provider/error.ts | 2 +- - packages/opencode/src/session/processor.ts | 19 + - packages/opencode/src/tool/task.ts | 18 + - packages/opencode/test/provider/error.test.ts | 106 ++++ - 6 files changed, 1543 insertions(+), 1 deletion(-) +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 (6 selected) +## 📝 Files Changed (10 selected) -### ✨ `AI_REVIEW.md` **[ADDED]** +### ✨ `docs/09-temp/codex-queue-steer-architecture.md` **[ADDED]** **Status:** ✅ **NEW FILE** - This file has been newly created **Type:** Documentation 📖 ```diff -@@ -0,0 +1,798 @@ - 1 +# 🔍 Code Review - 2/25/2026, 9:13:40 PM +@@ -0,0 +1,319 @@ + 1 +# Codex Queue/Steer Architecture Analysis 2 + - 3 +**Project:** AI Visual Code Review - 4 +**Generated by:** AI Visual Code Review v2.0 + 3 +> Deep-dive into OpenAI Codex CLI's queue/steer mechanism for mid-turn user interaction. + 4 +> Source: `references/codex/` submodule 5 + - 6 +## 📊 Change Summary + 6 +--- 7 + - 8 +``` - 9 +docs/09-temp/issues.md | 601 +++++++++++++++++++++++++++++ - 10 + packages/opencode/src/provider/error.ts | 2 +- - 11 + packages/opencode/src/session/processor.ts | 19 + - 12 + packages/opencode/src/tool/task.ts | 16 + - 13 + 4 files changed, 637 insertions(+), 1 deletion(-) - 14 +``` - 15 + - 16 +## 📝 Files Changed (4 selected) - 17 + + 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 +### ✨ `docs/09-temp/issues.md` **[ADDED]** + 19 +## Architecture Layers 20 + - 21 +**Status:** ✅ **NEW FILE** - This file has been newly created - 22 + - 23 +**Type:** Documentation 📖 - 24 + - 25 +```diff - 26 +@@ -0,0 +1,601 @@ - 27 + 1 +# OpenCode — Parallel Agent & Retry Storm Issues - 28 + 2 + - 29 + 3 +> **Created**: 2025-02-25 - 30 + 4 +> **Source**: Combined RCA by Cline + Antigravity - 31 + 5 +> **Status**: Approved for implementation - 32 + 6 + - 33 + 7 +--- - 34 + 8 + - 35 + 9 +## Issue #1: `processor-max-retries` — Infinite Retry Loop in processor.ts - 36 + 10 + - 37 + 11 +### Priority: P0 — Stop The Bleeding - 38 + 12 + - 39 + 13 +### What is the issue? - 40 + 14 +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. - 41 + 15 + - 42 + 16 +### What is the bug? - 43 + 17 +`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`. - 44 + 18 + - 45 + 19 +### Where it can happen? - 46 + 20 +- Any API call that returns a retryable error (transient network issues, rate limits, Bedrock context overflow misclassified as retryable) - 47 + 21 +- Most critically: Bedrock "prompt is too long" errors that get misclassified as retryable by the catch-all in `retry.ts` (see Issue #2) - 48 + 22 +- Affects both parent sessions and subagent sessions independently - 49 + 23 + - 50 + 24 +### What any agent needs to look for? - 51 + 25 +``` - 52 + 26 +File: packages/opencode/src/session/processor.ts - 53 + 27 +Location: The while(true) loop (~line 53) - 54 + 28 +Pattern: Look for the catch block that calls SessionRetry.retryable() and does `continue` - 55 + 29 +``` - 56 + 30 + - 57 + 31 +### How to make the fix? - 58 + 32 +Add a `MAX_RETRIES` constant and guard before the `continue`: - 59 + 33 + - 60 + 34 +```typescript - 61 + 35 +// At top of file or inside the function - 62 + 36 +const MAX_RETRIES = 10 - 63 + 37 + - 64 + 38 +// Inside the catch block, before `continue`: - 65 + 39 +if (attempt >= MAX_RETRIES) { - 66 + 40 + input.assistantMessage.error = { - 67 + 41 + name: "RetryLimitExceeded", - 68 + 42 + message: `Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`, - 69 + 43 + } - 70 + 44 + break - 71 + 45 +} - 72 + 46 +``` - 73 + 47 + - 74 + 48 +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. - 75 + 49 + - 76 + 50 +### Testing - 77 + 51 +- Trigger a retryable error (e.g., rate limit) and verify it stops after 10 attempts - 78 + 52 +- Verify the error message appears in the session UI - 79 + 53 +- Verify the session status returns to "idle" (not stuck in "retry") - 80 + 54 + - 81 + 55 +--- - 82 + 56 + - 83 + 57 +## Issue #2: `bedrock-undefined-message` — error.ts Fails to Parse Bedrock Error Messages - 84 + 58 + - 85 + 59 +### Priority: P0 — Stop The Bleeding - 86 + 60 + - 87 + 61 +### What is the issue? - 88 + 62 +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. - 89 + 63 + - 90 + 64 +### What is the bug? - 91 + 65 +`packages/opencode/src/provider/error.ts` function `message()` (~line 50-80): - 92 + 66 +```typescript - 93 + 67 +const msg = e.message - 94 + 68 +if (msg === "") { - 95 + 69 + if (e.responseBody) return e.responseBody - 96 + 70 + // ... - 97 + 71 +} - 98 + 72 +``` - 99 + 73 +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. - 100 + 74 + - 101 + 75 +### Where it can happen? - 102 + 76 +- Any Bedrock API call that returns an error (context overflow, validation errors, throttling) - 103 + 77 +- The Bedrock SDK wraps errors differently than the Anthropic direct SDK - 104 + 78 +- Specifically observed with "prompt is too long: 208845 tokens > 200000 maximum" errors - 105 + 79 + - 106 + 80 +### What any agent needs to look for? - 107 + 81 +``` - 108 + 82 +File: packages/opencode/src/provider/error.ts - 109 + 83 +Location: The message() function, specifically the `if (msg === "")` check - 110 + 84 +Also check: isOverflow() function and the OVERFLOW_PATTERNS regex - 111 + 85 +``` - 112 + 86 + - 113 + 87 +### How to make the fix? - 114 + 88 +Extend the empty-message check to also handle `"undefined"`: - 115 + 89 + - 116 + 90 +```typescript - 117 + 91 +function message(providerID: string, e: APICallError) { - 118 + 92 + return iife(() => { - 119 + 93 + const msg = e.message - 120 + 94 + if (msg === "" || msg === "undefined") { - 121 + 95 + if (e.responseBody) return e.responseBody - 122 + 96 + // ... rest of existing fallback logic - 123 + 97 + } - 124 + 98 + return msg - 125 + 99 + }) - 126 + 100 +} - 127 + 101 +``` - 128 + 102 + - 129 + 103 +This ensures the actual error body (which contains "prompt is too long") is used for overflow detection instead of the meaningless `"undefined"` string. - 130 + 104 + - 131 + 105 +### Testing - 132 + 106 +- Mock a Bedrock APICallError with `message: "undefined"` and `responseBody: "prompt is too long: 208845 tokens > 200000 maximum"` - 133 + 107 +- Verify `message()` returns the responseBody, not `"undefined"` - 134 + 108 +- Verify `isOverflow()` correctly detects the overflow pattern from the responseBody - 135 + 109 + - 136 + 110 +--- - 137 + 111 + - 138 + 112 +## Issue #3: `task-swallows-errors` — task.ts Silently Swallows Subagent Failures - 139 + 113 + - 140 + 114 +### Priority: P0 — Stop The Bleeding - 141 + 115 + - 142 + 116 +### What is the issue? - 143 + 117 +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." - 144 + 118 + - 145 + 119 +### What is the bug? - 146 + 120 +`packages/opencode/src/tool/task.ts` line ~145: - 147 + 121 +```typescript - 148 + 122 +const result = await SessionPrompt.prompt({...}) - 149 + 123 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 150 + 124 +``` - 151 + 125 + - 152 + 126 +`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. - 153 + 127 + - 154 + 128 +### Where it can happen? - 155 + 129 +- Any subagent failure: context overflow, API error, tool execution error, rate limit - 156 + 130 +- Parallel subagents: if 1 of 3 subagents fails, parent sees 3 "completed" tasks with one having empty output - 157 + 131 +- The parent LLM may then hallucinate that the task completed or silently move on - 158 + 132 + - 159 + 133 +### What any agent needs to look for? - 160 + 134 +``` - 161 + 135 +File: packages/opencode/src/tool/task.ts - 162 + 136 +Location: After the `SessionPrompt.prompt()` call, before building the output - 163 + 137 +Pattern: result.info should have an error field — check result.info type definition - 164 + 138 +Also check: packages/opencode/src/session/prompt.ts for the return type of prompt() - 165 + 139 +``` - 166 + 140 + - 167 + 141 +### How to make the fix? - 168 + 142 +Add an error check immediately after the `SessionPrompt.prompt()` call: - 169 + 143 + - 170 + 144 +```typescript - 171 + 145 +const result = await SessionPrompt.prompt({...}) - 172 + 146 + - 173 + 147 +// Check if child session errored - 174 + 148 +if (result.info.error) { - 175 + 149 + const error = result.info.error - 176 + 150 + const msg = error.message ?? error.name ?? "Subagent task failed" - 177 + 151 + return { - 178 + 152 + title: params.description, - 179 + 153 + metadata: { sessionId: session.id, model }, - 180 + 154 + output: [ - 181 + 155 + `task_id: ${session.id}`, - 182 + 156 + "", - 183 + 157 + "", - 184 + 158 + `ERROR: ${msg}`, - 185 + 159 + `The subtask encountered an error and could not complete.`, - 186 + 160 + "", - 187 + 161 + ].join("\n"), - 188 + 162 + } - 189 + 163 +} - 190 + 164 + - 191 + 165 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 192 + 166 +``` - 193 + 167 + - 194 + 168 +**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. - 195 + 169 + - 196 + 170 +### Testing - 197 + 171 +- Trigger a subagent error (e.g., invalid tool call, context overflow) - 198 + 172 +- Verify the parent session shows "ERROR: ..." in the task result, not empty - 199 + 173 +- Verify the parent LLM receives the error and can report it to the user - 200 + 174 + - 201 + 175 +--- - 202 + 176 + - 203 + 177 +## Issue #4: `bedrock-context-cap` — Bedrock Provider Missing Context Limit Override - 204 + 178 + - 205 + 179 +### Priority: P0 — This Sprint - 206 + 180 + - 207 + 181 +### What is the issue? - 208 + 182 +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. - 209 + 183 + - 210 + 184 +### What is the bug? - 211 + 185 +Two bugs combine: - 212 + 186 + - 213 + 187 +1. **`models-snapshot.ts`** lists Opus 4.6 Bedrock models at 1M context (reflects model capability, not runtime limit) - 214 + 188 +2. **`provider.ts`** `"amazon-bedrock"` handler has NO logic to: - 215 + 189 + - Send `additionalModelRequestFields: { anthropic_beta: ["context-1m-2025-08-07"] }` to enable 1M - 216 + 190 + - Override the context limit to 200K when 1M beta is NOT active - 217 + 191 + - 218 + 192 +**Affected models in snapshot**: - 219 + 193 +``` - 220 + 194 +amazon-bedrock / anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 221 + 195 +amazon-bedrock / us.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 222 + 196 +amazon-bedrock / eu.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 223 + 197 +amazon-bedrock / global.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 224 + 198 +``` - 225 + 199 + - 226 + 200 +All other Bedrock Claude models correctly show 200K. - 227 + 201 + - 228 + 202 +### Where it can happen? - 229 + 203 +- Any user running Claude Opus 4.6 via Amazon Bedrock - 230 + 204 +- Compaction threshold is calculated from `model.limit.context` → 1M → threshold ~900K - 231 + 205 +- Bedrock rejects at 200K → 700K token gap where compaction never fires but API always rejects - 232 + 206 +- Combined with Issue #1 (infinite retries), this causes the 3.5-hour freeze - 233 + 207 + - 234 + 208 +### What any agent needs to look for? - 235 + 209 +``` - 236 + 210 +File: packages/opencode/src/provider/provider.ts - 237 + 211 +Location: The "amazon-bedrock" entry in CUSTOM_LOADERS (~line 211) - 238 + 212 +Pattern: The returned object has options (providerOptions) and getModel() but NO context limit override - 239 + 213 +Also: Look at how compaction.ts uses model.limit.context (~line 33) - 240 + 214 +Also: Look at how Cline handles this — they use additionalModelRequestFields for Bedrock - 241 + 215 + - 242 + 216 +DO NOT edit models-snapshot.ts directly — it is auto-generated by build.ts - 243 + 217 +``` - 244 + 218 + - 245 + 219 +### How to make the fix? - 246 + 220 +**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. - 247 + 221 + - 248 + 222 +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: - 249 + 223 + - 250 + 224 +```typescript - 251 + 225 +// Inside amazon-bedrock handler or model resolution - 252 + 226 +if (providerID === "amazon-bedrock" && modelData.limit?.context > 200000) { - 253 + 227 + // Cap at 200K unless 1M beta is explicitly configured - 254 + 228 + modelData.limit.context = 200000 - 255 + 229 +} - 256 + 230 +``` - 257 + 231 + - 258 + 232 +**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`. - 259 + 233 + - 260 + 234 +### Testing - 261 + 235 +- Configure Bedrock with Opus 4.6 - 262 + 236 +- Verify UI shows context limit as 200K (not 1M) - 263 + 237 +- Verify compaction triggers before hitting Bedrock's actual 200K limit - 264 + 238 +- Verify no "prompt is too long" errors during normal usage - 265 + 239 + - 266 + 240 +--- - 267 + 241 + - 268 + 242 +## Issue #5: `subagent-timeout` — task.ts Has No Execution Timeout - 269 + 243 + - 270 + 244 +### Priority: P0 — This Sprint - 271 + 245 + - 272 + 246 +### What is the issue? - 273 + 247 +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. - 274 + 248 + - 275 + 249 +### What is the bug? - 276 + 250 +`packages/opencode/src/tool/task.ts`: - 277 + 251 +```typescript - 278 + 252 +const result = await SessionPrompt.prompt({ - 279 + 253 + messageID, - 280 + 254 + sessionID: session.id, - 281 + 255 + model: { modelID: model.modelID, providerID: model.providerID }, - 282 + 256 + agent: agent.name, - 283 + 257 + tools: { ... }, - 284 + 258 + parts: promptParts, - 285 + 259 +}) - 286 + 260 +// ← No timeout wrapper, no AbortController deadline - 287 + 261 +``` - 288 + 262 + - 289 + 263 +This Promise can hang indefinitely if the child session encounters: - 290 + 264 +- Infinite retry loop (Issue #1 before fix) - 291 + 265 +- Permission hang (Issue #6) - 292 + 266 +- Slow API responses that never complete - 293 + 267 + - 294 + 268 +### Where it can happen? - 295 + 269 +- Any subagent execution, but especially: - 296 + 270 + - When subagent hits context overflow with retries - 297 + 271 + - When subagent needs permission and user is watching parent - 298 + 272 + - When API provider is slow or unresponsive - 299 + 273 + - 300 + 274 +### What any agent needs to look for? - 301 + 275 +``` - 302 + 276 +File: packages/opencode/src/tool/task.ts - 303 + 277 +Location: The SessionPrompt.prompt() call - 304 + 278 +Pattern: Check if there's an AbortSignal or timeout mechanism available - 305 + 279 +Also check: How the abort signal flows from processor.ts → tool execution → task.ts - 306 + 280 +Also check: ctx parameter in execute() — does it carry an abort signal? - 307 + 281 +``` - 308 + 282 + - 309 + 283 +### How to make the fix? - 310 + 284 +Wrap the `SessionPrompt.prompt()` call with an AbortController timeout: - 311 + 285 + - 312 + 286 +```typescript - 313 + 287 +const timeout = 5 * 60 * 1000 // 5 minutes (configurable) - 314 + 288 +const controller = new AbortController() - 315 + 289 +const timer = setTimeout(() => controller.abort(), timeout) - 316 + 290 + - 317 + 291 +try { - 318 + 292 + const result = await SessionPrompt.prompt({ - 319 + 293 + // ... existing params ... - 320 + 294 + abort: controller.signal, // Pass abort signal if prompt() supports it - 321 + 295 + }) - 322 + 296 + clearTimeout(timer) - 323 + 297 + // ... process result ... - 324 + 298 +} catch (e) { - 325 + 299 + clearTimeout(timer) - 326 + 300 + if (controller.signal.aborted) { - 327 + 301 + return { - 328 + 302 + title: params.description, - 329 + 303 + metadata: { sessionId: session.id, model }, - 330 + 304 + output: `ERROR: Subtask timed out after ${timeout / 1000}s. The task may still be running in session ${session.id}.`, - 331 + 305 + } - 332 + 306 + } - 333 + 307 + throw e - 334 + 308 +} - 335 + 309 +``` - 336 + 310 + - 337 + 311 +Check if `SessionPrompt.prompt()` already accepts an `abort` parameter. If not, trace how `processor.ts` passes its abort signal and ensure the plumbing exists. - 338 + 312 + - 339 + 313 +### Testing - 340 + 314 +- Trigger a subagent that would hang (e.g., long-running task) - 341 + 315 +- Verify it times out after the configured deadline - 342 + 316 +- Verify the parent receives a timeout error message, not silent hang - 343 + 317 +- Verify the child session is properly cleaned up - 344 + 318 + - 345 + 319 +--- - 346 + 320 + - 347 + 321 +## Issue #6: `permission-abort` — next.ts Permission Promises Hang Forever in Subagents - 348 + 322 + - 349 + 323 +### Priority: P0 — This Sprint - 350 + 324 + - 351 + 325 +### What is the issue? - 352 + 326 +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. - 353 + 327 + - 354 + 328 +### What is the bug? - 355 + 329 +`packages/opencode/src/permission/next.ts` lines ~143-156: - 356 + 330 +```typescript - 357 + 331 +export function ask(input: AskInput) { - 358 + 332 + return new Promise((resolve, reject) => { - 359 + 333 + // ... sets up permission request ... - 360 + 334 + // NO abort signal listener - 361 + 335 + // NO timeout - 362 + 336 + // Promise resolves only when user explicitly grants/denies - 363 + 337 + }) - 364 + 338 +} - 365 + 339 +``` - 366 + 340 + - 367 + 341 +`grep -c "abort" next.ts` returns **0** — there is zero abort signal awareness in the entire file. - 368 + 342 + - 369 + 343 +### Where it can happen? - 370 + 344 +- Any subagent tool call that requires permission - 371 + 345 +- Parallel subagents: one hangs on permission → parent hangs → all other parallel results blocked - 372 + 346 +- Even with auto-approve policies, edge cases (new tools, destructive operations) may still prompt - 373 + 347 + - 374 + 348 +### What any agent needs to look for? - 375 + 349 +``` - 376 + 350 +File: packages/opencode/src/permission/next.ts - 377 + 351 +Location: The ask() function (exported, ~line 143) - 378 + 352 +Pattern: The Promise constructor — no abort/timeout handling - 379 + 353 +Also check: How ask() is called from tool execution context - 380 + 354 +Also check: Whether an AbortSignal is available in the call chain - 381 + 355 +Also check: packages/opencode/src/session/prompt.ts for where permissions are requested - 382 + 356 +``` - 383 + 357 + - 384 + 358 +### How to make the fix? - 385 + 359 +Add AbortSignal support to the `ask()` function: - 386 + 360 + - 387 + 361 +```typescript - 388 + 362 +export function ask(input: AskInput & { abort?: AbortSignal }) { - 389 + 363 + return new Promise((resolve, reject) => { - 390 + 364 + // Check if already aborted - 391 + 365 + if (input.abort?.aborted) { - 392 + 366 + return reject(new Error("Permission request aborted")) - 393 + 367 + } - 394 + 368 + - 395 + 369 + // Listen for abort - 396 + 370 + const onAbort = () => { - 397 + 371 + reject(new Error("Permission request aborted")) - 398 + 372 + } - 399 + 373 + input.abort?.addEventListener("abort", onAbort, { once: true }) - 400 + 374 + - 401 + 375 + // ... existing permission logic ... - 402 + 376 + // Clean up abort listener in resolve/reject paths - 403 + 377 + }) - 404 + 378 +} - 405 + 379 +``` - 406 + 380 + - 407 + 381 +**Important**: The abort signal must be plumbed from `processor.ts` through the tool execution chain to `next.ts`. Trace the call path: - 408 + 382 +``` - 409 + 383 +processor.ts (has abort) → tool execution → specific tool → permission check → next.ts ask() - 410 + 384 +``` - 411 + 385 + - 412 + 386 +### Testing - 413 + 387 +- Trigger a subagent that needs permission - 414 + 388 +- Abort the parent session while permission is pending - 415 + 389 +- Verify the child permission promise rejects - 416 + 390 +- Verify the parent tool call resolves with an error (not hangs forever) - 417 + 391 + - 418 + 392 +--- - 419 + 393 + - 420 + 394 +## Issue #7: `retry-catch-all` — retry.ts Catch-All Makes All JSON Errors Retryable - 421 + 395 + - 422 + 396 +### Priority: P1 — Robustness - 423 + 397 + - 424 + 398 +### What is the issue? - 425 + 399 +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. - 426 + 400 + - 427 + 401 +### What is the bug? - 428 + 402 +`packages/opencode/src/session/retry.ts` line ~96: - 429 + 403 +```typescript - 430 + 404 +// After checking specific patterns (rate limit, overloaded, etc.)... - 431 + 405 +return JSON.stringify(json) // ← ANY remaining JSON error = retryable - 432 + 406 +``` - 433 + 407 + - 434 + 408 +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. - 435 + 409 + - 436 + 410 +### Where it can happen? - 437 + 411 +- Any API error that returns a JSON response body - 438 + 412 +- Specifically: Bedrock validation errors (400), authentication errors, quota errors - 439 + 413 +- Combined with Issue #1 (no max retries), this creates infinite retry storms - 440 + 414 + - 441 + 415 +### What any agent needs to look for? - 442 + 416 +``` - 443 + 417 +File: packages/opencode/src/session/retry.ts - 444 + 418 +Location: The retryable() function, specifically the catch-all after all pattern checks - 445 + 419 +Pattern: The final `return JSON.stringify(json)` that runs for any unmatched JSON error - 446 + 420 +Also check: What specific patterns ARE checked before the catch-all - 447 + 421 +Also check: Whether the JSON body contains "isRetryable" or HTTP status fields - 448 + 422 +``` - 449 + 423 + - 450 + 424 +### How to make the fix? - 451 + 425 +Replace the blanket catch-all with HTTP status-aware classification: - 452 + 426 + - 453 + 427 +```typescript - 454 + 428 +// Instead of: return JSON.stringify(json) - 455 + 429 +// Use: - 456 + 430 +const status = (json as any).status ?? (json as any).statusCode - 457 + 431 +if (typeof status === "number" && status >= 400 && status < 500) { - 458 + 432 + // 4xx errors are client errors — NOT retryable (bad request, auth, not found, etc.) - 459 + 433 + return undefined - 460 + 434 +} - 461 + 435 +// 5xx and truly unknown → retryable (but capped by MAX_RETRIES from Issue #1) - 462 + 436 +return JSON.stringify(json) - 463 + 437 +``` - 464 + 438 + - 465 + 439 +Also check for the `isRetryable` field that Bedrock includes: - 466 + 440 +```typescript - 467 + 441 +if ((json as any).isRetryable === false) return undefined - 468 + 442 +``` - 469 + 443 + - 470 + 444 +**Note**: This fix is SAFER when combined with Issue #1 (MAX_RETRIES), since any misclassification is bounded by the retry cap. - 471 + 445 + - 472 + 446 +### Testing - 473 + 447 +- Send a Bedrock 400 "prompt is too long" error → verify NOT retried - 474 + 448 +- Send a 429 rate limit error → verify IS retried - 475 + 449 +- Send a 500 server error → verify IS retried (up to MAX_RETRIES) - 476 + 450 +- Send a JSON error with `isRetryable: false` → verify NOT retried - 477 + 451 + - 478 + 452 +--- - 479 + 453 + - 480 + 454 +## Issue #8: `tool-error-metadata` — processor.ts Drops Metadata on Tool Errors - 481 + 455 + - 482 + 456 +### Priority: P1 — Robustness - 483 + 457 + - 484 + 458 +### What is the issue? - 485 + 459 +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). - 486 + 460 + - 487 + 461 +### What is the bug? - 488 + 462 +`packages/opencode/src/session/processor.ts` lines ~207-218, the `"tool-error"` case: - 489 + 463 +```typescript - 490 + 464 +case "tool-error": { - 491 + 465 + const match = toolcalls[value.toolCallId] - 492 + 466 + if (match && match.state.status === "running") { - 493 + 467 + await Session.updatePart({ - 494 + 468 + ...match, - 495 + 469 + state: { - 496 + 470 + status: "error", - 497 + 471 + input: value.input ?? match.state.input, - 498 + 472 + error: (value.error as any).toString(), - 499 + 473 + // ❌ Missing: title: match.state.title, - 500 + 474 + // ❌ Missing: metadata: match.state.metadata, - 501 + 475 + time: { - 502 + 476 + start: match.state.time.start, - 503 + 477 + end: Date.now(), - 504 + 478 + }, - 505 + 479 + }, - 506 + 480 + }) - 507 + 481 + } - 508 + 482 +} - 509 + 483 +``` - 510 + 484 + - 511 + 485 +### Where it can happen? - 512 + 486 +- Any tool that errors during execution - 513 + 487 +- Most visible for task tool errors — the `sessionId` metadata (used for navigating to child sessions) is lost - 514 + 488 +- Also affects batch tool parts and any tool with custom title/metadata - 515 + 489 + - 516 + 490 +### What any agent needs to look for? - 517 + 491 +``` - 518 + 492 +File: packages/opencode/src/session/processor.ts - 519 + 493 +Location: The "tool-error" case in the stream event handler - 520 + 494 +Pattern: Compare the "tool-error" state update with the "tool-result" state update - 521 + 495 +The "tool-result" case preserves title and metadata, but "tool-error" does not - 522 + 496 +``` - 523 + 497 + - 524 + 498 +### How to make the fix? - 525 + 499 +Add `title` and `metadata` preservation to the error state: - 526 + 500 + - 527 + 501 +```typescript - 528 + 502 +case "tool-error": { - 529 + 503 + const match = toolcalls[value.toolCallId] - 530 + 504 + if (match && match.state.status === "running") { - 531 + 505 + await Session.updatePart({ - 532 + 506 + ...match, - 533 + 507 + state: { - 534 + 508 + status: "error", - 535 + 509 + input: value.input ?? match.state.input, - 536 + 510 + error: (value.error as any).toString(), - 537 + 511 + title: match.state.title, // ← ADD - 538 + 512 + metadata: match.state.metadata, // ← ADD - 539 + 513 + time: { - 540 + 514 + start: match.state.time.start, - 541 + 515 + end: Date.now(), - 542 + 516 + }, - 543 + 517 + }, - 544 + 518 + }) - 545 + 519 + } - 546 + 520 +} - 547 + 521 +``` - 548 + 522 + - 549 + 523 +### Testing - 550 + 524 +- Trigger a tool error (e.g., file read on non-existent path) - 551 + 525 +- Verify the error part in the UI shows the tool title - 552 + 526 +- Trigger a subagent error → verify the sessionId metadata is preserved in the error part - 553 + 527 + - 554 + 528 +--- - 555 + 529 + - 556 + 530 +## Issue #9: `batch-error-details` — batch.ts Output Lacks Per-Tool Error Details - 557 + 531 + - 558 + 532 +### Priority: P2 — Nice to Have - 559 + 533 + - 560 + 534 +### What is the issue? - 561 + 535 +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. - 562 + 536 + - 563 + 537 +### What is the bug? - 564 + 538 +`packages/opencode/src/tool/batch.ts` output message: - 565 + 539 +```typescript - 566 + 540 +const outputMessage = failedCalls > 0 - 567 + 541 + ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` - 568 + 542 + : `All ${successfulCalls} tools executed successfully.` - 569 + 543 +``` - 570 + 544 + - 571 + 545 +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. - 572 + 546 + - 573 + 547 +### Where it can happen? - 574 + 548 +- Any batch execution where one or more tools fail - 575 + 549 +- The LLM sees the summary but not the individual error details - 576 + 550 +- Can cause the LLM to blindly retry the same failing operations - 577 + 551 + - 578 + 552 +### What any agent needs to look for? - 579 + 553 +``` - 580 + 554 +File: packages/opencode/src/tool/batch.ts - 581 + 555 +Location: The outputMessage construction after Promise.all results - 582 + 556 +Pattern: The results array has { success, tool, error? } for each call - 583 + 557 +``` - 584 + 558 + - 585 + 559 +### How to make the fix? - 586 + 560 +Include per-tool error details in the output: - 587 + 561 + - 588 + 562 +```typescript - 589 + 563 +const outputMessage = failedCalls > 0 - 590 + 564 + ? [ - 591 + 565 + `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`, - 592 + 566 + "", - 593 + 567 + "Failed tools:", - 594 + 568 + ...results - 595 + 569 + .filter((r) => !r.success) - 596 + 570 + .map((r) => `- ${r.tool}: ${r.error instanceof Error ? r.error.message : String(r.error)}`), - 597 + 571 + ].join("\n") - 598 + 572 + : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` - 599 + 573 +``` - 600 + 574 + - 601 + 575 +### Testing - 602 + 576 +- Execute a batch with one intentionally failing tool (e.g., read non-existent file) - 603 + 577 +- Verify the output includes the tool name and error message - 604 + 578 +- Verify the LLM can see which tool failed and why - 605 + 579 + - 606 + 580 +--- - 607 + 581 + - 608 + 582 +## Implementation Order - 609 + 583 + - 610 + 584 +``` - 611 + 585 +TODAY (P0 — Stop The Bleeding): - 612 + 586 + #1 processor-max-retries → 3.5hr freeze prevention - 613 + 587 + #2 bedrock-undefined-message → Bedrock overflow detection - 614 + 588 + #3 task-swallows-errors → Silent failure surfacing - 615 + 589 + - 616 + 590 +THIS SPRINT (P0 — Root Cause): - 617 + 591 + #4 bedrock-context-cap → Context limit accuracy - 618 + 592 + #5 subagent-timeout → Hung subagent prevention - 619 + 593 + #6 permission-abort → Permission hang prevention - 620 + 594 + - 621 + 595 +NEXT SPRINT (P1 — Robustness): - 622 + 596 + #7 retry-catch-all → Smart retry classification - 623 + 597 + #8 tool-error-metadata → Error UI metadata - 624 + 598 + - 625 + 599 +BACKLOG (P2): - 626 + 600 + #9 batch-error-details → Batch error visibility - 627 + 601 +``` - 628 + 602 - 629 + - 630 +``` - 631 + - 632 + - 633 +### 📄 `packages/opencode/src/provider/error.ts` - 634 + - 635 +**Type:** TypeScript Source File 📘 - 636 + - 637 +```diff - 638 +@@ -50,7 +50,7 @@ export namespace ProviderError { - 639 + 50 50 function message(providerID: string, e: APICallError) { - 640 + 51 51 return iife(() => { - 641 + 52 52 const msg = e.message - 642 + 53 - if (msg === "") { - 643 + 53 + if (msg === "" || msg === "undefined") { - 644 + 54 54 if (e.responseBody) return e.responseBody - 645 + 55 55 if (e.statusCode) { - 646 + 56 56 const err = STATUS_CODES[e.statusCode] - 647 + 57 57 - 648 + - 649 +``` - 650 + - 651 + - 652 +### 📄 `packages/opencode/src/session/processor.ts` - 653 + - 654 +**Type:** TypeScript Source File 📘 - 655 + - 656 +```diff - 657 +@@ -18,6 +18,7 @@ import { Question } from "@/question" - 658 + 18 18 - 659 + 19 19 export namespace SessionProcessor { - 660 + 20 20 const DOOM_LOOP_THRESHOLD = 3 - 661 + 21 + const MAX_RETRIES = 10 - 662 + 21 22 const log = Log.create({ service: "session.processor" }) - 663 + 22 23 - 664 + 23 24 export type Info = Awaited> - 665 +@@ -210,6 +211,7 @@ export namespace SessionProcessor { - 666 +210 211 status: "error", - 667 +211 212 input: value.input ?? match.state.input, - 668 +212 213 error: (value.error as any).toString(), - 669 + 214 + metadata: match.state.metadata, - 670 +213 215 time: { - 671 +214 216 start: match.state.time.start, - 672 +215 217 end: Date.now(), - 673 +@@ -363,6 +365,23 @@ export namespace SessionProcessor { - 674 +363 365 const retry = SessionRetry.retryable(error) - 675 +364 366 if (retry !== undefined) { - 676 +365 367 attempt++ - 677 + 368 + if (attempt >= MAX_RETRIES) { - 678 + 369 + log.error("max retries exceeded", { - 679 + 370 + sessionID: input.sessionID, - 680 + 371 + attempt, - 681 + 372 + message: retry, - 682 + 373 + }) - 683 + 374 + input.assistantMessage.error = MessageV2.fromError( - 684 + 375 + new Error(`Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`), - 685 + 376 + { providerID: input.model.providerID }, - 686 + 377 + ) - 687 + 378 + Bus.publish(Session.Event.Error, { - 688 + 379 + sessionID: input.assistantMessage.sessionID, - 689 + 380 + error: input.assistantMessage.error, - 690 + 381 + }) - 691 + 382 + SessionStatus.set(input.sessionID, { type: "idle" }) - 692 + 383 + break - 693 + 384 + } - 694 +366 385 const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) - 695 +367 386 SessionStatus.set(input.sessionID, { - 696 +368 387 type: "retry", - 697 +369 388 - 698 + - 699 +``` - 700 + - 701 + - 702 +### 📄 `packages/opencode/src/tool/task.ts` - 703 + - 704 +**Type:** TypeScript Source File 📘 - 705 + - 706 +```diff - 707 +@@ -142,6 +142,22 @@ export const TaskTool = Tool.define("task", async (ctx) => { - 708 +142 142 parts: promptParts, - 709 +143 143 }) - 710 +144 144 - 711 + 145 + if ((result.info as MessageV2.Assistant).error) { - 712 + 146 + const errorObj = (result.info as MessageV2.Assistant).error - 713 + 147 + const msg = errorObj?.message ?? errorObj?.name ?? "Subagent task failed" - 714 + 148 + return { - 715 + 149 + title: params.description, - 716 + 150 + metadata: { sessionId: session.id, model }, - 717 + 151 + output: [ - 718 + 152 + `task_id: ${session.id} (for resuming to continue this task if needed)`, - 719 + 153 + "", - 720 + 154 + "", - 721 + 155 + `ERROR: ${msg}`, - 722 + 156 + "", - 723 + 157 + ].join("\n"), - 724 + 158 + } - 725 + 159 + } - 726 + 160 + - 727 +145 161 const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 728 +146 162 - 729 +147 163 const output = [ - 730 +148 164 - 731 + - 732 +``` - 733 + - 734 +## 🤖 Comprehensive Review Checklist - 735 + - 736 +### ✅ Code Quality & Standards - 737 +- [ ] **Syntax & Formatting**: Consistent indentation, proper spacing - 738 +- [ ] **Naming Conventions**: Clear, descriptive variable/function names - 739 +- [ ] **Code Structure**: Logical organization, appropriate function size - 740 +- [ ] **Documentation**: Clear comments explaining complex logic - 741 +- [ ] **Type Safety**: Proper typing (if applicable) - 742 + - 743 +### 🔍 Logic & Functionality - 744 +- [ ] **Algorithm Correctness**: Logic implements requirements correctly - 745 +- [ ] **Edge Case Handling**: Boundary conditions properly addressed - 746 +- [ ] **Error Handling**: Appropriate try-catch blocks and error messages - 747 +- [ ] **Performance**: Efficient algorithms, no unnecessary loops - 748 +- [ ] **Memory Management**: Proper cleanup, no memory leaks - 749 + - 750 +### 🐛 Potential Issues & Bugs - 751 +- [ ] **Runtime Errors**: No null/undefined dereferencing - 752 +- [ ] **Type Mismatches**: Consistent data types throughout - 753 +- [ ] **Race Conditions**: Proper async/await handling - 754 +- [ ] **Resource Leaks**: Event listeners, timers properly cleaned up - 755 +- [ ] **Off-by-one Errors**: Array/loop bounds correctly handled - 756 + - 757 +### 🔒 Security Considerations - 758 +- [ ] **Input Validation**: User inputs properly sanitized - 759 +- [ ] **XSS Prevention**: No unsafe HTML injection - 760 +- [ ] **Authentication**: Proper access controls if applicable - 761 +- [ ] **Data Exposure**: No sensitive information in logs/client - 762 +- [ ] **Dependency Security**: No known vulnerable packages - 763 + - 764 +### 📱 User Experience & Accessibility - 765 +- [ ] **Responsive Design**: Works on different screen sizes - 766 +- [ ] **Loading States**: Proper feedback during operations - 767 +- [ ] **Error Messages**: User-friendly error communication - 768 +- [ ] **Accessibility**: ARIA labels, keyboard navigation - 769 +- [ ] **Performance**: Fast loading, smooth interactions - 770 + - 771 +### 💡 Improvement Suggestions - 772 + - 773 +#### Code Organization - 774 +- [ ] Consider extracting complex logic into separate functions - 775 +- [ ] Evaluate if constants should be moved to configuration - 776 +- [ ] Check for code duplication opportunities - 777 + - 778 +#### Performance Optimizations - 779 +- [ ] Identify opportunities for memoization - 780 +- [ ] Consider lazy loading for heavy operations - 781 +- [ ] Evaluate database query efficiency (if applicable) - 782 + - 783 +#### Testing Recommendations - 784 +- [ ] Unit tests for core functionality - 785 +- [ ] Integration tests for API endpoints - 786 +- [ ] Edge case testing scenarios - 787 + - 788 +#### Documentation Needs - 789 +- [ ] API documentation updates - 790 +- [ ] Code comments for complex algorithms - 791 +- [ ] README updates if public interfaces changed - 792 + - 793 +### 📝 Review Notes - 794 +*Add your specific feedback, suggestions, and observations here:* - 795 + - 796 +--- - 797 +*Individual file review generated by AI Visual Code Review v2.0* - 798 +*Generated: 2026-02-25T15:43:40.531Z* - 799 - -``` - - -### ✨ `docs/09-temp/issues.md` **[ADDED]** - -**Status:** ✅ **NEW FILE** - This file has been newly created - -**Type:** Documentation 📖 - -```diff -@@ -0,0 +1,601 @@ - 1 +# OpenCode — Parallel Agent & Retry Storm Issues - 2 + - 3 +> **Created**: 2025-02-25 - 4 +> **Source**: Combined RCA by Cline + Antigravity - 5 +> **Status**: Approved for implementation - 6 + - 7 +--- - 8 + - 9 +## Issue #1: `processor-max-retries` — Infinite Retry Loop in processor.ts - 10 + - 11 +### Priority: P0 — Stop The Bleeding - 12 + - 13 +### What is the issue? - 14 +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. - 15 + - 16 +### What is the bug? - 17 +`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`. - 18 + - 19 +### Where it can happen? - 20 +- Any API call that returns a retryable error (transient network issues, rate limits, Bedrock context overflow misclassified as retryable) - 21 +- Most critically: Bedrock "prompt is too long" errors that get misclassified as retryable by the catch-all in `retry.ts` (see Issue #2) - 22 +- Affects both parent sessions and subagent sessions independently - 23 + - 24 +### What any agent needs to look for? - 25 +``` - 26 +File: packages/opencode/src/session/processor.ts - 27 +Location: The while(true) loop (~line 53) - 28 +Pattern: Look for the catch block that calls SessionRetry.retryable() and does `continue` - 29 +``` - 30 + - 31 +### How to make the fix? - 32 +Add a `MAX_RETRIES` constant and guard before the `continue`: - 33 + - 34 +```typescript - 35 +// At top of file or inside the function - 36 +const MAX_RETRIES = 10 - 37 + - 38 +// Inside the catch block, before `continue`: - 39 +if (attempt >= MAX_RETRIES) { - 40 + input.assistantMessage.error = { - 41 + name: "RetryLimitExceeded", - 42 + message: `Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`, - 43 + } - 44 + break - 45 +} - 46 +``` - 47 + - 48 +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. - 49 + - 50 +### Testing - 51 +- Trigger a retryable error (e.g., rate limit) and verify it stops after 10 attempts - 52 +- Verify the error message appears in the session UI - 53 +- Verify the session status returns to "idle" (not stuck in "retry") - 54 + - 55 +--- - 56 + - 57 +## Issue #2: `bedrock-undefined-message` — error.ts Fails to Parse Bedrock Error Messages - 58 + - 59 +### Priority: P0 — Stop The Bleeding - 60 + - 61 +### What is the issue? - 62 +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. - 63 + - 64 +### What is the bug? - 65 +`packages/opencode/src/provider/error.ts` function `message()` (~line 50-80): - 66 +```typescript - 67 +const msg = e.message - 68 +if (msg === "") { - 69 + if (e.responseBody) return e.responseBody - 70 + // ... - 71 +} - 72 +``` - 73 +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. - 74 + - 75 +### Where it can happen? - 76 +- Any Bedrock API call that returns an error (context overflow, validation errors, throttling) - 77 +- The Bedrock SDK wraps errors differently than the Anthropic direct SDK - 78 +- Specifically observed with "prompt is too long: 208845 tokens > 200000 maximum" errors - 79 + - 80 +### What any agent needs to look for? - 81 +``` - 82 +File: packages/opencode/src/provider/error.ts - 83 +Location: The message() function, specifically the `if (msg === "")` check - 84 +Also check: isOverflow() function and the OVERFLOW_PATTERNS regex - 85 +``` - 86 + - 87 +### How to make the fix? - 88 +Extend the empty-message check to also handle `"undefined"`: - 89 + - 90 +```typescript - 91 +function message(providerID: string, e: APICallError) { - 92 + return iife(() => { - 93 + const msg = e.message - 94 + if (msg === "" || msg === "undefined") { - 95 + if (e.responseBody) return e.responseBody - 96 + // ... rest of existing fallback logic - 97 + } - 98 + return msg - 99 + }) - 100 +} - 101 +``` - 102 + - 103 +This ensures the actual error body (which contains "prompt is too long") is used for overflow detection instead of the meaningless `"undefined"` string. - 104 + - 105 +### Testing - 106 +- Mock a Bedrock APICallError with `message: "undefined"` and `responseBody: "prompt is too long: 208845 tokens > 200000 maximum"` - 107 +- Verify `message()` returns the responseBody, not `"undefined"` - 108 +- Verify `isOverflow()` correctly detects the overflow pattern from the responseBody - 109 + - 110 +--- - 111 + - 112 +## Issue #3: `task-swallows-errors` — task.ts Silently Swallows Subagent Failures - 113 + - 114 +### Priority: P0 — Stop The Bleeding - 115 + - 116 +### What is the issue? - 117 +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." + 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 +### What is the bug? - 120 +`packages/opencode/src/tool/task.ts` line ~145: - 121 +```typescript - 122 +const result = await SessionPrompt.prompt({...}) - 123 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 124 +``` - 125 + - 126 +`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. - 127 + - 128 +### Where it can happen? - 129 +- Any subagent failure: context overflow, API error, tool execution error, rate limit - 130 +- Parallel subagents: if 1 of 3 subagents fails, parent sees 3 "completed" tasks with one having empty output - 131 +- The parent LLM may then hallucinate that the task completed or silently move on - 132 + - 133 +### What any agent needs to look for? - 134 +``` - 135 +File: packages/opencode/src/tool/task.ts - 136 +Location: After the `SessionPrompt.prompt()` call, before building the output - 137 +Pattern: result.info should have an error field — check result.info type definition - 138 +Also check: packages/opencode/src/session/prompt.ts for the return type of prompt() - 139 +``` - 140 + - 141 +### How to make the fix? - 142 +Add an error check immediately after the `SessionPrompt.prompt()` call: - 143 + - 144 +```typescript - 145 +const result = await SessionPrompt.prompt({...}) - 146 + - 147 +// Check if child session errored - 148 +if (result.info.error) { - 149 + const error = result.info.error - 150 + const msg = error.message ?? error.name ?? "Subagent task failed" - 151 + return { - 152 + title: params.description, - 153 + metadata: { sessionId: session.id, model }, - 154 + output: [ - 155 + `task_id: ${session.id}`, - 156 + "", - 157 + "", - 158 + `ERROR: ${msg}`, - 159 + `The subtask encountered an error and could not complete.`, - 160 + "", - 161 + ].join("\n"), - 162 + } - 163 +} + 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 +const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 166 +``` - 167 + - 168 +**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. - 169 + - 170 +### Testing - 171 +- Trigger a subagent error (e.g., invalid tool call, context overflow) - 172 +- Verify the parent session shows "ERROR: ..." in the task result, not empty - 173 +- Verify the parent LLM receives the error and can report it to the user - 174 + - 175 +--- - 176 + - 177 +## Issue #4: `bedrock-context-cap` — Bedrock Provider Missing Context Limit Override - 178 + - 179 +### Priority: P0 — This Sprint - 180 + - 181 +### What is the issue? - 182 +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. + 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 +### What is the bug? - 185 +Two bugs combine: - 186 + - 187 +1. **`models-snapshot.ts`** lists Opus 4.6 Bedrock models at 1M context (reflects model capability, not runtime limit) - 188 +2. **`provider.ts`** `"amazon-bedrock"` handler has NO logic to: - 189 + - Send `additionalModelRequestFields: { anthropic_beta: ["context-1m-2025-08-07"] }` to enable 1M - 190 + - Override the context limit to 200K when 1M beta is NOT active - 191 + - 192 +**Affected models in snapshot**: - 193 +``` - 194 +amazon-bedrock / anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 195 +amazon-bedrock / us.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 196 +amazon-bedrock / eu.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 197 +amazon-bedrock / global.anthropic.claude-opus-4-6-v1: context=1,000,000 ❌ - 198 +``` - 199 + - 200 +All other Bedrock Claude models correctly show 200K. + 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 +### Where it can happen? - 203 +- Any user running Claude Opus 4.6 via Amazon Bedrock - 204 +- Compaction threshold is calculated from `model.limit.context` → 1M → threshold ~900K - 205 +- Bedrock rejects at 200K → 700K token gap where compaction never fires but API always rejects - 206 +- Combined with Issue #1 (infinite retries), this causes the 3.5-hour freeze - 207 + - 208 +### What any agent needs to look for? - 209 +``` - 210 +File: packages/opencode/src/provider/provider.ts - 211 +Location: The "amazon-bedrock" entry in CUSTOM_LOADERS (~line 211) - 212 +Pattern: The returned object has options (providerOptions) and getModel() but NO context limit override - 213 +Also: Look at how compaction.ts uses model.limit.context (~line 33) - 214 +Also: Look at how Cline handles this — they use additionalModelRequestFields for Bedrock - 215 + - 216 +DO NOT edit models-snapshot.ts directly — it is auto-generated by build.ts + 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 + - 219 +### How to make the fix? - 220 +**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. - 221 + - 222 +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: - 223 + - 224 +```typescript - 225 +// Inside amazon-bedrock handler or model resolution - 226 +if (providerID === "amazon-bedrock" && modelData.limit?.context > 200000) { - 227 + // Cap at 200K unless 1M beta is explicitly configured - 228 + modelData.limit.context = 200000 - 229 +} - 230 +``` - 231 + - 232 +**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`. - 233 + - 234 +### Testing - 235 +- Configure Bedrock with Opus 4.6 - 236 +- Verify UI shows context limit as 200K (not 1M) - 237 +- Verify compaction triggers before hitting Bedrock's actual 200K limit - 238 +- Verify no "prompt is too long" errors during normal usage - 239 + - 240 +--- - 241 + - 242 +## Issue #5: `subagent-timeout` — task.ts Has No Execution Timeout - 243 + - 244 +### Priority: P0 — This Sprint - 245 + - 246 +### What is the issue? - 247 +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. + 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 +### What is the bug? - 250 +`packages/opencode/src/tool/task.ts`: - 251 +```typescript - 252 +const result = await SessionPrompt.prompt({ - 253 + messageID, - 254 + sessionID: session.id, - 255 + model: { modelID: model.modelID, providerID: model.providerID }, - 256 + agent: agent.name, - 257 + tools: { ... }, - 258 + parts: promptParts, - 259 +}) - 260 +// ← No timeout wrapper, no AbortController deadline - 261 +``` - 262 + - 263 +This Promise can hang indefinitely if the child session encounters: - 264 +- Infinite retry loop (Issue #1 before fix) - 265 +- Permission hang (Issue #6) - 266 +- Slow API responses that never complete - 267 + - 268 +### Where it can happen? - 269 +- Any subagent execution, but especially: - 270 + - When subagent hits context overflow with retries - 271 + - When subagent needs permission and user is watching parent - 272 + - When API provider is slow or unresponsive - 273 + - 274 +### What any agent needs to look for? - 275 +``` - 276 +File: packages/opencode/src/tool/task.ts - 277 +Location: The SessionPrompt.prompt() call - 278 +Pattern: Check if there's an AbortSignal or timeout mechanism available - 279 +Also check: How the abort signal flows from processor.ts → tool execution → task.ts - 280 +Also check: ctx parameter in execute() — does it carry an abort signal? + 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 +### How to make the fix? - 284 +Wrap the `SessionPrompt.prompt()` call with an AbortController timeout: - 285 + - 286 +```typescript - 287 +const timeout = 5 * 60 * 1000 // 5 minutes (configurable) - 288 +const controller = new AbortController() - 289 +const timer = setTimeout(() => controller.abort(), timeout) - 290 + - 291 +try { - 292 + const result = await SessionPrompt.prompt({ - 293 + // ... existing params ... - 294 + abort: controller.signal, // Pass abort signal if prompt() supports it - 295 + }) - 296 + clearTimeout(timer) - 297 + // ... process result ... - 298 +} catch (e) { - 299 + clearTimeout(timer) - 300 + if (controller.signal.aborted) { - 301 + return { - 302 + title: params.description, - 303 + metadata: { sessionId: session.id, model }, - 304 + output: `ERROR: Subtask timed out after ${timeout / 1000}s. The task may still be running in session ${session.id}.`, - 305 + } - 306 + } - 307 + throw e - 308 +} - 309 +``` + 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 +Check if `SessionPrompt.prompt()` already accepts an `abort` parameter. If not, trace how `processor.ts` passes its abort signal and ensure the plumbing exists. - 312 + - 313 +### Testing - 314 +- Trigger a subagent that would hang (e.g., long-running task) - 315 +- Verify it times out after the configured deadline - 316 +- Verify the parent receives a timeout error message, not silent hang - 317 +- Verify the child session is properly cleaned up - 318 + - 319 +--- - 320 + - 321 +## Issue #6: `permission-abort` — next.ts Permission Promises Hang Forever in Subagents - 322 + - 323 +### Priority: P0 — This Sprint - 324 + - 325 +### What is the issue? - 326 +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. - 327 + - 328 +### What is the bug? - 329 +`packages/opencode/src/permission/next.ts` lines ~143-156: - 330 +```typescript - 331 +export function ask(input: AskInput) { - 332 + return new Promise((resolve, reject) => { - 333 + // ... sets up permission request ... - 334 + // NO abort signal listener - 335 + // NO timeout - 336 + // Promise resolves only when user explicitly grants/denies - 337 + }) - 338 +} - 339 +``` - 340 + - 341 +`grep -c "abort" next.ts` returns **0** — there is zero abort signal awareness in the entire file. - 342 + - 343 +### Where it can happen? - 344 +- Any subagent tool call that requires permission - 345 +- Parallel subagents: one hangs on permission → parent hangs → all other parallel results blocked - 346 +- Even with auto-approve policies, edge cases (new tools, destructive operations) may still prompt - 347 + - 348 +### What any agent needs to look for? - 349 +``` - 350 +File: packages/opencode/src/permission/next.ts - 351 +Location: The ask() function (exported, ~line 143) - 352 +Pattern: The Promise constructor — no abort/timeout handling - 353 +Also check: How ask() is called from tool execution context - 354 +Also check: Whether an AbortSignal is available in the call chain - 355 +Also check: packages/opencode/src/session/prompt.ts for where permissions are requested - 356 +``` - 357 + - 358 +### How to make the fix? - 359 +Add AbortSignal support to the `ask()` function: - 360 + - 361 +```typescript - 362 +export function ask(input: AskInput & { abort?: AbortSignal }) { - 363 + return new Promise((resolve, reject) => { - 364 + // Check if already aborted - 365 + if (input.abort?.aborted) { - 366 + return reject(new Error("Permission request aborted")) - 367 + } - 368 + - 369 + // Listen for abort - 370 + const onAbort = () => { - 371 + reject(new Error("Permission request aborted")) - 372 + } - 373 + input.abort?.addEventListener("abort", onAbort, { once: true }) - 374 + - 375 + // ... existing permission logic ... - 376 + // Clean up abort listener in resolve/reject paths - 377 + }) - 378 +} - 379 +``` - 380 + - 381 +**Important**: The abort signal must be plumbed from `processor.ts` through the tool execution chain to `next.ts`. Trace the call path: - 382 +``` - 383 +processor.ts (has abort) → tool execution → specific tool → permission check → next.ts ask() - 384 +``` - 385 + - 386 +### Testing - 387 +- Trigger a subagent that needs permission - 388 +- Abort the parent session while permission is pending - 389 +- Verify the child permission promise rejects - 390 +- Verify the parent tool call resolves with an error (not hangs forever) - 391 + - 392 +--- - 393 + - 394 +## Issue #7: `retry-catch-all` — retry.ts Catch-All Makes All JSON Errors Retryable - 395 + - 396 +### Priority: P1 — Robustness - 397 + - 398 +### What is the issue? - 399 +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. - 400 + - 401 +### What is the bug? - 402 +`packages/opencode/src/session/retry.ts` line ~96: - 403 +```typescript - 404 +// After checking specific patterns (rate limit, overloaded, etc.)... - 405 +return JSON.stringify(json) // ← ANY remaining JSON error = retryable - 406 +``` - 407 + - 408 +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. - 409 + - 410 +### Where it can happen? - 411 +- Any API error that returns a JSON response body - 412 +- Specifically: Bedrock validation errors (400), authentication errors, quota errors - 413 +- Combined with Issue #1 (no max retries), this creates infinite retry storms - 414 + - 415 +### What any agent needs to look for? - 416 +``` - 417 +File: packages/opencode/src/session/retry.ts - 418 +Location: The retryable() function, specifically the catch-all after all pattern checks - 419 +Pattern: The final `return JSON.stringify(json)` that runs for any unmatched JSON error - 420 +Also check: What specific patterns ARE checked before the catch-all - 421 +Also check: Whether the JSON body contains "isRetryable" or HTTP status fields - 422 +``` - 423 + - 424 +### How to make the fix? - 425 +Replace the blanket catch-all with HTTP status-aware classification: - 426 + - 427 +```typescript - 428 +// Instead of: return JSON.stringify(json) - 429 +// Use: - 430 +const status = (json as any).status ?? (json as any).statusCode - 431 +if (typeof status === "number" && status >= 400 && status < 500) { - 432 + // 4xx errors are client errors — NOT retryable (bad request, auth, not found, etc.) - 433 + return undefined - 434 +} - 435 +// 5xx and truly unknown → retryable (but capped by MAX_RETRIES from Issue #1) - 436 +return JSON.stringify(json) - 437 +``` - 438 + - 439 +Also check for the `isRetryable` field that Bedrock includes: - 440 +```typescript - 441 +if ((json as any).isRetryable === false) return undefined - 442 +``` - 443 + - 444 +**Note**: This fix is SAFER when combined with Issue #1 (MAX_RETRIES), since any misclassification is bounded by the retry cap. - 445 + - 446 +### Testing - 447 +- Send a Bedrock 400 "prompt is too long" error → verify NOT retried - 448 +- Send a 429 rate limit error → verify IS retried - 449 +- Send a 500 server error → verify IS retried (up to MAX_RETRIES) - 450 +- Send a JSON error with `isRetryable: false` → verify NOT retried - 451 + - 452 +--- - 453 + - 454 +## Issue #8: `tool-error-metadata` — processor.ts Drops Metadata on Tool Errors - 455 + - 456 +### Priority: P1 — Robustness - 457 + - 458 +### What is the issue? - 459 +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). - 460 + - 461 +### What is the bug? - 462 +`packages/opencode/src/session/processor.ts` lines ~207-218, the `"tool-error"` case: - 463 +```typescript - 464 +case "tool-error": { - 465 + const match = toolcalls[value.toolCallId] - 466 + if (match && match.state.status === "running") { - 467 + await Session.updatePart({ - 468 + ...match, - 469 + state: { - 470 + status: "error", - 471 + input: value.input ?? match.state.input, - 472 + error: (value.error as any).toString(), - 473 + // ❌ Missing: title: match.state.title, - 474 + // ❌ Missing: metadata: match.state.metadata, - 475 + time: { - 476 + start: match.state.time.start, - 477 + end: Date.now(), - 478 + }, - 479 + }, - 480 + }) - 481 + } - 482 +} - 483 +``` - 484 + - 485 +### Where it can happen? - 486 +- Any tool that errors during execution - 487 +- Most visible for task tool errors — the `sessionId` metadata (used for navigating to child sessions) is lost - 488 +- Also affects batch tool parts and any tool with custom title/metadata - 489 + - 490 +### What any agent needs to look for? - 491 +``` - 492 +File: packages/opencode/src/session/processor.ts - 493 +Location: The "tool-error" case in the stream event handler - 494 +Pattern: Compare the "tool-error" state update with the "tool-result" state update - 495 +The "tool-result" case preserves title and metadata, but "tool-error" does not - 496 +``` - 497 + - 498 +### How to make the fix? - 499 +Add `title` and `metadata` preservation to the error state: - 500 + - 501 +```typescript - 502 +case "tool-error": { - 503 + const match = toolcalls[value.toolCallId] - 504 + if (match && match.state.status === "running") { - 505 + await Session.updatePart({ - 506 + ...match, - 507 + state: { - 508 + status: "error", - 509 + input: value.input ?? match.state.input, - 510 + error: (value.error as any).toString(), - 511 + title: match.state.title, // ← ADD - 512 + metadata: match.state.metadata, // ← ADD - 513 + time: { - 514 + start: match.state.time.start, - 515 + end: Date.now(), - 516 + }, - 517 + }, - 518 + }) - 519 + } - 520 +} - 521 +``` - 522 + - 523 +### Testing - 524 +- Trigger a tool error (e.g., file read on non-existent path) - 525 +- Verify the error part in the UI shows the tool title - 526 +- Trigger a subagent error → verify the sessionId metadata is preserved in the error part - 527 + - 528 +--- - 529 + - 530 +## Issue #9: `batch-error-details` — batch.ts Output Lacks Per-Tool Error Details - 531 + - 532 +### Priority: P2 — Nice to Have - 533 + - 534 +### What is the issue? - 535 +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. - 536 + - 537 +### What is the bug? - 538 +`packages/opencode/src/tool/batch.ts` output message: - 539 +```typescript - 540 +const outputMessage = failedCalls > 0 - 541 + ? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.` - 542 + : `All ${successfulCalls} tools executed successfully.` - 543 +``` - 544 + - 545 +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. - 546 + - 547 +### Where it can happen? - 548 +- Any batch execution where one or more tools fail - 549 +- The LLM sees the summary but not the individual error details - 550 +- Can cause the LLM to blindly retry the same failing operations - 551 + - 552 +### What any agent needs to look for? - 553 +``` - 554 +File: packages/opencode/src/tool/batch.ts - 555 +Location: The outputMessage construction after Promise.all results - 556 +Pattern: The results array has { success, tool, error? } for each call - 557 +``` - 558 + - 559 +### How to make the fix? - 560 +Include per-tool error details in the output: - 561 + - 562 +```typescript - 563 +const outputMessage = failedCalls > 0 - 564 + ? [ - 565 + `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`, - 566 + "", - 567 + "Failed tools:", - 568 + ...results - 569 + .filter((r) => !r.success) - 570 + .map((r) => `- ${r.tool}: ${r.error instanceof Error ? r.error.message : String(r.error)}`), - 571 + ].join("\n") - 572 + : `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!` - 573 +``` - 574 + - 575 +### Testing - 576 +- Execute a batch with one intentionally failing tool (e.g., read non-existent file) - 577 +- Verify the output includes the tool name and error message - 578 +- Verify the LLM can see which tool failed and why - 579 + - 580 +--- - 581 + - 582 +## Implementation Order - 583 + - 584 +``` - 585 +TODAY (P0 — Stop The Bleeding): - 586 + #1 processor-max-retries → 3.5hr freeze prevention - 587 + #2 bedrock-undefined-message → Bedrock overflow detection - 588 + #3 task-swallows-errors → Silent failure surfacing - 589 + - 590 +THIS SPRINT (P0 — Root Cause): - 591 + #4 bedrock-context-cap → Context limit accuracy - 592 + #5 subagent-timeout → Hung subagent prevention - 593 + #6 permission-abort → Permission hang prevention - 594 + - 595 +NEXT SPRINT (P1 — Robustness): - 596 + #7 retry-catch-all → Smart retry classification - 597 + #8 tool-error-metadata → Error UI metadata - 598 + - 599 +BACKLOG (P2): - 600 + #9 batch-error-details → Batch error visibility - 601 +``` - 602 + 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/opencode/src/provider/error.ts` +### 📄 `packages/app/src/context/global-sync/child-store.ts` **Type:** TypeScript Source File 📘 ```diff -@@ -50,7 +50,7 @@ export namespace ProviderError { - 50 50 function message(providerID: string, e: APICallError) { - 51 51 return iife(() => { - 52 52 const msg = e.message - 53 - if (msg === "") { - 53 + if (msg === "" || msg === "undefined") { - 54 54 if (e.responseBody) return e.responseBody - 55 55 if (e.statusCode) { - 56 56 const err = STATUS_CODES[e.statusCode] - 57 57 +@@ -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/opencode/src/session/processor.ts` +### 📄 `packages/app/src/context/global-sync/event-reducer.test.ts` **Type:** TypeScript Source File 📘 ```diff -@@ -18,6 +18,7 @@ import { Question } from "@/question" - 18 18 - 19 19 export namespace SessionProcessor { - 20 20 const DOOM_LOOP_THRESHOLD = 3 - 21 + const MAX_RETRIES = 10 - 21 22 const log = Log.create({ service: "session.processor" }) - 22 23 - 23 24 export type Info = Awaited> -@@ -210,6 +211,7 @@ export namespace SessionProcessor { -210 211 status: "error", -211 212 input: value.input ?? match.state.input, -212 213 error: (value.error as any).toString(), - 214 + metadata: match.state.metadata, -213 215 time: { -214 216 start: match.state.time.start, -215 217 end: Date.now(), -@@ -363,6 +365,23 @@ export namespace SessionProcessor { -363 365 const retry = SessionRetry.retryable(error) -364 366 if (retry !== undefined) { -365 367 attempt++ - 368 + if (attempt >= MAX_RETRIES) { - 369 + log.error("max retries exceeded", { - 370 + sessionID: input.sessionID, - 371 + attempt, - 372 + message: retry, - 373 + }) - 374 + input.assistantMessage.error = MessageV2.fromError( - 375 + new Error(`Maximum retries (${MAX_RETRIES}) exceeded. Last error: ${retry}`), - 376 + { providerID: input.model.providerID }, - 377 + ) - 378 + Bus.publish(Session.Event.Error, { - 379 + sessionID: input.assistantMessage.sessionID, - 380 + error: input.assistantMessage.error, - 381 + }) - 382 + SessionStatus.set(input.sessionID, { type: "idle" }) - 383 + break - 384 + } -366 385 const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) -367 386 SessionStatus.set(input.sessionID, { -368 387 type: "retry", -369 388 +@@ -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/opencode/src/tool/task.ts` +### 📄 `packages/app/src/context/global-sync/event-reducer.ts` **Type:** TypeScript Source File 📘 ```diff -@@ -142,6 +142,24 @@ export const TaskTool = Tool.define("task", async (ctx) => { -142 142 parts: promptParts, -143 143 }) -144 144 - 145 + if ((result.info as MessageV2.Assistant).error) { - 146 + const errorObj = (result.info as MessageV2.Assistant).error! - 147 + const msg = ("data" in errorObj && "message" in errorObj.data) - 148 + ? errorObj.data.message - 149 + : errorObj.name - 150 + return { - 151 + title: params.description, - 152 + metadata: { sessionId: session.id, model }, - 153 + output: [ - 154 + `task_id: ${session.id} (for resuming to continue this task if needed)`, - 155 + "", - 156 + "", - 157 + `ERROR: ${msg}`, - 158 + "", - 159 + ].join("\n"), - 160 + } - 161 + } - 162 + -145 163 const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" -146 164 -147 165 const output = [ -148 166 +@@ -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/opencode/test/provider/error.test.ts` **[ADDED]** +### 📄 `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,106 @@ - 1 +import { describe, expect, test } from "bun:test" - 2 +import { APICallError } from "ai" - 3 +import { ProviderError } from "../../src/provider/error" - 4 + - 5 +function makeAPICallError(opts: { - 6 + message: string - 7 + statusCode?: number - 8 + responseBody?: string - 9 + isRetryable?: boolean - 10 +}) { - 11 + return new APICallError({ - 12 + message: opts.message, - 13 + url: "https://bedrock.us-east-1.amazonaws.com", - 14 + requestBodyValues: {}, - 15 + statusCode: opts.statusCode ?? 400, - 16 + responseHeaders: {}, - 17 + responseBody: opts.responseBody, - 18 + isRetryable: opts.isRetryable ?? false, - 19 + }) - 20 +} - 21 + - 22 +describe("provider.error.parseAPICallError", () => { - 23 + test("detects overflow when message is literal 'undefined' and responseBody has prompt-too-long", () => { - 24 + const error = makeAPICallError({ - 25 + message: "undefined", - 26 + statusCode: 400, - 27 + responseBody: JSON.stringify({ - 28 + message: "prompt is too long: 208845 tokens > 200000 maximum", - 29 + }), - 30 + }) - 31 + const result = ProviderError.parseAPICallError({ - 32 + providerID: "amazon-bedrock", - 33 + error, - 34 + }) - 35 + expect(result.type).toBe("context_overflow") - 36 + }) - 37 + - 38 + test("detects overflow when message is empty string and responseBody has prompt-too-long", () => { - 39 + const error = makeAPICallError({ - 40 + message: "", - 41 + statusCode: 400, - 42 + responseBody: JSON.stringify({ - 43 + message: "prompt is too long: 208845 tokens > 200000 maximum", - 44 + }), - 45 + }) - 46 + const result = ProviderError.parseAPICallError({ - 47 + providerID: "amazon-bedrock", - 48 + error, +@@ -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 + expect(result.type).toBe("context_overflow") - 51 + }) - 52 + - 53 + test("does NOT treat literal 'undefined' as overflow when responseBody has no overflow pattern", () => { - 54 + const error = makeAPICallError({ - 55 + message: "undefined", - 56 + statusCode: 403, - 57 + responseBody: JSON.stringify({ message: "Access denied" }), - 58 + }) - 59 + const result = ProviderError.parseAPICallError({ - 60 + providerID: "amazon-bedrock", - 61 + error, - 62 + }) - 63 + expect(result.type).toBe("api_error") - 64 + }) - 65 + - 66 + test("detects overflow for direct Anthropic provider with normal message", () => { - 67 + const error = makeAPICallError({ - 68 + message: "prompt is too long: 208845 tokens > 200000 maximum", - 69 + statusCode: 400, - 70 + }) - 71 + const result = ProviderError.parseAPICallError({ - 72 + providerID: "anthropic", - 73 + error, - 74 + }) - 75 + expect(result.type).toBe("context_overflow") - 76 + }) - 77 + - 78 + test("detects overflow for Bedrock 'input is too long' pattern", () => { - 79 + const error = makeAPICallError({ - 80 + message: "undefined", - 81 + statusCode: 400, - 82 + responseBody: "input is too long for requested model", - 83 + }) - 84 + const result = ProviderError.parseAPICallError({ - 85 + providerID: "amazon-bedrock", - 86 + error, - 87 + }) - 88 + expect(result.type).toBe("context_overflow") - 89 + }) - 90 + - 91 + test("returns api_error for normal non-overflow errors", () => { - 92 + const error = makeAPICallError({ - 93 + message: "Rate limit exceeded", - 94 + statusCode: 429, - 95 + isRetryable: true, - 96 + }) - 97 + const result = ProviderError.parseAPICallError({ - 98 + providerID: "amazon-bedrock", - 99 + error, - 100 + }) - 101 + expect(result.type).toBe("api_error") - 102 + if (result.type === "api_error") { - 103 + expect(result.isRetryable).toBe(true) - 104 + } - 105 + }) - 106 +}) - 107 + 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 ``` @@ -1729,4 +1425,4 @@ AI_REVIEW.md | 798 ++++++++++++++++++++++++++ --- *Individual file review generated by AI Visual Code Review v2.0* -*Generated: 2026-02-25T15:47:47.700Z* +*Generated: 2026-02-26T10:33:24.938Z* 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/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/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) + }) + }) + }) +}) From a898dc9ed8f6cc40d3205ef74c2c7006934d3538 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Thu, 26 Feb 2026 17:05:04 +0530 Subject: [PATCH 17/20] docs(design): add Aurora Design System specification Comprehensive design system document covering: - Design vision and principles for "code illuminated from within" - Color system with dark/light themes and TUI mappings - Typography scale using JetBrains Mono and Geist/Inter - Spacing system based on 4px grid - Motion and animation guidelines with spring physics - Component specifications for buttons, cards, inputs, messages - TUI terminal component translations with Unicode patterns - Stitch prompts for visual prototyping - Accessibility requirements including reduced motion support - WCAG compliance checklist --- docs/plans/2025-02-26-aurora-design-system.md | 1977 +++++++++++++++++ 1 file changed, 1977 insertions(+) create mode 100644 docs/plans/2025-02-26-aurora-design-system.md 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)* From d65ad2956efc356ab03ae30142212228cb4d5f14 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Thu, 26 Feb 2026 17:15:40 +0530 Subject: [PATCH 18/20] chore(deps): update reference submodules --- references/AionUi | 2 +- references/cline | 2 +- references/codex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/references/AionUi b/references/AionUi index dce8776d4a15..3227e4fb9847 160000 --- a/references/AionUi +++ b/references/AionUi @@ -1 +1 @@ -Subproject commit dce8776d4a1551119d94ec10d07949ba0f863de4 +Subproject commit 3227e4fb98474db815c6b867038f41d49b68916e diff --git a/references/cline b/references/cline index f10b6f39be17..94b02bf0527c 160000 --- a/references/cline +++ b/references/cline @@ -1 +1 @@ -Subproject commit f10b6f39be17f24c3a410ae5780eb7adf15473b8 +Subproject commit 94b02bf0527cb5c4047b228ea44ce264b74dbf58 diff --git a/references/codex b/references/codex index f86087eaa8da..52aa49db1b8a 160000 --- a/references/codex +++ b/references/codex @@ -1 +1 @@ -Subproject commit f86087eaa8da9cac9245c87085adc1b7a4065f36 +Subproject commit 52aa49db1b8af1ef2b090363b08260a026ad853c From 20e97a624b1333bb70a285b0e97761a271ca3c2a Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Fri, 27 Feb 2026 15:43:51 +0530 Subject: [PATCH 19/20] docs: add debugging plan and Boot.dev Python agent references --- ...-02-26-roo-code-orphaned-tool-use-debug.md | 442 ++++++++++++++++++ references/AionUi | 2 +- ...gent in Python: Agent Loop | Boot.dev.html | 30 ++ ... Python: Build an AI Agent | Boot.dev.html | 30 ++ ... in Python: Calculator App | Boot.dev.html | 30 ++ ... Python: Calling Functions | Boot.dev.html | 30 ++ ...thon: Function Declaration | Boot.dev.html | 30 ++ ...gent in Python: Gemini API | Boot.dev.html | 30 ++ ...n Python: Get File Content | Boot.dev.html | 30 ++ ...Agent in Python: Get Files | Boot.dev.html | 30 ++ ... Python: More Declarations | Boot.dev.html | 30 ++ ... Python: Multiple Messages | Boot.dev.html | 30 ++ ...nt in Python: Python Setup | Boot.dev.html | 30 ++ ...gent in Python: Run Python | Boot.dev.html | 30 ++ ...n Python: Submit Your Repo | Boot.dev.html | 30 ++ ...t in Python: System Prompt | Boot.dev.html | 30 ++ ... in Python: Token Metadata | Boot.dev.html | 30 ++ ...ent in Python: Update Code | Boot.dev.html | 30 ++ ...gent in Python: User Input | Boot.dev.html | 30 ++ ... in Python: Verbose Output | Boot.dev.html | 30 ++ ...ent in Python: Write Files | Boot.dev.html | 30 ++ references/Roo-Code | 1 + references/cline | 2 +- references/codex | 2 +- 24 files changed, 1016 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2025-02-26-roo-code-orphaned-tool-use-debug.md create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Agent Loop | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Build an AI Agent | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Calculator App | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Calling Functions | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Function Declaration | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Gemini API | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Get File Content | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Get Files | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: More Declarations | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Multiple Messages | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Python Setup | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Run Python | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Submit Your Repo | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: System Prompt | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Token Metadata | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Update Code | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: User Input | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Verbose Output | Boot.dev.html create mode 100644 references/Build an AI Agent in Python Boot.dev/Build an AI Agent in Python: Write Files | Boot.dev.html create mode 160000 references/Roo-Code 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/references/AionUi b/references/AionUi index 3227e4fb9847..8a6c0e10da44 160000 --- a/references/AionUi +++ b/references/AionUi @@ -1 +1 @@ -Subproject commit 3227e4fb98474db815c6b867038f41d49b68916e +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