diff --git a/CONTEXT/PLAN-4709-restore-live-token-usage-2025-12-09.md b/CONTEXT/PLAN-4709-restore-live-token-usage-2025-12-09.md
new file mode 100644
index 000000000000..9fdd2847fe57
--- /dev/null
+++ b/CONTEXT/PLAN-4709-restore-live-token-usage-2025-12-09.md
@@ -0,0 +1,209 @@
+# Plan: Restore Live Token Usage Feature (PR #4709)
+
+**Date:** 2025-12-09
+**Related PR:** https://github.com/sst/opencode/pull/4709
+**Status:** IMPLEMENTED - Feature restored 2025-12-10
+
+## Overview
+
+This plan documents the restoration of the "Live Token Usage During Streaming" feature that was originally added in PR #4709. The feature provides:
+
+- Real-time token tracking while streaming responses
+- `IN/OUT` format display for input/output tokens
+- Reasoning token display for "thinking" models
+- Toggle tokens command in TUI
+
+## Current State Analysis
+
+### What's Working
+
+| Component | File | Status |
+| ------------------------------ | --------------------------------------------- | -------------------------- |
+| Token utility functions | `packages/opencode/src/util/token.ts` | **EXISTS** |
+| Message schema fields | `packages/opencode/src/session/message-v2.ts` | **EXISTS** |
+| Token calculation in prompt.ts | `packages/opencode/src/session/prompt.ts` | **EXISTS** |
+| Subtask completion fix | `packages/opencode/src/session/prompt.ts` | **EXISTS** (bug was fixed) |
+
+### What's Missing
+
+| Component | File | Status |
+| ------------------------ | ------------------------------------------------------------ | ----------- |
+| Streaming token updates | `packages/opencode/src/session/processor.ts` | **MISSING** |
+| `showTokens` state | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| `contextLimit` memo | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| IN/OUT token display | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| Reasoning token display | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| "Toggle tokens" command | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| User message token count | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+
+### Backend Token Utility (Exists)
+
+```typescript
+// packages/opencode/src/util/token.ts
+Token.estimate(input: string) // Character-based estimation
+Token.toCharCount(tokenEstimate: number) // Convert tokens to chars
+Token.toTokenEstimate(charCount: number) // Convert chars to tokens
+Token.calculateToolResultTokens(parts) // Estimate tool result size
+```
+
+### Message Schema Fields (Exist)
+
+```typescript
+// UserMessage
+sentEstimate: z.number().optional()
+contextEstimate: z.number().optional()
+
+// AssistantMessage
+outputEstimate: z.number().optional()
+reasoningEstimate: z.number().optional()
+contextEstimate: z.number().optional()
+sentEstimate: z.number().optional()
+```
+
+## Technical Approach
+
+### Token Estimation Logic
+
+- Simple estimation based on character count using `CHARS_PER_TOKEN` constant
+- `calculateToolResultTokens` estimates size of tool inputs, outputs, and errors
+- Estimates prefixed with `~` to indicate they are approximate
+
+### Display Format
+
+- `IN X↓` - Input/context tokens (sent to model)
+- `OUT Y↑` - Output tokens (generated by model)
+- `~X think` - Reasoning tokens for thinking models
+- Context percentage: `X% of limit`
+
+## Implementation Tasks
+
+### Phase 1: Add Streaming Token Updates to Processor
+
+- [x] Import `Token` module in `packages/opencode/src/session/processor.ts`
+- [x] Add `reasoningTotal` and `textTotal` character accumulators at processor creation
+- [x] Update `reasoning-delta` handler to calculate and store `reasoningEstimate`:
+ ```typescript
+ case "reasoning-delta":
+ reasoningTotal += value.text.length
+ input.assistantMessage.reasoningEstimate = Token.toTokenEstimate(reasoningTotal)
+ await Session.updateMessage(input.assistantMessage)
+ ```
+- [x] Update `text-delta` handler to calculate and store `outputEstimate`:
+ ```typescript
+ case "text-delta":
+ textTotal += value.text.length
+ input.assistantMessage.outputEstimate = Token.toTokenEstimate(textTotal)
+ await Session.updateMessage(input.assistantMessage)
+ ```
+- [ ] Update `finish-step` to emit final `contextEstimate` from usage data
+
+### Phase 2: Add Token Display State
+
+- [x] Add `showTokens` signal to session component:
+ ```typescript
+ const [showTokens, setShowTokens] = createSignal(kv.get("show_tokens", false))
+ ```
+- [ ] Add `contextLimit` memo that gets limit from current model/provider
+- [x] Add to context provider: `showTokens: () => boolean`
+
+### Phase 3: Add Toggle Tokens Command
+
+- [x] Add "Toggle tokens" command to `command.register()` array:
+ ```typescript
+ {
+ title: showTokens() ? "Hide tokens" : "Show tokens",
+ value: "session.toggle.tokens",
+ category: "Session",
+ onSelect: (dialog) => {
+ setShowTokens((prev) => {
+ const next = !prev
+ kv.set("show_tokens", next)
+ return next
+ })
+ dialog.clear()
+ },
+ }
+ ```
+
+### Phase 4: Update AssistantMessage Component
+
+- [x] Add token calculation logic:
+ ```typescript
+ const inputTokens = createMemo(() => {
+ const sent = props.message.sentEstimate ?? 0
+ const context = props.message.contextEstimate ?? 0
+ return sent + context
+ })
+ const outputTokens = createMemo(() => props.message.tokens?.output ?? props.message.outputEstimate ?? 0)
+ const reasoningTokens = createMemo(() => props.message.tokens?.reasoning ?? props.message.reasoningEstimate ?? 0)
+ ```
+- [x] Add conditional token display:
+ ```tsx
+
+
+ IN {inputTokens().toLocaleString()}↓ OUT {outputTokens().toLocaleString()}↑
+ ~{reasoningTokens().toLocaleString()} think
+
+
+ ```
+
+### Phase 5: Update UserMessage Component
+
+- [x] Add individual token count display when `showTokens()` is true:
+ ```tsx
+
+ ~{props.message.sentEstimate?.toLocaleString()} tokens
+
+ ```
+
+## Code References
+
+### Internal Files
+
+- `packages/opencode/src/util/token.ts` - Token utility functions (exists)
+- `packages/opencode/src/session/message-v2.ts:308-309` - User message estimate fields (exists)
+- `packages/opencode/src/session/message-v2.ts:369-372` - Assistant message estimate fields (exists)
+- `packages/opencode/src/session/processor.ts:82-88` - reasoning-delta handler (needs update)
+- `packages/opencode/src/session/processor.ts:305-315` - text-delta handler (needs update)
+- `packages/opencode/src/session/processor.ts:251-270` - finish-step handler (needs update)
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1097-1162` - AssistantMessage component
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1001-1095` - UserMessage component
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:241-777` - Command registration
+
+### External References
+
+- Original PR: https://github.com/sst/opencode/pull/4709
+
+## Estimated Changes
+
+| File | Lines Added | Lines Modified |
+| -------------- | ----------- | -------------- |
+| `processor.ts` | ~15 | ~10 |
+| `index.tsx` | ~50 | ~15 |
+| **Total** | ~65 | ~25 |
+
+## Validation Criteria
+
+- [x] Token estimates display during streaming (before final usage available)
+- [x] `IN X↓` shows input/context tokens accurately
+- [x] `OUT Y↑` shows output tokens, updating in real-time during generation
+- [x] Reasoning tokens display for models that support thinking (e.g., Claude)
+- [x] "Toggle tokens" command appears in command palette
+- [x] Toggle persists via KV store across sessions
+- [x] User messages show estimated token count when toggle enabled
+- [x] Estimates use `~` prefix to indicate approximation
+- [x] Final token counts from API replace estimates when available
+
+## Dependencies
+
+None - all required utilities and schema fields already exist.
+
+## Risks & Considerations
+
+1. **Estimation Accuracy**: Character-based estimation is approximate. Actual tokenization varies by model. Consider this acceptable for UX purposes.
+
+2. **Performance**: Updating message on every delta may cause performance issues. Consider throttling updates (e.g., every 100ms or 100 chars).
+
+3. **Context Limit**: Different models have different context limits. Need to properly fetch limit from provider/model configuration.
+
+4. **Subtask Bug Status**: The regression bug mentioned in PR discussions (missing `updatePart` call after `taskTool.execute`) was previously fixed and the fix is still present. No action needed.
diff --git a/CONTEXT/PLAN-4791-restore-bash-viewer-ansi-2025-12-09.md b/CONTEXT/PLAN-4791-restore-bash-viewer-ansi-2025-12-09.md
new file mode 100644
index 000000000000..f1036d56036b
--- /dev/null
+++ b/CONTEXT/PLAN-4791-restore-bash-viewer-ansi-2025-12-09.md
@@ -0,0 +1,188 @@
+# Plan: Restore Bash Tool Expansion & ANSI Output Feature (PR #4791)
+
+**Date:** 2025-12-09
+**Related PR:** https://github.com/sst/opencode/pull/4791
+**Status:** IMPLEMENTED - Feature restored 2025-12-10
+
+## Overview
+
+This plan documents the restoration of the "Bash Tool Expansion & Colored ANSI Output" feature that was originally added in PR #4791. The feature provides:
+
+- Full-screen viewer for bash command outputs
+- ANSI color rendering for terminal output
+- Output truncation in chat with "Click to view full output" button
+- Forced color output from CLI tools
+
+## Current State Analysis
+
+### What's Missing
+
+| Component | File | Status |
+| ------------------------------ | -------------------------------------------------------------- | ----------- |
+| `ghostty-opentui` dependency | `packages/opencode/package.json` | **MISSING** |
+| `ptyToText` import | `packages/opencode/src/tool/bash.ts` | **MISSING** |
+| `FORCE_COLOR` env vars | `packages/opencode/src/tool/bash.ts` | **MISSING** |
+| `bashOutput` signal | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| `showBashOutput` context | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| Full-screen bash viewer | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| `ghostty-terminal` component | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| Keyboard navigation for viewer | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| Bash tool truncated preview | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **MISSING** |
+| `initialValue` prop | `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` | **MISSING** |
+| `text` getter on PromptRef | `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` | **MISSING** |
+
+### Current Bash Tool Rendering
+
+The current bash tool simply strips ANSI codes and displays plain text:
+
+```typescript
+const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
+// ...
+{output()}
+```
+
+## Technical Approach
+
+### ANSI Color Rendering
+
+- Use `ghostty-opentui` package for terminal rendering
+- `GhosttyTerminalRenderable` component renders ANSI codes properly
+- `ptyToText()` processes raw PTY output
+
+### Environment Variables for Color Output
+
+Force CLI tools to produce colored output even when not in TTY:
+
+```typescript
+env: {
+ FORCE_COLOR: "3",
+ CLICOLOR: "1",
+ CLICOLOR_FORCE: "1",
+ TERM: "xterm-256color",
+ TERM_PROGRAM: "bash-tool",
+ PY_COLORS: "1",
+ ANSICON: "1",
+ // ... more
+}
+```
+
+### Full-Screen Viewer
+
+- Toggle between chat view and full-screen bash viewer
+- Keyboard navigation: ESC to close, Page Up/Down, Home/End for scrolling
+- Preserve prompt text when switching views
+
+## Implementation Tasks
+
+### Phase 1: Add Dependencies
+
+- [x] Add `ghostty-opentui` to `packages/opencode/package.json`
+ ```json
+ "ghostty-opentui": "1.3.6"
+ ```
+- [x] Run `bun install` to update lockfile
+
+### Phase 2: Update Bash Tool
+
+- [x] Add import to `packages/opencode/src/tool/bash.ts`:
+ ```typescript
+ import { ptyToText } from "ghostty-opentui"
+ ```
+- [x] Update spawn environment variables (around line 225):
+ ```typescript
+ env: {
+ ...process.env,
+ FORCE_COLOR: "3",
+ CLICOLOR: "1",
+ CLICOLOR_FORCE: "1",
+ TERM: "xterm-256color",
+ TERM_PROGRAM: "bash-tool",
+ PY_COLORS: "1",
+ ANSICON: "1",
+ NO_COLOR: undefined,
+ }
+ ```
+- [x] Wrap output with `ptyToText()` before returning
+
+### Phase 3: Update Session Index
+
+- [x] Add `BashOutputView` type:
+ ```typescript
+ type BashOutputView = {
+ command: string
+ output: () => string
+ }
+ ```
+- [x] Add `bashOutput` signal: `createSignal(undefined)`
+- [x] Add `showBashOutput` function to context
+- [x] Register `ghostty-terminal` component with opentui
+- [x] Add keyboard handlers for viewer navigation (ESC, PageUp/Down, Home/End)
+- [x] Add conditional rendering that switches between scrollbox and bash viewer
+- [x] Add `promptDraft` signal for preserving prompt text
+
+### Phase 4: Update Bash Tool Renderer
+
+- [x] Update the bash tool registration (around line 1382-1404):
+ - [x] Use `` for output preview
+ - [x] Limit preview to 20 lines
+ - [x] Add "Click to see full output" button when output exceeds limit
+ - [x] Wire click handler to `showBashOutput`
+
+### Phase 5: Update Prompt Component
+
+- [ ] Add `initialValue` prop to `PromptProps` type (line 29-36) - Not needed for basic implementation
+- [ ] Add `text` getter to `PromptRef` type (line 38-45) - Not needed for basic implementation
+- [ ] Handle `initialValue` in `onMount` to restore prompt text - Not needed for basic implementation
+
+## Code References
+
+### Internal Files
+
+- `packages/opencode/package.json` - Add ghostty-opentui dependency
+- `packages/opencode/src/tool/bash.ts:225-233` - spawn() call, needs env vars
+- `packages/opencode/src/tool/bash.ts:350-358` - return statement, needs ptyToText
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:80-89` - Context definition
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1382-1404` - Bash tool renderer
+- `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:29-45` - PromptProps/PromptRef types
+
+### External References
+
+- Original PR: https://github.com/sst/opencode/pull/4791
+- ghostty-opentui package: https://www.npmjs.com/package/ghostty-opentui
+
+## Estimated Changes
+
+| File | Lines Added | Lines Modified |
+| ------------------ | ----------- | -------------- |
+| `package.json` | 1 | 0 |
+| `bash.ts` | 15 | 5 |
+| `index.tsx` | ~150 | ~30 |
+| `prompt/index.tsx` | 10 | 5 |
+| **Total** | ~176 | ~40 |
+
+## Validation Criteria
+
+- [x] `bun install` succeeds with new dependency
+- [x] CLI tools produce colored output (test with `ls --color`, `git status`)
+- [x] Bash output in chat shows ANSI colors (not raw escape codes)
+- [x] Long outputs are truncated to 20 lines in chat preview
+- [x] "Click to see full output" button appears for truncated outputs
+- [x] Clicking opens full-screen bash viewer
+- [x] Full-screen viewer shows complete output with colors
+- [x] ESC key closes full-screen viewer
+- [x] Page Up/Down, Home/End work in viewer
+- [ ] Prompt text is preserved when opening/closing viewer - Minor, can be addressed later
+
+## Dependencies
+
+- `ghostty-opentui` npm package (needs to be added)
+
+## Risks & Considerations
+
+1. **Package Compatibility**: The `ghostty-opentui` package may have been updated since PR #4791. Check for any API changes.
+
+2. **Performance**: Rendering ANSI codes in the TUI may impact performance for very large outputs. The 20-line preview helps mitigate this.
+
+3. **Interactive Commands**: This feature does NOT support interactive commands (like `top` or `vim`). It's strictly for static output rendering.
+
+4. **Platform Differences**: Color forcing env vars may behave differently on Windows vs Unix. Test on multiple platforms.
diff --git a/CONTEXT/PLAN-4865-restore-subagent-navigation-2025-12-09.md b/CONTEXT/PLAN-4865-restore-subagent-navigation-2025-12-09.md
new file mode 100644
index 000000000000..21bc882185ab
--- /dev/null
+++ b/CONTEXT/PLAN-4865-restore-subagent-navigation-2025-12-09.md
@@ -0,0 +1,212 @@
+# Plan: Restore Subagent Sidebar Navigation Feature (PR #4865)
+
+**Date:** 2025-12-09
+**Related PR:** https://github.com/sst/opencode/pull/4865
+**Status:** IMPLEMENTED - Fix completed 2025-12-10
+
+## Overview
+
+This plan documents the restoration of the "Subagents Sidebar with Clickable Navigation" feature that was originally added in PR #4865. The feature provides:
+
+- Sidebar display of active and past subagents grouped by type
+- Click navigation to subagent sessions
+- `+Up` keybind to return to parent session
+- Collapsible subagents section
+
+## Current State Analysis
+
+### What's Working
+
+| Component | File | Status |
+| ------------------------------ | ------------- | --------- |
+| Subagent grouping | `sidebar.tsx` | **WORKS** |
+| ASCII spinners | `sidebar.tsx` | **WORKS** |
+| Active/error status display | `sidebar.tsx` | **WORKS** |
+| Expand/collapse UI | `sidebar.tsx` | **WORKS** |
+| "Go to parent session" command | `index.tsx` | **WORKS** |
+| `session_parent` keybind | `config.ts` | **WORKS** |
+| Header "Parent" indicator | `header.tsx` | **WORKS** |
+| Child session cycling | `index.tsx` | **WORKS** |
+
+### What's Broken
+
+| Component | File | Status |
+| ---------------------------- | --------------------- | ---------------------------------- |
+| Click navigation to subagent | `sidebar.tsx:244-252` | **BROKEN** - uses wrong session ID |
+
+## Root Cause Analysis
+
+The bug is in `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx` at lines 244-252:
+
+```typescript
+const sessionId = part.sessionID // BUG: This is the PARENT session ID!
+return (
+ {
+ route.navigate({ type: "session", sessionID: sessionId })
+ }}
+ >
+```
+
+**The Problem**: `part.sessionID` refers to the session that _contains_ the tool part (the parent session), NOT the subagent session that was created by the task tool.
+
+**The Solution**: The subagent's session ID is stored in the tool's **metadata**:
+
+From `packages/opencode/src/tool/task.ts`:
+
+```typescript
+// Line 49-54: During execution
+ctx.metadata({
+ title: params.description,
+ metadata: {
+ sessionId: session.id, // <-- This is the subagent session ID
+ },
+})
+
+// Line 127-132: In final output
+return {
+ title: params.description,
+ metadata: {
+ summary,
+ sessionId: session.id, // <-- Also here
+ },
+ output,
+}
+```
+
+## Implementation Tasks
+
+### Phase 1: Fix Click Navigation
+
+- [x] Update `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx` lines 244-252
+- [x] Extract `sessionId` from part metadata instead of `part.sessionID`:
+ ```typescript
+ const metadata =
+ part.state.status === "completed"
+ ? part.state.metadata
+ : ((part.state as { metadata?: Record }).metadata ?? {})
+ const subagentSessionId = (metadata?.sessionId as string) ?? undefined
+ ```
+- [x] Update click handler to use the correct session ID:
+ ```typescript
+ onMouseDown={() => {
+ if (subagentSessionId) {
+ route.navigate({ type: "session", sessionID: subagentSessionId })
+ }
+ }}
+ ```
+- [x] Add visual feedback when subagent session ID is not available (e.g., disabled state or tooltip)
+
+### Phase 2: (Optional) Improve Error Handling
+
+- [x] Handle case where subagent session ID is missing (task still running, metadata not yet populated)
+- [ ] Consider showing cursor style change on hover to indicate clickability
+- [ ] Add console warning if session navigation fails
+
+## Code References
+
+### Internal Files
+
+- `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx:244-252` - **BUG LOCATION**
+- `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx:40-48` - taskToolParts extraction
+- `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx:50-60` - subagentGroups memo
+- `packages/opencode/src/tool/task.ts:49-54` - metadata with sessionId during execution
+- `packages/opencode/src/tool/task.ts:127-132` - metadata with sessionId in return
+
+### External References
+
+- Original PR: https://github.com/sst/opencode/pull/4865
+
+## Detailed Code Change
+
+### Current Code (Broken)
+
+```typescript
+// sidebar.tsx lines 238-261
+
+ {(part) => {
+ const isActive = () => part.state.status === "running" || part.state.status === "pending"
+ const isError = () => part.state.status === "error"
+ const input = part.state.input as Record
+ const description = (input?.description as string) ?? ""
+ const sessionId = part.sessionID // WRONG: This is the parent session ID
+ return (
+ {
+ route.navigate({ type: "session", sessionID: sessionId })
+ }}
+ >
+ {/* ... */}
+
+ )
+ }}
+
+```
+
+### Fixed Code
+
+```typescript
+// sidebar.tsx lines 238-261
+
+ {(part) => {
+ const isActive = () => part.state.status === "running" || part.state.status === "pending"
+ const isError = () => part.state.status === "error"
+ const input = part.state.input as Record
+ const description = (input?.description as string) ?? ""
+
+ // Get subagent session ID from metadata, not part.sessionID
+ const metadata = part.state.status === "completed"
+ ? part.state.metadata
+ : (part.state as { metadata?: Record }).metadata ?? {}
+ const subagentSessionId = (metadata?.sessionId as string) ?? undefined
+
+ return (
+ {
+ if (subagentSessionId) {
+ route.navigate({ type: "session", sessionID: subagentSessionId })
+ }
+ }}
+ >
+ {/* ... */}
+
+ )
+ }}
+
+```
+
+## Estimated Changes
+
+| File | Lines Added | Lines Modified |
+| ------------- | ----------- | -------------- |
+| `sidebar.tsx` | 5 | 3 |
+| **Total** | 5 | 3 |
+
+## Validation Criteria
+
+- [x] Subagents appear in sidebar grouped by type (already works)
+- [x] ASCII spinners animate for active tasks (already works)
+- [x] Clicking on a completed subagent navigates to the subagent's session
+- [x] Clicking on a running subagent navigates to the subagent's session (if metadata is available)
+- [x] `+Up` returns to parent session from subagent view (already works)
+- [x] Child session cycling with `+Left/Right` works (already works)
+- [x] Header shows "Parent" indicator when viewing subagent (already works)
+
+## Dependencies
+
+None - this is a simple bug fix.
+
+## Risks & Considerations
+
+1. **Timing Issue**: For tasks that are still running, the metadata may not be populated yet. The `ctx.metadata()` call happens early in task execution (line 49-54 in task.ts), so this should be available even for running tasks.
+
+2. **Type Safety**: The metadata extraction requires type casting. Consider adding proper type definitions if this pattern is used elsewhere.
+
+3. **Backward Compatibility**: Old sessions created before the metadata field was added may not have `sessionId` in metadata. Handle this gracefully by doing nothing on click.
diff --git a/CONTEXT/PLAN-4898-restore-search-in-messages-2025-12-09.md b/CONTEXT/PLAN-4898-restore-search-in-messages-2025-12-09.md
new file mode 100644
index 000000000000..12da7e77fd41
--- /dev/null
+++ b/CONTEXT/PLAN-4898-restore-search-in-messages-2025-12-09.md
@@ -0,0 +1,136 @@
+# Plan: Restore Search in Messages Feature (PR #4898)
+
+**Date:** 2025-12-09
+**Related PR:** https://github.com/sst/opencode/pull/4898
+**Status:** IMPLEMENTED - Feature restored 2025-12-10
+
+## Overview
+
+This plan documents the restoration of the "Search in Messages" feature that was originally added in PR #4898. The feature allows users to press `Ctrl+F` to search through chat history with match highlighting and navigation.
+
+## Current State Analysis
+
+### What's Working
+
+| Component | File | Status |
+| ------------------------ | --------------------------------------------------------------- | ---------------------- |
+| `SearchInput` component | `packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx` | **EXISTS** (231 lines) |
+| Theme strikethrough hack | `packages/opencode/src/cli/cmd/tui/context/theme.tsx` | **EXISTS** (line 912) |
+
+### What's Missing
+
+| Component | File | Status |
+| ------------------------ | ------------------------------------------------------------ | ----------------------------- |
+| Search integration | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **REMOVED** by upstream merge |
+| `ctrl+f` keybind handler | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **REMOVED** |
+| Match highlighting | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **REMOVED** |
+| Match navigation | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | **REMOVED** |
+
+The `SearchInput` component exists but is orphaned - not imported or used anywhere.
+
+## Technical Approach
+
+### Highlighting Strategy ("Strikethrough Hack")
+
+- For Markdown messages, inject `~~` (strikethrough) markers around matched text
+- Theme renders `markup.strikethrough` as highlighted block (Primary Background / Inverse Text)
+- Complex regex handles matches inside code blocks by temporarily "breaking" and restarting the block
+
+### Scroll Navigation & Estimation
+
+- TUI renders large markdown blocks as single components
+- Estimation algorithm scrolls to specific matches within long messages
+- Calculates approximate line using `match.charOffset / charsPerLine`
+- Scrolls viewport to estimated Y-offset
+
+## Implementation Tasks
+
+### Phase 1: Add Search State and Types
+
+- [x] Add `SearchMatch` type definition to `index.tsx`
+ ```typescript
+ type SearchMatch = {
+ messageID: string
+ partID: string
+ matchIndex: number
+ charOffset: number
+ }
+ ```
+- [x] Add import for `SearchInput` and `SearchInputRef` from `@tui/component/prompt/search`
+- [x] Add search-related signals:
+ - [x] `searchMode: createSignal(false)`
+ - [x] `searchQuery: createSignal("")`
+ - [x] `currentMatchIndex: createSignal(0)`
+- [x] Add `matches` memo that computes `SearchMatch[]` from messages
+
+### Phase 2: Add Search Functions
+
+- [x] Implement `handleNextMatch()` function
+- [x] Implement `handlePrevMatch()` function
+- [x] Implement `scrollToMatch(index: number)` function with estimation logic
+
+### Phase 3: Add Keyboard Handling
+
+- [x] Add `ctrl+f` keybind handler to toggle search mode
+- [x] Ensure `ESC` exits search mode and returns focus to prompt
+- [x] Wire up `Up` and `Down` arrow navigation to match cycling
+
+### Phase 4: Add Highlighter Components
+
+- [x] Implement `SearchHighlighter` component for plain text parts
+- [x] Implement `MarkdownSearchHighlighter` component for markdown with code block handling
+- [x] Add regex for handling code blocks: breaks code fence, inserts highlight, restarts fence
+
+### Phase 5: Update Context and UI
+
+- [x] Add to context provider:
+ - `searchQuery: () => string`
+ - `currentMatchIndex: () => number`
+ - `matches: () => SearchMatch[]`
+- [x] Add conditional rendering of `SearchInput` when `searchMode()` is true
+- [x] Update `UserMessage` component to use `SearchHighlighter` when search is active
+- [x] Update `TextPart` component to use `MarkdownSearchHighlighter` when search is active
+
+## Code References
+
+### Internal Files
+
+- `packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx` - SearchInput component (exists)
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:80-89` - Context definition (needs search fields)
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1001-1095` - UserMessage component (needs highlighting)
+- `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1203-1221` - TextPart component (needs highlighting)
+- `packages/opencode/src/cli/cmd/tui/context/theme.tsx:912` - Strikethrough hack (exists)
+
+### External References
+
+- Original PR: https://github.com/sst/opencode/pull/4898
+
+## Estimated Changes
+
+| File | Lines Added | Lines Modified |
+| ----------- | ----------- | -------------- |
+| `index.tsx` | ~340 | ~20 |
+| **Total** | ~340 | ~20 |
+
+## Validation Criteria
+
+- [x] Pressing `Ctrl+F` activates search mode with search input visible
+- [x] Typing a query highlights all matches in chat history
+- [x] Match counter displays "X of Y" format (e.g., "1 of 12")
+- [x] `Up` and `Down` arrows navigate between matches
+- [x] Viewport scrolls to current match, including matches within long messages
+- [x] `ESC` exits search mode and returns to normal prompt
+- [x] Matches inside code blocks are highlighted correctly
+- [x] Search works across both user and assistant messages
+
+## Dependencies
+
+None - the `SearchInput` component is already complete and ready to use.
+
+## Risks & Considerations
+
+1. **Scroll Estimation Accuracy**: The estimation algorithm for scrolling to matches within long messages achieves ~80% accuracy. Consider if more precise scrolling is needed.
+
+2. **Code Block Handling**: The regex for handling matches inside code blocks is complex. Test thoroughly with various code block scenarios.
+
+3. **Performance**: Match calculation runs on every keystroke. May need debouncing for very long sessions.
diff --git a/README.md b/README.md
index 5c2a1a950044..12587d922024 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,10 @@ The following PRs have been merged into this fork and are awaiting merge into up
| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | [@remorses](https://github.com/remorses) | Open | Full terminal emulation for bash output with color support |
| [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | [@AmineGuitouni](https://github.com/AmineGuitouni) | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits |
| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | [@arsham](https://github.com/arsham) | Open | Real-time token tracking and display during model responses |
-| [#4773](https://github.com/sst/opencode/pull/4773) | Configurable subagent visibility | [@Sewer56](https://github.com/Sewer56) | Open | Allow agents to restrict which subagents they can invoke |
| [#4865](https://github.com/sst/opencode/pull/4865) | Subagents sidebar with clickable navigation | [@franlol](https://github.com/franlol) | Open | Show subagents in sidebar with click-to-navigate and parent keybind |
| [#4515](https://github.com/sst/opencode/pull/4515) | Show plugins in /status | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Open | Display configured plugins in /status dialog alongside MCP/LSP servers |
-_Last updated: 2025-12-07_
+_Last updated: 2025-12-10_
---
diff --git a/bun.lock b/bun.lock
index d2b570f0f627..dfe70a2d44d5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -256,6 +256,7 @@
"decimal.js": "10.5.0",
"diff": "catalog:",
"fuzzysort": "3.1.0",
+ "ghostty-opentui": "1.3.6",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
@@ -2439,6 +2440,8 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
+ "ghostty-opentui": ["ghostty-opentui@1.3.6", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-DETUuSiIcTwTIqICmDEezYxt0gXk/4bGC+28Hd4fqFdejB8GTCJvRzGGcwfPoYgIKxsqcVTm1Hku3m6K+NiPAA=="],
+
"ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index ca3af5810b4c..34be78eb5ee5 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -85,6 +85,7 @@
"decimal.js": "10.5.0",
"diff": "catalog:",
"fuzzysort": "3.1.0",
+ "ghostty-opentui": "1.3.6",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index df40d06a60a2..e461eef30ab3 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -32,7 +32,6 @@ export namespace Agent {
.optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()),
- subagents: z.record(z.string(), z.boolean()),
options: z.record(z.string(), z.any()),
maxSteps: z.number().int().positive().optional(),
})
@@ -110,7 +109,6 @@ export namespace Agent {
todowrite: false,
...defaultTools,
},
- subagents: {},
options: {},
permission: agentPermission,
mode: "subagent",
@@ -125,7 +123,6 @@ export namespace Agent {
write: false,
...defaultTools,
},
- subagents: {},
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions. (Tools: All tools)`,
prompt: [
`You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`,
@@ -155,7 +152,6 @@ export namespace Agent {
build: {
name: "build",
tools: { ...defaultTools },
- subagents: {},
options: {},
permission: agentPermission,
mode: "primary",
@@ -168,7 +164,6 @@ export namespace Agent {
tools: {
...defaultTools,
},
- subagents: {},
mode: "primary",
builtIn: true,
},
@@ -186,7 +181,6 @@ export namespace Agent {
permission: agentPermission,
options: {},
tools: {},
- subagents: {},
builtIn: false,
}
const {
@@ -194,7 +188,7 @@ export namespace Agent {
model,
prompt,
tools,
- subagents,
+ subagents: _subagents,
description,
temperature,
top_p,
@@ -219,11 +213,6 @@ export namespace Agent {
...defaultTools,
...item.tools,
}
- if (subagents)
- item.subagents = {
- ...item.subagents,
- ...subagents,
- }
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index 88565577d799..a328d4d0b3bf 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -11,7 +11,6 @@ import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
-import { Wildcard } from "@/util/wildcard"
import type { PromptInfo } from "./history"
export type AutocompleteRef = {
@@ -185,11 +184,8 @@ export function Autocomplete(props: {
)
const agents = createMemo(() => {
- const current = local.agent.current() as { subagents?: Record }
- const subagents = current.subagents ?? {}
return sync.data.agent
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
- .filter((agent) => Wildcard.all(agent.name, subagents) !== false)
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 66dede77eced..1de99990d528 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -25,6 +25,7 @@ import {
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
+import { SearchInput, type SearchInputRef } from "@tui/component/prompt/search"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
@@ -40,7 +41,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
-import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid"
+import { useKeyboard, useRenderer, useTerminalDimensions, extend, type BoxProps, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "@tui/context/keybind"
@@ -64,6 +65,9 @@ import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
+import { GhosttyTerminalRenderable } from "ghostty-opentui/terminal-buffer"
+
+extend({ "ghostty-terminal": GhosttyTerminalRenderable })
addDefaultParsers(parsers.parsers)
@@ -84,8 +88,13 @@ const context = createContext<{
showTimestamps: () => boolean
usernameVisible: () => boolean
showDetails: () => boolean
+ showTokens: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType
+ searchQuery: () => string
+ currentMatchIndex: () => number
+ matches: () => SearchMatch[]
+ showBashOutput: (command: string, output: () => string) => void
}>()
function use() {
@@ -94,6 +103,18 @@ function use() {
return ctx
}
+type SearchMatch = {
+ messageID: string
+ partID: string
+ matchIndex: number
+ charOffset: number
+}
+
+type BashOutputView = {
+ command: string
+ output: () => string
+}
+
export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
@@ -121,7 +142,65 @@ export function Session() {
const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
+ const [showTokens, setShowTokens] = createSignal(kv.get("show_tokens", false))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
+ const [searchMode, setSearchMode] = createSignal(false)
+ const [searchQuery, setSearchQuery] = createSignal("")
+ const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0)
+ const [bashOutput, setBashOutput] = createSignal(undefined)
+
+ function showBashOutput(command: string, output: () => string) {
+ setBashOutput({ command, output })
+ }
+
+ const matches = createMemo(() => {
+ const query = searchQuery()
+ if (!query) return []
+
+ const results: SearchMatch[] = []
+ for (const message of messages()) {
+ const parts = sync.data.part[message.id] ?? []
+ for (const part of parts) {
+ if (part.type !== "text") continue
+ const text = part.text.toLowerCase()
+ const queryLower = query.toLowerCase()
+ let index = 0
+ let pos = text.indexOf(queryLower, index)
+ while (pos !== -1) {
+ results.push({
+ messageID: message.id,
+ partID: part.id,
+ matchIndex: results.length,
+ charOffset: pos,
+ })
+ index = pos + 1
+ pos = text.indexOf(queryLower, index)
+ }
+ }
+ }
+ return results
+ })
+
+ function handleNextMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ setCurrentMatchIndex((prev) => (prev + 1) % m.length)
+ scrollToMatch(currentMatchIndex())
+ }
+
+ function handlePrevMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ setCurrentMatchIndex((prev) => (prev - 1 + m.length) % m.length)
+ scrollToMatch(currentMatchIndex())
+ }
+
+ function scrollToMatch(index: number) {
+ const m = matches()[index]
+ if (!m) return
+ const child = scroll.getChildren().find((c) => c.id === m.messageID || c.id === "text-" + m.partID)
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
+ }
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -192,6 +271,19 @@ export function Session() {
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
+ if (bashOutput()) {
+ if (evt.name === "escape") {
+ setBashOutput(undefined)
+ return
+ }
+ return
+ }
+
+ if (evt.ctrl && evt.name === "f") {
+ setSearchMode(true)
+ return
+ }
+
const first = permissions()[0]
if (first) {
const response = iife(() => {
@@ -239,6 +331,16 @@ export function Session() {
const command = useCommandDialog()
command.register(() => [
+ {
+ title: "Search messages",
+ value: "session.search",
+ keybind: "session_search" as const,
+ category: "Session",
+ onSelect: (dialog) => {
+ setSearchMode(true)
+ dialog.clear()
+ },
+ },
...(sync.data.config.share !== "disabled"
? [
{
@@ -475,6 +577,19 @@ export function Session() {
dialog.clear()
},
},
+ {
+ title: showTokens() ? "Hide tokens" : "Show tokens",
+ value: "session.toggle.tokens",
+ category: "Session",
+ onSelect: (dialog) => {
+ setShowTokens((prev) => {
+ const next = !prev
+ kv.set("show_tokens", next)
+ return next
+ })
+ dialog.clear()
+ },
+ },
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
@@ -840,8 +955,13 @@ export function Session() {
showTimestamps,
usernameVisible,
showDetails,
+ showTokens,
diffWrapMode,
sync,
+ searchQuery,
+ currentMatchIndex,
+ matches,
+ showBashOutput,
}}
>
@@ -850,129 +970,163 @@ export function Session() {
- (scroll = r)}
- verticalScrollbarOptions={{
- paddingLeft: 1,
- visible: showScrollbar(),
- trackOptions: {
- backgroundColor: theme.backgroundElement,
- foregroundColor: theme.border,
- },
- }}
- stickyScroll={true}
- stickyStart="bottom"
- flexGrow={1}
- scrollAcceleration={scrollAcceleration()}
- >
-
- {(message, index) => (
-
-
- {(function () {
- const command = useCommandDialog()
- const [hover, setHover] = createSignal(false)
- const dialog = useDialog()
-
- const handleUnrevert = async () => {
- const confirmed = await DialogConfirm.show(
- dialog,
- "Confirm Redo",
- "Are you sure you want to restore the reverted messages?",
- )
- if (confirmed) {
- command.trigger("session.redo")
- }
- }
-
- return (
- setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={handleUnrevert}
- marginTop={1}
- flexShrink={0}
- border={["left"]}
- customBorderChars={SplitBorder.customBorderChars}
- borderColor={theme.backgroundPanel}
- >
-
- {revert()!.reverted.length} message reverted
-
- {keybind.print("messages_redo")} or /redo to
- restore
-
-
-
-
- {(file) => (
-
- {file.filename}
- 0}>
- +{file.additions}
-
- 0}>
- -{file.deletions}
-
-
- )}
-
+ (scroll = r)}
+ verticalScrollbarOptions={{
+ paddingLeft: 1,
+ visible: showScrollbar(),
+ trackOptions: {
+ backgroundColor: theme.backgroundElement,
+ foregroundColor: theme.border,
+ },
+ }}
+ stickyScroll={true}
+ stickyStart="bottom"
+ flexGrow={1}
+ scrollAcceleration={scrollAcceleration()}
+ >
+
+ {(message, index) => (
+
+
+ {(function () {
+ const command = useCommandDialog()
+ const [hover, setHover] = createSignal(false)
+ const dialog = useDialog()
+
+ const handleUnrevert = async () => {
+ const confirmed = await DialogConfirm.show(
+ dialog,
+ "Confirm Redo",
+ "Are you sure you want to restore the reverted messages?",
+ )
+ if (confirmed) {
+ command.trigger("session.redo")
+ }
+ }
+
+ return (
+ setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={handleUnrevert}
+ marginTop={1}
+ flexShrink={0}
+ border={["left"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.backgroundPanel}
+ >
+
+ {revert()!.reverted.length} message reverted
+
+ {keybind.print("messages_redo")} or /redo to
+ restore
+
+
+
+
+ {(file) => (
+
+ {file.filename}
+ 0}>
+ +{file.additions}
+
+ 0}>
+ -{file.deletions}
+
+
+ )}
+
+
+
-
-
-
- )
- })()}
-
- = revert()!.messageID}>
- <>>
-
-
- {
- if (renderer.getSelection()?.getSelectedText()) return
- dialog.replace(() => (
- prompt.set(promptInfo)}
- />
- ))
- }}
- message={message as UserMessage}
- parts={sync.data.part[message.id] ?? []}
- pending={pending()}
- />
-
-
-
-
-
- )}
-
-
+
+ )
+ })()}
+
+ = revert()!.messageID}>
+ <>>
+
+
+ {
+ if (renderer.getSelection()?.getSelectedText()) return
+ dialog.replace(() => (
+ prompt.set(promptInfo)}
+ />
+ ))
+ }}
+ message={message as UserMessage}
+ parts={sync.data.part[message.id] ?? []}
+ pending={pending()}
+ />
+
+
+
+
+
+ )}
+
+
+ }
+ >
+
+
+ $ {bashOutput()!.command}
+
+
+ ESC to close · PageUp/PageDown to scroll
+
+
- {
- prompt = r
- promptRef.set(r)
- }}
- disabled={permissions().length > 0}
- onSubmit={() => {
- toBottom()
- }}
- sessionID={route.sessionID}
- />
+ {
+ setSearchQuery(query)
+ setCurrentMatchIndex(0)
+ }}
+ onExit={() => {
+ setSearchMode(false)
+ setSearchQuery("")
+ prompt.focus()
+ }}
+ onNext={handleNextMatch}
+ onPrevious={handlePrevMatch}
+ matchInfo={{ current: currentMatchIndex(), total: matches().length }}
+ />
+ }
+ >
+ {
+ prompt = r
+ promptRef.set(r)
+ }}
+ disabled={permissions().length > 0}
+ onSubmit={() => {
+ toBottom()
+ }}
+ sessionID={route.sessionID}
+ />
+
@@ -1077,6 +1231,9 @@ function UserMessage(props: {
QUEUED
+
+ · ~{props.message.sentEstimate?.toLocaleString()} tokens
+
@@ -1097,6 +1254,7 @@ function UserMessage(props: {
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const local = useLocal()
const { theme } = useTheme()
+ const ctx = use()
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
@@ -1112,6 +1270,15 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
+ // Token calculations for live display
+ const inputTokens = createMemo(() => {
+ const sent = props.message.sentEstimate ?? 0
+ const context = props.message.contextEstimate ?? 0
+ return sent + context
+ })
+ const outputTokens = createMemo(() => props.message.tokens?.output ?? props.message.outputEstimate ?? 0)
+ const reasoningTokens = createMemo(() => props.message.tokens?.reasoning ?? props.message.reasoningEstimate ?? 0)
+
return (
<>
@@ -1154,6 +1321,12 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
· {Locale.duration(duration())}
+
+
+ IN ~{inputTokens().toLocaleString()}↓ OUT ~{outputTokens().toLocaleString()}↑
+ ~{reasoningTokens().toLocaleString()} think
+
+
@@ -1203,6 +1376,17 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
+
+ const highlightedText = createMemo(() => {
+ const query = ctx.searchQuery()
+ if (!query) return props.part.text.trim()
+
+ // Highlight search matches with ANSI background color
+ // Using yellow background (43) and black foreground (30) for visibility across themes
+ const regex = new RegExp(`(${escapeRegex(query)})`, "gi")
+ return props.part.text.trim().replace(regex, "\x1b[43m\x1b[30m$1\x1b[0m")
+ })
+
return (
@@ -1211,7 +1395,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
- content={props.part.text.trim()}
+ content={highlightedText()}
conceal={ctx.conceal()}
fg={theme.text}
/>
@@ -1383,8 +1567,12 @@ ToolRegistry.register({
name: "bash",
container: "block",
render(props) {
- const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
+ const output = createMemo(() => props.metadata.output?.trim() ?? "")
const { theme } = useTheme()
+ const lines = createMemo(() => output().split("\n"))
+ const isTruncated = createMemo(() => lines().length > 20)
+ const { showBashOutput } = use()
+
return (
<>
@@ -1395,8 +1583,18 @@ ToolRegistry.register({
- {output()}
+
+
+ {
+ showBashOutput(props.input.command ?? "bash", output)
+ }}
+ >
+ Click to see full output ({lines().length} lines)
+
+
>
)
@@ -1719,3 +1917,7 @@ function filetype(input?: string) {
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}
+
+function escapeRegex(str: string) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 1facc54eb576..b7e37549021d 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -241,14 +241,23 @@ export function Sidebar(props: { sessionID: string }) {
const isError = () => part.state.status === "error"
const input = part.state.input as Record
const description = (input?.description as string) ?? ""
- const sessionId = part.sessionID
+
+ // Get subagent session ID from metadata, not part.sessionID (which is the parent)
+ const metadata =
+ part.state.status === "completed"
+ ? part.state.metadata
+ : ((part.state as { metadata?: Record }).metadata ?? {})
+ const subagentSessionId = (metadata?.sessionId as string) ?? undefined
+
return (
{
- route.navigate({ type: "session", sessionID: sessionId })
+ if (subagentSessionId) {
+ route.navigate({ type: "session", sessionID: subagentSessionId })
+ }
}}
>
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 7a15516adc18..fd197d5991a4 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -388,7 +388,6 @@ export namespace Config {
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
- subagents: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index f1f7dd0964f4..2af91a02bae2 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -12,6 +12,7 @@ import { SessionRetry } from "./retry"
import { SessionStatus } from "./status"
import { Plugin } from "@/plugin"
import type { Provider } from "@/provider/provider"
+import { Token } from "@/util/token"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -53,6 +54,8 @@ export namespace SessionProcessor {
try {
let currentText: MessageV2.TextPart | undefined
let reasoningMap: Record = {}
+ let reasoningTotal = 0
+ let textTotal = 0
const stream = streamText(streamInput)
for await (const value of stream.fullStream) {
@@ -85,6 +88,9 @@ export namespace SessionProcessor {
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
if (part.text) await Session.updatePart({ part, delta: value.text })
+ // Track reasoning tokens for live display
+ reasoningTotal += value.text.length
+ input.assistantMessage.reasoningEstimate = Token.toTokenEstimate(reasoningTotal)
}
break
@@ -311,6 +317,9 @@ export namespace SessionProcessor {
part: currentText,
delta: value.text,
})
+ // Track output tokens for live display
+ textTotal += value.text.length
+ input.assistantMessage.outputEstimate = Token.toTokenEstimate(textTotal)
}
break
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index ea8ee009cc93..94debbd4d0be 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -47,7 +47,7 @@ import { Config } from "../config/config"
import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
-import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
+import { TaskTool, TASK_DESCRIPTION } from "@/tool/task"
import { SessionStatus } from "./status"
import { Token } from "@/util/token"
@@ -857,22 +857,6 @@ export namespace SessionPrompt {
tools[key] = item
}
- // Regenerate task tool description with filtered subagents
- if (tools.task) {
- const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
- const filtered = filterSubagents(all, input.agent.subagents)
- const description = TASK_DESCRIPTION.replace(
- "{agents}",
- filtered
- .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
- .join("\n"),
- )
- tools.task = {
- ...tools.task,
- description,
- }
- }
-
return tools
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 6b0b9d41046b..25d38b64d5d0 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -227,6 +227,14 @@ export const BashTool = Tool.define("bash", async () => {
cwd,
env: {
...process.env,
+ FORCE_COLOR: "3",
+ CLICOLOR: "1",
+ CLICOLOR_FORCE: "1",
+ TERM: "xterm-256color",
+ TERM_PROGRAM: "bash-tool",
+ PY_COLORS: "1",
+ ANSICON: "1",
+ NO_COLOR: undefined,
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 59dff3aee6d0..c8a7be22e45f 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -9,15 +9,10 @@ import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
-import { Wildcard } from "@/util/wildcard"
import { Config } from "../config/config"
export { DESCRIPTION as TASK_DESCRIPTION }
-export function filterSubagents(agents: Agent.Info[], subagents: Record) {
- return agents.filter((a) => Wildcard.all(a.name, subagents) !== false)
-}
-
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const description = DESCRIPTION.replace(
@@ -37,9 +32,6 @@ export const TaskTool = Tool.define("task", async () => {
async execute(params, ctx) {
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
- const calling = await Agent.get(ctx.agent)
- if (calling && Wildcard.all(params.subagent_type, calling.subagents) === false)
- throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`)
const session = await iife(async () => {
if (params.session_id) {
const found = await Session.get(params.session_id).catch(() => {})
diff --git a/packages/opencode/test/subagents-filter.test.ts b/packages/opencode/test/subagents-filter.test.ts
deleted file mode 100644
index 5cb0b7e0a089..000000000000
--- a/packages/opencode/test/subagents-filter.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import type { Agent } from "../src/agent/agent"
-import { filterSubagents } from "../src/tool/task"
-import { Wildcard } from "../src/util/wildcard"
-
-describe("filterSubagents", () => {
- const mockAgents = [
- { name: "general", mode: "subagent" },
- { name: "code-reviewer", mode: "subagent" },
- { name: "orchestrator-fast", mode: "subagent" },
- { name: "orchestrator-slow", mode: "subagent" },
- ] as Agent.Info[]
-
- test("returns all agents when subagents config is empty", () => {
- const result = filterSubagents(mockAgents, {})
- expect(result).toHaveLength(4)
- expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
- })
-
- test("excludes agents with explicit false", () => {
- const result = filterSubagents(mockAgents, { "code-reviewer": false })
- expect(result).toHaveLength(3)
- expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
- })
-
- test("includes agents with explicit true", () => {
- const result = filterSubagents(mockAgents, {
- "code-reviewer": true,
- general: false,
- })
- expect(result).toHaveLength(3)
- expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
- })
-
- test("supports wildcard patterns to exclude", () => {
- const result = filterSubagents(mockAgents, { "orchestrator-*": false })
- expect(result).toHaveLength(2)
- expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
- })
-
- test("supports wildcard patterns to include with specific exclusion", () => {
- const result = filterSubagents(mockAgents, {
- "*": true,
- "orchestrator-fast": false,
- })
- expect(result).toHaveLength(3)
- expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
- })
-
- test("longer pattern takes precedence", () => {
- const result = filterSubagents(mockAgents, {
- "orchestrator-*": false,
- "orchestrator-fast": true,
- })
- expect(result).toHaveLength(3)
- expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
- })
-})
-
-describe("Wildcard.all for subagents", () => {
- test("returns undefined when no match", () => {
- expect(Wildcard.all("code-reviewer", {})).toBeUndefined()
- })
-
- test("returns false for explicit false", () => {
- expect(Wildcard.all("code-reviewer", { "code-reviewer": false })).toBe(false)
- })
-
- test("returns true for explicit true", () => {
- expect(Wildcard.all("code-reviewer", { "code-reviewer": true })).toBe(true)
- })
-
- test("matches wildcard patterns", () => {
- expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": false })).toBe(false)
- expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": false })).toBe(false)
- expect(Wildcard.all("general", { "orchestrator-*": false })).toBeUndefined()
- })
-
- test("longer pattern takes precedence over shorter", () => {
- expect(
- Wildcard.all("orchestrator-fast", {
- "orchestrator-*": false,
- "orchestrator-fast": true,
- }),
- ).toBe(true)
- expect(
- Wildcard.all("orchestrator-slow", {
- "orchestrator-*": false,
- "orchestrator-fast": true,
- }),
- ).toBe(false)
- })
-})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 6ddf7b2277ea..909490b5b2bc 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -983,9 +983,6 @@ export type AgentConfig = {
tools?: {
[key: string]: boolean
}
- subagents?: {
- [key: string]: boolean
- }
disable?: boolean
/**
* Description of when to use the agent
@@ -1017,9 +1014,6 @@ export type AgentConfig = {
| unknown
| string
| number
- | {
- [key: string]: boolean
- }
| {
[key: string]: boolean
}
@@ -1631,9 +1625,6 @@ export type Agent = {
tools: {
[key: string]: boolean
}
- subagents: {
- [key: string]: boolean
- }
options: {
[key: string]: unknown
}
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index a674760be951..7df8088fd4de 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -7229,15 +7229,6 @@
"type": "boolean"
}
},
- "subagents": {
- "type": "object",
- "propertyNames": {
- "type": "string"
- },
- "additionalProperties": {
- "type": "boolean"
- }
- },
"disable": {
"type": "boolean"
},
@@ -8791,15 +8782,6 @@
"type": "boolean"
}
},
- "subagents": {
- "type": "object",
- "propertyNames": {
- "type": "string"
- },
- "additionalProperties": {
- "type": "boolean"
- }
- },
"options": {
"type": "object",
"propertyNames": {
@@ -8813,7 +8795,7 @@
"maximum": 9007199254740991
}
},
- "required": ["name", "mode", "builtIn", "permission", "tools", "subagents", "options"]
+ "required": ["name", "mode", "builtIn", "permission", "tools", "options"]
},
"MCPStatusConnected": {
"type": "object",
diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx
index 441683ddd3f3..11df4702fdaf 100644
--- a/packages/web/src/content/docs/agents.mdx
+++ b/packages/web/src/content/docs/agents.mdx
@@ -381,76 +381,6 @@ You can also use wildcards to control multiple tools at once. For example, to di
---
-### Subagents
-
-Control which subagents this agent can invoke via the Task tool with the `subagents` config.
-
-By default, all subagents are available. Set a subagent to `false` to hide it from this agent.
-
-```json title="opencode.json"
-{
- "$schema": "https://opencode.ai/config.json",
- "agent": {
- "build": {
- "subagents": {
- "code-reviewer": false
- }
- }
- }
-}
-```
-
-You can also use wildcards to control multiple subagents at once:
-
-```json title="opencode.json"
-{
- "$schema": "https://opencode.ai/config.json",
- "agent": {
- "build": {
- "subagents": {
- "orchestrator-*": false
- }
- }
- }
-}
-```
-
-Longer patterns take precedence over shorter ones. This allows you to exclude a group while keeping specific exceptions:
-
-```json title="opencode.json"
-{
- "$schema": "https://opencode.ai/config.json",
- "agent": {
- "build": {
- "subagents": {
- "orchestrator-*": false,
- "orchestrator-fast": true
- }
- }
- }
-}
-```
-
-You can also configure subagents in Markdown agents:
-
-```markdown title="~/.config/opencode/agent/focused.md"
----
-description: Agent with limited subagent access
-mode: primary
-subagents:
- general: true
- orchestrator-*: false
----
-
-You are a focused agent with limited subagent access.
-```
-
-:::note
-Filtered subagents will not appear in the Task tool description and cannot be invoked.
-:::
-
----
-
### Permissions
You can configure permissions to manage what actions an agent can take. Currently, the permissions for the `edit`, `bash`, and `webfetch` tools can be configured to: