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} + /> +