diff --git a/docs/AGENT_CLIENT_PROTOCOL.md b/docs/AGENT_CLIENT_PROTOCOL.md index da17b3663..0aa16238b 100644 --- a/docs/AGENT_CLIENT_PROTOCOL.md +++ b/docs/AGENT_CLIENT_PROTOCOL.md @@ -142,7 +142,7 @@ The ACP protocol implementation is identical to upstream OpenCode — no Franken However, Frankencode's additional tools are **transparently available** to ACP clients. When an ACP client sends a prompt, the agent can use all Frankencode tools including `context_edit`, `thread_park`, `classifier_threads`, `distill_threads`, `verify`, `refine`, and `objective_set`. These appear as standard tool calls in the ACP event stream — no client-side changes needed. -See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete list of Frankencode additions. +See [FRANKENCODE.md](FRANKENCODE.md) for the complete list of Frankencode additions. --- @@ -152,4 +152,4 @@ See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete li - [API_PROVIDERS.md](API_PROVIDERS.md) — provider/model selection (used in ACP NewSessionRequest) - [context-editing.md](context-editing.md) — context editing tools available through ACP - [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect architecture (ACP sessions boot via InstanceLifecycle) -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode differences +- [FRANKENCODE.md](FRANKENCODE.md) — all Frankencode vs OpenCode differences diff --git a/docs/API_PROVIDERS.md b/docs/API_PROVIDERS.md index ef6516e0f..cbaa654d0 100644 --- a/docs/API_PROVIDERS.md +++ b/docs/API_PROVIDERS.md @@ -214,7 +214,7 @@ Frankencode adds features at the **session/tool layer** that work with all provi - **Refine tool** (`src/tool/refine.ts`) — evaluator-optimizer loop, spawns child sessions on the same provider - **Evaluator/optimizer agents** — use the session's model for code review scoring -See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete list. +See [FRANKENCODE.md](FRANKENCODE.md) for the complete list. --- @@ -223,4 +223,4 @@ See [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) for the complete li - [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect architecture (ProviderAuthService, ConfigService are Effect services) - [agents.md](agents.md) — agents that use providers (evaluator, optimizer inherit session model) - [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — model selection via ACP NewSessionRequest -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode differences +- [FRANKENCODE.md](FRANKENCODE.md) — all Frankencode vs OpenCode differences diff --git a/docs/EFFECTIFICATION.md b/docs/EFFECTIFICATION.md index dc977adfc..2185b6a05 100644 --- a/docs/EFFECTIFICATION.md +++ b/docs/EFFECTIFICATION.md @@ -285,7 +285,7 @@ Frankencode completed the Effect-ification ahead of upstream in several areas: ## See Also -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — complete list of Frankencode vs OpenCode changes +- [FRANKENCODE.md](FRANKENCODE.md) — complete list of Frankencode vs OpenCode changes - [API_PROVIDERS.md](API_PROVIDERS.md) — provider architecture (ProviderAuthService is an Effect service) - [context-editing.md](context-editing.md) — context editing tools (use Effect services for state management) - [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — ACP protocol (sessions boot via InstanceLifecycle) diff --git a/docs/FRANKENCODE_DIFFERENCES.md b/docs/FRANKENCODE.md similarity index 52% rename from docs/FRANKENCODE_DIFFERENCES.md rename to docs/FRANKENCODE.md index 586afe6ca..a73513645 100644 --- a/docs/FRANKENCODE_DIFFERENCES.md +++ b/docs/FRANKENCODE.md @@ -1,6 +1,55 @@ -# Frankencode vs OpenCode: All Differences +# Frankencode -This document lists every change Frankencode makes relative to upstream [OpenCode](https://github.com/anomalyco/opencode) (`dev` branch). +Frankencode is a fork of [OpenCode](https://github.com/anomalyco/opencode) that adds subagent tabs, fork agents, context editing, and a verification/refinement loop. + +--- + +## Tab Bar and Subagent Tabs + +OpenCode shows one session at a time. Frankencode adds a **tab bar** at the top of the session view: + +``` +Main │ + │ S1 │ S2 │ F1 +``` + +- **Main** — the root session (always present) +- **+** — creates a fork agent (copies Main's conversation history) +- **S1, S2, ...** — subagents spawned by the LLM's task tool +- **F1, F2, ...** — fork agents created by the user via `+` + +**Navigation:** + +| Key | Action | +|-----|--------| +| Tab | Toggle focus between tab bar and chat prompt | +| ←/→ | Navigate tabs (immediately shows selected session) | +| ↓ / Escape | Return focus to chat prompt | +| Enter/Space | Activate selected tab (spawn fork if on `+`) | +| x | Kill selected agent | +| Shift+Tab | Cycle agent type (Build/Plan/etc.) | +| Ctrl+C | First: abort all + show hint. Second: exit app | +| Click | Select any tab and focus bar | + +Implementation: [`tab.ts`](../packages/opencode/src/cli/cmd/tui/routes/session/tab.ts) (hook + logic), [`tabbar.tsx`](../packages/opencode/src/cli/cmd/tui/routes/session/tabbar.tsx) (renderer) + +--- + +## Fork Agents + +Fork agents are user-created branches of the main conversation. Pressing `+` in the tab bar forks Main's full conversation history into a new child session. + +| Feature | Fork Agent (F) | Subagent (S) | +|---------|---------------|--------------| +| Created by | User via `+` | LLM via task tool | +| Conversation history | Copied from Main | Starts empty | +| Can spawn subagents | No (denied by permission) | Yes | +| Tab color | Yellow | Agent color | +| Lifetime | Until user kills with `x` | Auto-terminates when done | +| Promptable | Yes (user types in chat) | LLM-driven | + +Fork agents receive a system prompt explaining they cannot spawn subagents and should suggest delegating to Main if needed. + +Implementation: `Session.fork()` in [`session/index.ts`](../packages/opencode/src/session/index.ts), fork system prompt in [`prompt.ts`](../packages/opencode/src/session/prompt.ts) --- @@ -13,6 +62,7 @@ This document lists every change Frankencode makes relative to upstream [OpenCod | Context Editing | `src/context-edit/index.ts` | 6 edit operations (hide, unhide, replace, externalize, annotate, mark) | | Side Threads | `src/session/side-thread.ts`, `src/session/side-thread.sql.ts` | Project-level deferred findings that survive across sessions | | Objective Tracker | `src/session/objective.ts` | Extracts and tracks session objective from first user message | +| Tab Bar | `src/cli/cmd/tui/routes/session/tab.ts`, `tabbar.tsx` | `useTab` hook + pure renderer for subagent tabs | See [context-editing.md](context-editing.md) and [schema.md](schema.md) for details. @@ -39,13 +89,13 @@ See [context-editing.md](context-editing.md) for usage details. ## New Agents (5) -| Agent | Prompt File | Purpose | Default | -|-------|-------------|---------|---------| -| classifier | `src/agent/prompt/classifier.txt` | Label messages as main/side/mixed with topics | Enabled (hidden) | -| focus | `src/agent/prompt/focus.txt` | Context cleanup based on classification | Disabled | -| focus-rewrite-history | `src/agent/prompt/rewrite-history.txt` | Full conversation rewrite with confirmation | Disabled | -| evaluator | `src/agent/prompt/evaluator.txt` | Score code changes 1-10 with feedback | Enabled (hidden) | -| optimizer | `src/agent/prompt/optimizer.txt` | Improve code based on evaluator feedback | Enabled (hidden) | +| Agent | Purpose | Default | +|-------|---------|---------| +| classifier | Label messages as main/side/mixed with topics | Enabled (hidden) | +| focus | Context cleanup based on classification | Disabled | +| focus-rewrite-history | Full conversation rewrite with confirmation | Disabled | +| evaluator | Score code changes 1-10 with feedback | Enabled (hidden) | +| optimizer | Improve code based on evaluator feedback | Enabled (hidden) | See [agents.md](agents.md) for configuration and model recommendations. @@ -77,9 +127,7 @@ See [agents.md](agents.md) for configuration and model recommendations. | `edit_graph_head` | Per-session DAG head tracking + branches | | `side_thread` | Project-level side threads | -Plus 2 new fields on `PartBase` (all message parts): `edit` (EditMeta) and `lifecycle` (LifecycleMeta). - -See [schema.md](schema.md) for column details. +Plus 2 new fields on `PartBase` (all message parts): `edit` (EditMeta) and `lifecycle` (LifecycleMeta). See [schema.md](schema.md) for column details. --- @@ -91,27 +139,30 @@ Added to the message processing pipeline in `src/session/prompt.ts`: 2. **`filterEphemeral()`** — drops ephemeral command messages entirely 3. **Deterministic sweeper** — auto-hides/externalizes parts based on lifecycle markers 4. **Focus status injection** — adds objective + parked threads to system prompt when context_edit is available +5. **Fork agent prompt** — tells fork agents they cannot spawn subagents --- -## Effect-ification Differences +## Keybinding Changes -Frankencode completed several Effect-ification stages ahead of upstream: +| Keybind | OpenCode | Frankencode | +|---------|----------|-------------| +| Tab | Cycle agents | Toggle tab bar focus | +| Shift+Tab | (none) | Cycle agents (Build/Plan/etc.) | +| ←/→ (tab bar focused) | (N/A) | Navigate tabs | +| Ctrl+C | Exit immediately (if input empty) | Double-press to exit (first press aborts + shows hint) | -| Difference | Frankencode | Upstream | -|------------|-------------|----------| -| `src/project/instance.ts` | Deleted, split into InstanceALS + InstanceLifecycle + InstanceContext | Still has `instance-state.ts` using ScopedCache | -| ALS fallback patterns | 0 remaining (all 59 eliminated) | ~36 deferred | -| Test compatibility | `test/fixture/instance-shim.ts` for 58 test files | N/A | -| TUI tests | 81 component tests + tmux integration harness | Fewer tests | +--- + +## Promptable Agent Mode Switching -See [EFFECTIFICATION.md](EFFECTIFICATION.md) for architecture details. +Build and Plan agents switch via `plan_enter`/`plan_exit` tools — see [agents.md](agents.md#promptable-mode-switching) for details. --- -## Type Safety Improvements +## Type Safety -Frankencode's type safety audit eliminated ~236 `any` types: +~236 `any` types eliminated. Strong Zod schemas for all message types. 14 documented exceptions at SDK boundaries. | Area | Changes | |------|---------| @@ -122,17 +173,29 @@ Frankencode's type safety audit eliminated ~236 `any` types: --- -## Bug Fixes (51 total) +## Effect-TS Architecture + +22 Effect services, dual-layer context model (InstanceALS + InstanceContext). See [EFFECTIFICATION.md](EFFECTIFICATION.md) for details. + +| Difference | Frankencode | Upstream | +|------------|-------------|----------| +| `src/project/instance.ts` | Deleted, split into InstanceALS + InstanceLifecycle + InstanceContext | Still has `instance-state.ts` using ScopedCache | +| ALS fallback patterns | 0 remaining (all 59 eliminated) | ~36 deferred | +| Test compatibility | `test/fixture/instance-shim.ts` for 58 test files | N/A | + +--- + +## Bug Fixes (64 total) + +See `BUGS.md` at repo root for the complete bug tracker. | Range | Category | |-------|----------| | B1-B9 | Upstream backports (Phase 1) | | B10-B16 | Upstream backports (Phase 2) | | B17-B22 | Upstream app fixes (Phase 3) | -| B23-B46 | Code review fixes (CAS, circuit breaker, evaluator, scripts, lock starvation, etc.) | -| B47-B52 | Final pass (objective cache, session cleanup, mark transaction, queue, bus errors) | - -See `BUGS.md` at repo root for the complete bug tracker. +| B23-B52 | Code review fixes (CAS, circuit breaker, evaluator, session cleanup, etc.) | +| B53-B64 | QA rounds 1-6 (plugin hooks, markdown injection, MCP type mismatch, ripgrep, file count, processor timing) | --- @@ -142,7 +205,20 @@ These areas are identical to upstream OpenCode: - **API providers** — all 21+ providers, models.dev integration, transform pipeline (see [API_PROVIDERS.md](API_PROVIDERS.md)) - **ACP support** — full ACP v1 protocol, same capabilities (see [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md)) -- **TUI** — same terminal UI (OpenTUI + SolidJS) - **Session/message format** — same MessageV2 schema (extended with EditMeta/LifecycleMeta) - **Plugin system** — same plugin hooks (plus `context.edit.before`/`context.edit.after`) - **Permission system** — same PermissionNext framework + +--- + +## Documentation Index + +| Document | What it covers | +|----------|---------------| +| [agents.md](agents.md) | Agent config, mode switching, fork agents, model recommendations | +| [context-editing.md](context-editing.md) | Edit operations, lifecycle markers, sweeper, slash commands | +| [schema.md](schema.md) | Database schema (4 new tables, part extensions) | +| [EFFECTIFICATION.md](EFFECTIFICATION.md) | Effect-TS services, layers, dual context | +| [API_PROVIDERS.md](API_PROVIDERS.md) | 21+ LLM providers | +| [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) | ACP v1 protocol for IDEs | +| [SECURITY_AUDIT.md](SECURITY_AUDIT.md) | CVEs, security issues | diff --git a/docs/README.md b/docs/README.md index 9b18d522d..bc0bfd2f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,14 +1,14 @@ # Frankencode Documentation -> **Frankencode** is a fork of [OpenCode](https://github.com/anomalyco/opencode) that adds context editing, content-addressable storage, an edit graph, focus agents, side threads, and a verification/refinement loop. +> **Frankencode** is a fork of [OpenCode](https://github.com/anomalyco/opencode) that adds context editing, subagent tabs, fork agents, content-addressable storage, an edit graph, focus agents, side threads, and a verification/refinement loop. ## Documentation Map | Document | Description | |----------|-------------| -| [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) | All differences between Frankencode and upstream OpenCode | +| [FRANKENCODE.md](FRANKENCODE.md) | **Start here** — all Frankencode features, diff vs upstream, tab bar, fork agents, context editing | | [context-editing.md](context-editing.md) | Context editing tools, lifecycle markers, and deterministic sweeper | -| [agents.md](agents.md) | Frankencode-specific agents (classifier, focus, evaluator, optimizer) | +| [agents.md](agents.md) | Agents, mode switching, fork agents, model recommendations | | [schema.md](schema.md) | Database schema changes (4 new tables, PartBase extensions) | | [EFFECTIFICATION.md](EFFECTIFICATION.md) | Effect-TS architecture, 22 services, LayerMap, dual-layer context | | [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) | ACP v1 protocol support for IDE integration | @@ -23,6 +23,8 @@ | ACP (JSON-RPC/stdio) or TUI or HTTP API | + TUI Tab Bar: Main │ + │ S1 │ F1 + | InstanceLifecycle.boot(directory) | InstanceALS + InstanceContext (dual context) diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md index 301f3d8f8..d8b2646bd 100644 --- a/docs/SECURITY_AUDIT.md +++ b/docs/SECURITY_AUDIT.md @@ -119,5 +119,5 @@ From `SECURITY.md`: ## See Also - [UPSTREAM_STATUS.md](../UPSTREAM_STATUS.md) — full upstream commit catalogue -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — Frankencode vs OpenCode differences +- [FRANKENCODE.md](FRANKENCODE.md) — Frankencode vs OpenCode differences - [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect architecture (relevant to service isolation) diff --git a/docs/agents.md b/docs/agents.md index 821f55266..6d3bd3acf 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -154,25 +154,31 @@ All Frankencode agents inherit the session's model by default. Override per-agen ## Agent Visibility -| Agent | Tab-selectable | Mode | Default | -| --------------------- | :------------: | ---------------- | -------- | -| build | Yes | primary | enabled | -| plan | Yes | primary | enabled | -| general | Via `@general` | subagent | enabled | -| explore | Via `@explore` | subagent | enabled | -| classifier | No | subagent | enabled | -| focus | No | primary (hidden) | disabled | -| focus-rewrite-history | No | primary (hidden) | disabled | -| compaction | No | primary (hidden) | enabled | -| title | No | primary (hidden) | enabled | -| summary | No | primary (hidden) | enabled | +| Agent | Shift+Tab cycle | Tab bar | Mode | Default | +| --------------------- | :-------------: | :-----: | ---------------- | -------- | +| build | Yes | — | primary | enabled | +| plan | Yes | — | primary | enabled | +| general | No | S*n* | subagent | enabled | +| explore | No | S*n* | subagent | enabled | +| classifier | No | — | subagent | enabled | +| focus | No | — | primary (hidden) | disabled | +| focus-rewrite-history | No | — | primary (hidden) | disabled | +| compaction | No | — | primary (hidden) | enabled | +| title | No | — | primary (hidden) | enabled | +| summary | No | — | primary (hidden) | enabled | +| *(fork agent)* | No | F*n* | fork child | N/A | + +**Shift+Tab cycle** — cycles the agent type (Build/Plan/Docs) for the current session. +**Tab bar** — subagents spawned by the LLM appear as S1, S2, etc. Fork agents appear as F1, F2, etc. + +See [FRANKENCODE.md](FRANKENCODE.md#tab-bar-and-subagent-tabs) for full tab bar documentation. --- ## See Also +- [FRANKENCODE.md](FRANKENCODE.md) — tab bar, fork agents, and all Frankencode differences - [context-editing.md](context-editing.md) — tools used by focus/classifier agents - [API_PROVIDERS.md](API_PROVIDERS.md) — model selection for agents - [AGENT_CLIENT_PROTOCOL.md](AGENT_CLIENT_PROTOCOL.md) — agents exposed via ACP protocol - [EFFECTIFICATION.md](EFFECTIFICATION.md) — AgentService Effect layer -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode additions diff --git a/docs/context-editing.md b/docs/context-editing.md index 7c289f1df..47328c6b8 100644 --- a/docs/context-editing.md +++ b/docs/context-editing.md @@ -162,5 +162,5 @@ See `test/context-edit/integration.test.ts` for the proof tests. - [schema.md](schema.md) — database tables (cas_object, edit_graph_node/head, side_thread, PartBase extensions) - [agents.md](agents.md) — classifier, focus, and focus-rewrite-history agents -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode changes +- [FRANKENCODE.md](FRANKENCODE.md) — all Frankencode vs OpenCode changes - [EFFECTIFICATION.md](EFFECTIFICATION.md) — Effect services powering the context editing pipeline diff --git a/docs/schema.md b/docs/schema.md index b001169cb..a773ce827 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -134,5 +134,5 @@ All new tables are created in a single migration: `20260315120000_context_editin - [context-editing.md](context-editing.md) — tools that read/write these tables - [agents.md](agents.md) — agents that create side threads and edit graph nodes -- [FRANKENCODE_DIFFERENCES.md](FRANKENCODE_DIFFERENCES.md) — all Frankencode vs OpenCode changes +- [FRANKENCODE.md](FRANKENCODE.md) — all Frankencode vs OpenCode changes - [EFFECTIFICATION.md](EFFECTIFICATION.md) — database access via Drizzle ORM and Effect service layers diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index be031296e..a54505e3b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -58,6 +58,7 @@ function init() { const suspended = () => suspendCount() > 0 useKeyboard((evt) => { + if (evt.defaultPrevented) return if (suspended()) return if (dialog.stack.length > 0) return for (const option of entries()) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index f64dbe533..34602af43 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -41,7 +41,7 @@ const WorkspaceInfo = (props: { workspace: Accessor }) => { ) } -export function Header() { +export function Header(props: { tabBar?: boolean }) { const route = useRouteData("session") const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) @@ -100,7 +100,7 @@ export function Header() { backgroundColor={theme.backgroundPanel} > - + {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? ( 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 b315d4602..94ad56d02 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -53,6 +53,8 @@ import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" import { Header } from "./header" +import { TabBar } from "./tabbar" +import { useTab } from "./tab" import { contextCommands } from "./context-commands" import { parsePatch } from "diff" import { useDialog } from "../../ui/dialog" @@ -325,32 +327,42 @@ export function Session() { const local = useLocal() - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) { - navigate({ - type: "session", - sessionID: next.id, - }) - } - } - - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) + direction + // Tab bar — single hook owns all state and actions + const bar = useTab({ + sessions: children, + current: () => route.sessionID, + permissions, + questions, + status: () => sync.data.session_status ?? {}, + fork: (root) => + sdk.client.session.fork({ sessionID: root, parentID: root }).then((r) => r.data?.id), + abort: (id) => sdk.client.session.abort({ sessionID: id }).catch(() => {}), + remove: (id) => sdk.client.session.delete({ sessionID: id }), + navigate: (id) => navigate({ type: "session", sessionID: id }), + exit, + }) - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) { - navigate({ - type: "session", - sessionID: sessions[next].id, - }) + // Focus management: blur prompt when tab bar focused, refocus when unfocused + createEffect(() => { + if (bar.focused()) { + promptRef.current?.blur() + } else { + promptRef.current?.focus() } - } + }) + + // Tab bar keyboard handler — thin dispatcher to hook actions + useKeyboard((evt) => { + if (evt.name === "c" && evt.ctrl) { evt.preventDefault(); bar.ctrlc(); return } + if (evt.name === "tab" && !evt.shift && !evt.ctrl && !evt.meta) { evt.preventDefault(); bar.toggle(); return } + if (!bar.focused()) return + if (evt.name === "left" || evt.name === "h") { evt.preventDefault(); bar.move(-1); return } + if (evt.name === "right" || evt.name === "l") { evt.preventDefault(); bar.move(1); return } + if (evt.name === "return" || evt.name === "space") { evt.preventDefault(); bar.activate(); return } + if (evt.name === "down" || evt.name === "j") { evt.preventDefault(); bar.blur(); return } + if (evt.name === "x") { evt.preventDefault(); bar.kill(); return } + if (evt.name === "escape") { evt.preventDefault(); bar.blur(); return } + }) function childSessionHandler(func: (dialog: DialogContext) => void) { return (dialog: DialogContext) => { @@ -934,7 +946,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - moveFirstChild() + bar.cycle(1) dialog.clear() }, }, @@ -944,7 +956,7 @@ export function Session() { keybind: "session_parent", category: "Session", hidden: true, - enabled: !!session()?.parentID, + enabled: !!session()?.parentID && !bar.focused(), onSelect: childSessionHandler((dialog) => { const parentID = session()?.parentID if (parentID) { @@ -962,9 +974,9 @@ export function Session() { keybind: "session_child_cycle", category: "Session", hidden: true, - enabled: !!session()?.parentID, + enabled: bar.tabs().filter((t) => !t.spawn).length > 1 && !bar.focused(), onSelect: childSessionHandler((dialog) => { - moveChild(1) + bar.cycle(1) dialog.clear() }), }, @@ -974,9 +986,9 @@ export function Session() { keybind: "session_child_cycle_reverse", category: "Session", hidden: true, - enabled: !!session()?.parentID, + enabled: bar.tabs().filter((t) => !t.spawn).length > 1 && !bar.focused(), onSelect: childSessionHandler((dialog) => { - moveChild(-1) + bar.cycle(-1) dialog.clear() }), }, @@ -1053,8 +1065,18 @@ export function Session() { + route.sessionID} + focused={bar.focused} + selected={bar.selected} + page={bar.page} + pending={bar.pending} + onClick={(id) => bar.click(id)} + onPage={(delta) => bar.paginate(delta)} + /> -
+
(scroll = r)} @@ -1171,14 +1193,14 @@ export function Session() { - 0}> + 0 && !bar.focused()}> - 0}> + 0 && !bar.focused()}> { prompt = r promptRef.set(r) @@ -1194,6 +1216,27 @@ export function Session() { sessionID={route.sessionID} /> + + + tab switch to agents shift+tab cycle agents + + } + > + + x kill agent tab return to chat ← → navigate ↓ chat + + + } + > + Press Ctrl+C again to close the app + + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc..1b86291e7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -434,6 +434,12 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} escapeKey="reject" fullscreen + onReject={() => { + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + }} onSelect={(option) => { if (option === "always") { setStore("stage", "always") @@ -477,11 +483,17 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( useKeyboard((evt) => { if (dialog.stack.length > 0) return - if (evt.name === "escape" || keybind.match("app_exit", evt)) { + if (evt.name === "escape") { evt.preventDefault() props.onCancel() return } + // Ctrl+C auto-confirms rejection (empty feedback) + if (keybind.match("app_exit", evt)) { + evt.preventDefault() + props.onConfirm("") + return + } if (evt.name === "return") { evt.preventDefault() props.onConfirm(input.plainText) @@ -545,6 +557,7 @@ function Prompt>(props: { escapeKey?: keyof T fullscreen?: boolean onSelect: (option: keyof T) => void + onReject?: () => void }) { const { theme } = useTheme() const keybind = useKeybind() @@ -580,11 +593,16 @@ function Prompt>(props: { props.onSelect(store.selected) } - if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { + if (props.escapeKey && evt.name === "escape") { evt.preventDefault() props.onSelect(props.escapeKey) } + // Ctrl+C rejects permission directly (no reject-reason prompt) + if (props.escapeKey && keybind.match("app_exit", evt)) { + props.onReject?.() + } + if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { evt.preventDefault() evt.stopPropagation() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/tab.ts b/packages/opencode/src/cli/cmd/tui/routes/session/tab.ts new file mode 100644 index 000000000..c1588b8ec --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/tab.ts @@ -0,0 +1,188 @@ +import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import type { Session } from "@opencode-ai/sdk/v2" + +export const PER_PAGE = 6 + +export interface Tab { + id: string + label: string + main: boolean + spawn?: boolean + fork?: boolean + idx: number +} + +export function buildTabs(sessions: Session[]): Tab[] { + const root = sessions.find((s) => !s.parentID) + const result: Tab[] = [] + let idx = 0 + + if (root) { + result.push({ id: root.id, label: "Main", main: true, idx: idx++ }) + } + + result.push({ id: "+", label: "+", main: false, spawn: true, idx: idx++ }) + + const kids = sessions.filter((s) => !!s.parentID).sort((a, b) => (a.id < b.id ? -1 : 1)) + let fi = 0 + let si = 0 + for (const kid of kids) { + if (kid.title.includes("(fork #")) { + fi++ + result.push({ id: kid.id, label: `F${fi}`, main: false, fork: true, idx: idx++ }) + } else { + si++ + result.push({ id: kid.id, label: `S${si}`, main: false, idx: idx++ }) + } + } + + return result +} + +export function useTab(input: { + sessions: Accessor + current: Accessor + permissions: Accessor<{ id: string }[]> + questions: Accessor<{ id: string }[]> + status: Accessor> + fork: (root: string) => Promise + abort: (id: string) => void + remove: (id: string) => void + navigate: (id: string) => void + exit: () => void +}) { + const [focused, setFocused] = createSignal(false) + const [selected, setSelected] = createSignal(0) + const [page, setPage] = createSignal(0) + const [hint, setHint] = createSignal(false) + let timer: ReturnType | undefined + onCleanup(() => { + if (timer) clearTimeout(timer) + }) + + const tabs = createMemo(() => buildTabs(input.sessions())) + + const pending = createMemo(() => focused() && (input.permissions().length > 0 || input.questions().length > 0)) + + // Sync selection when route changes (e.g. from command or click) + createEffect(() => { + const idx = tabs().findIndex((t) => t.id === input.current()) + if (idx >= 0) { + setSelected(idx) + setPage(Math.floor(idx / PER_PAGE)) + } + }) + + function select(idx: number) { + const clamped = Math.max(0, Math.min(tabs().length - 1, idx)) + setSelected(clamped) + setPage(Math.floor(clamped / PER_PAGE)) + const tab = tabs()[clamped] + if (tab && !tab.spawn) input.navigate(tab.id) + } + + function move(delta: number) { + select(selected() + delta) + } + + // For unfocused keybind navigation: skip spawn, wrap around + function cycle(delta: number) { + const real = tabs().filter((t) => !t.spawn) + if (real.length <= 1) return + const cur = real.findIndex((t) => t.id === input.current()) + let next = cur + delta + if (next >= real.length) next = 0 + if (next < 0) next = real.length - 1 + const tab = real[next] + if (tab) input.navigate(tab.id) + } + + function spawn() { + const sessions = input.sessions() + const root = sessions.find((s) => !s.parentID) + if (!root) return + input.fork(root.id).then((id) => { + if (id) input.navigate(id) + }) + } + + function kill() { + const tab = tabs()[selected()] + if (!tab || tab.spawn) return + const status = input.status()[tab.id] + if (status?.type !== "idle") input.abort(tab.id) + input.remove(tab.id) + if (tab.main && input.sessions().length <= 1) { + input.exit() + return + } + if (tab.id === input.current()) { + const remaining = input.sessions().find((s) => s.id !== tab.id) + if (remaining) input.navigate(remaining.id) + } + setSelected((s) => Math.max(0, Math.min(s, tabs().length - 2))) + } + + function toggle() { + setFocused((f) => !f) + } + + function blur() { + setFocused(false) + } + + function ctrlc() { + if (hint()) { + setHint(false) + input.exit() + return + } + for (const s of input.sessions()) { + const status = input.status()[s.id] + if (status?.type !== "idle") input.abort(s.id) + } + setHint(true) + if (timer) clearTimeout(timer) + timer = setTimeout(() => setHint(false), 3000) + } + + function activate() { + const tab = tabs()[selected()] + if (!tab) return + if (tab.spawn) return spawn() + input.navigate(tab.id) + } + + function paginate(delta: number) { + setPage((p) => p + delta) + } + + function click(id: string) { + const tab = tabs().find((t) => t.id === id) + if (!tab) return + if (tab.spawn) return spawn() + setFocused(true) + select(tab.idx) + } + + return { + focused, + setFocused, + selected, + page, + tabs, + pending, + hint, + select, + move, + cycle, + spawn, + kill, + toggle, + blur, + ctrlc, + activate, + paginate, + click, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/tabbar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/tabbar.tsx new file mode 100644 index 000000000..bb2d53c0f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/tabbar.tsx @@ -0,0 +1,160 @@ +import { type Accessor, createMemo, createSignal, For, Show, onCleanup } from "solid-js" +import { selectedForeground, useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useSync } from "@tui/context/sync" +import { SplitBorder } from "@tui/component/border" +import type { RGBA } from "@opentui/core" +import { type Tab, PER_PAGE } from "./tab" + +function Chip(props: { + tab: Tab + focused: boolean + selected: boolean + active: boolean + color?: RGBA + blink?: boolean + onClick: () => void +}) { + const { theme } = useTheme() + const [hover, setHover] = createSignal(false) + const [blinkOn, setBlinkOn] = createSignal(true) + + const interval = setInterval(() => { + if (props.blink) setBlinkOn((v) => !v) + else setBlinkOn(true) + }, 600) + onCleanup(() => clearInterval(interval)) + + const bg = () => { + if (props.blink && !blinkOn()) return theme.warning + if (props.selected && props.focused) return props.color ?? theme.accent + if (props.active && !props.tab.spawn) return theme.backgroundElement + if (hover()) return theme.backgroundElement + return theme.backgroundPanel + } + + const fg = () => { + if (props.blink && !blinkOn()) return selectedForeground(theme) + if (props.selected && props.focused) return selectedForeground(theme, props.color) + if (props.tab.spawn) return theme.textMuted + if (props.active && props.color) return props.color + if (props.tab.main && props.color) return props.color + if (props.tab.main) return theme.text + return theme.textMuted + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => props.onClick()} + backgroundColor={bg()} + paddingLeft={1} + paddingRight={1} + > + + {props.tab.label} + + + ) +} + +function Nav(props: { label: string; onClick: () => void }) { + const { theme } = useTheme() + const [hover, setHover] = createSignal(false) + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => props.onClick()} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + paddingLeft={1} + paddingRight={1} + > + + {props.label} + + + ) +} + +export function TabBar(props: { + tabs: Accessor + current: Accessor + focused: Accessor + selected: Accessor + page: Accessor + pending?: Accessor + onClick: (id: string) => void + onPage: (delta: number) => void +}) { + const { theme } = useTheme() + const local = useLocal() + const sync = useSync() + + const totalPages = createMemo(() => Math.max(1, Math.ceil(props.tabs().length / PER_PAGE))) + const safePage = createMemo(() => Math.min(props.page(), totalPages() - 1)) + + const visible = createMemo(() => { + const all = props.tabs() + const start = safePage() * PER_PAGE + return all.slice(start, start + PER_PAGE) + }) + + const hasPrev = createMemo(() => safePage() > 0) + const hasNext = createMemo(() => safePage() < totalPages() - 1) + + const border = () => (props.focused() ? theme.accent : theme.border) + + function color(tab: Tab): RGBA { + if (tab.fork) return theme.warning + const msgs = sync.data.message[tab.id] ?? [] + const last = msgs.findLast((m) => m.role === "assistant") + const agent = last?.agent ?? local.agent.current()?.name ?? "build" + return local.agent.color(agent) + } + + return ( + + + +