diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..603941e75b6c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCode is an open-source AI-powered coding agent, similar to Claude Code but provider-agnostic. It supports multiple LLM providers (Anthropic, OpenAI, Google, Azure, local models) and features a TUI built with SolidJS, LSP support, and client/server architecture. + +## Development Commands + +```bash +# Install and run development server +bun install +bun dev # Run in packages/opencode directory +bun dev # Run against a specific directory +bun dev . # Run against repo root + +# Type checking +bun run typecheck # Single package +bun turbo typecheck # All packages + +# Testing (per-package, not from root) +cd packages/opencode && bun test + +# Build standalone executable +./packages/opencode/script/build.ts --single +# Output: ./packages/opencode/dist/opencode-/bin/opencode + +# Regenerate SDK after API changes +./script/generate.ts +# Or for JS SDK specifically: +./packages/sdk/js/script/build.ts + +# Web app development +bun run --cwd packages/app dev # http://localhost:5173 + +# Desktop app (requires Tauri/Rust) +bun run --cwd packages/desktop tauri dev # Native + web server +bun run --cwd packages/desktop dev # Web only (port 1420) +bun run --cwd packages/desktop tauri build # Production build +``` + +## Architecture + +**Monorepo Structure** (Bun workspaces + Turbo): + +| Package | Purpose | +|---------|---------| +| `packages/opencode` | Core CLI, server, business logic | +| `packages/app` | Shared web UI components (SolidJS + Vite) | +| `packages/desktop` | Native desktop app (Tauri wrapper) | +| `packages/ui` | Shared component library (Kobalte + Tailwind) | +| `packages/console/app` | Console dashboard (Solid Start) | +| `packages/console/core` | Backend services (Hono + DrizzleORM) | +| `packages/sdk/js` | JavaScript SDK | +| `packages/plugin` | Plugin system API | + +**Key Directories in `packages/opencode/src`**: +- `cli/cmd/tui/` - Terminal UI (SolidJS + opentui) +- `agent/` - Agent logic and state +- `provider/` - AI provider implementations +- `server/` - Server mode +- `mcp/` - Model Context Protocol integration +- `lsp/` - Language Server Protocol support + +**Default branch**: `dev` + +## Code Style + +- Keep logic in single functions unless reusable +- Avoid destructuring: use `obj.a` instead of `const { a } = obj` +- Avoid `try/catch` - prefer `.catch()` +- Avoid `else` statements +- Avoid `any` type +- Avoid `let` - use immutable patterns +- Prefer single-word variable names when descriptive +- Use Bun APIs (e.g., `Bun.file()`) when applicable + +## Built-in Agents + +- **build** - Default agent with full access for development +- **plan** - Read-only agent for analysis (denies edits, asks before bash) +- **general** - Subagent for complex tasks, invoked with `@general` + +Switch agents with `Tab` key in TUI. + +## Debugging + +```bash +# Debug with inspector +bun run --inspect=ws://localhost:6499/ dev + +# Debug server separately +bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096 +opencode attach http://localhost:4096 + +# Debug TUI +bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts + +# Use spawn for breakpoints in server code +bun dev spawn +``` + +Use `--inspect-wait` or `--inspect-brk` for different breakpoint behaviors. + +## PR Guidelines + +- All PRs must reference an existing issue (`Fixes #123`) +- UI/core feature changes require design review with core team +- PR titles follow conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Include screenshots/videos for UI changes +- Explain verification steps for logic changes diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c53ca04e2383..ea75795ad558 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,12 +1,10 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" -import { generateObject, streamObject, type ModelMessage } from "ai" +import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { Truncate } from "../tool/truncation" -import { Auth } from "../auth" -import { ProviderTransform } from "../provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -42,6 +40,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + task_budget: z.number().int().nonnegative().optional(), }) .meta({ ref: "Agent", @@ -61,13 +60,11 @@ export namespace Agent { ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), }, question: "deny", - plan_enter: "deny", - plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", - "*.env": "ask", - "*.env.*": "ask", + "*.env": "deny", + "*.env.*": "deny", "*.env.example": "allow", }, }) @@ -82,7 +79,6 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_enter: "allow", }), user, ), @@ -97,14 +93,9 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", - plan_exit: "allow", - external_directory: { - [path.join(Global.Path.data, "plans", "*")]: "allow", - }, edit: { "*": "deny", - [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + ".opencode/plan/*.md": "allow", }, }), user, @@ -227,6 +218,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.task_budget = value.task_budget ?? item.task_budget item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } @@ -264,20 +256,7 @@ export namespace Agent { } export async function defaultAgent() { - const cfg = await Config.get() - const agents = await state() - - if (cfg.default_agent) { - const agent = agents[cfg.default_agent] - if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`) - if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`) - if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`) - return agent.name - } - - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) - if (!primaryVisible) throw new Error("no primary visible agent found") - return primaryVisible.name + return state().then((x) => Object.keys(x)[0]) } export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { @@ -289,8 +268,7 @@ export namespace Agent { const system = [PROMPT_GENERATE] await Plugin.trigger("experimental.chat.system.transform", { model }, { system }) const existing = await list() - - const params = { + const result = await generateObject({ experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, metadata: { @@ -316,24 +294,7 @@ export namespace Agent { whenToUse: z.string(), systemPrompt: z.string(), }), - } satisfies Parameters[0] - - if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") { - const result = streamObject({ - ...params, - providerOptions: ProviderTransform.providerOptions(model, { - instructions: SystemPrompt.instructions(), - store: false, - }), - onError: () => {}, - }) - for await (const part of result.fullStream) { - if (part.type === "error") throw part.error - } - return result.object - } - - const result = await generateObject(params) + }) return result.object } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb38..5418a121e1ce 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -26,7 +26,7 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) + const result = await sdk.client.session.list({ search: query, limit: 30, roots: true }) return result.data ?? [] }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd1..91c928b812c0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -28,6 +28,11 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" +import { parseLinkHeader } from "@/util/link-header" +import { evictFromEnd, evictFromStart, paginationError, windowNewest, windowOldest } from "@tui/util/pagination" + +/** Maximum messages kept in memory per session */ +const MAX_LOADED_MESSAGES = 500 export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -48,6 +53,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } config: Config session: Session[] + message_page: { + [sessionID: string]: { + hasOlder: boolean + hasNewer: boolean + loading: boolean + loadingDirection?: "older" | "newer" + oldest?: string + newest?: string + error?: string + } + } session_status: { [sessionID: string]: SessionStatus } @@ -89,6 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider: [], provider_default: {}, session: [], + message_page: {}, session_status: {}, session_diff: {}, todo: {}, @@ -104,6 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() + const getRevertMarker = (sessionID: string) => { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (!match.found) return undefined + return store.session[match.index].revert?.messageID + } + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -226,40 +249,74 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] + const sessionID = event.properties.info.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const pinned = getRevertMarker(sessionID) if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", sessionID, [event.properties.info]) break } const result = Binary.search(messages, event.properties.info.id, (m) => m.id) if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + setStore("message", sessionID, result.index, reconcile(event.properties.info)) + break + } + const loadingNewer = page?.loading && page.loadingDirection === "newer" + const loadingOlder = page?.loading && page.loadingDirection === "older" + if (page?.hasNewer && !loadingNewer) { + break + } + if (page?.oldest && event.properties.info.id < page.oldest && !loadingOlder) { break } setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] + if (page) { + const nextOldest = windowOldest(updated, pinned) ?? page.oldest + const nextNewest = windowNewest(updated, pinned) ?? page.newest + setStore("message_page", event.properties.info.sessionID, { + ...page, + newest: nextNewest, + oldest: nextOldest, + }) + } + if (updated.length > MAX_LOADED_MESSAGES) { + const evictCount = updated.length - MAX_LOADED_MESSAGES + const preview = [...updated] + const evicted = evictFromStart(preview, evictCount, pinned) + const nextOldest = windowOldest(preview, pinned) ?? page?.oldest + const nextNewest = windowNewest(preview, pinned) ?? page?.newest batch(() => { setStore( "message", event.properties.info.sessionID, produce((draft) => { - draft.shift() + evictFromStart(draft, evictCount, pinned) }), ) setStore( "part", produce((draft) => { - delete draft[oldest.id] + for (const msg of evicted) { + delete draft[msg.id] + } }), ) + if (page) { + setStore("message_page", event.properties.info.sessionID, { + ...page, + hasOlder: true, + oldest: nextOldest, + newest: nextNewest, + }) + } }) } break @@ -279,6 +336,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { + const sessionID = event.properties.part.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const messageExists = messages?.some((m) => m.id === event.properties.part.messageID) + const loadingNewer = page?.loading && page.loadingDirection === "newer" + if (!messageExists && !loadingNewer) { + break + } const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) @@ -350,7 +415,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ console.log("bootstrapping") const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session - .list({ start: start }) + .list({ start: start, roots: true }) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session @@ -419,7 +484,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) .catch(async (e) => { Log.Default.error("tui bootstrap failed", { - error: e instanceof Error ? e.message : String(e), + error: paginationError(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) @@ -432,6 +497,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const fullSyncedSessions = new Set() + const treeSyncedRoots = new Set() + const loadingGuard = new Set() const result = { data: store, set: setStore, @@ -465,21 +532,426 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const link = messages.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + const pageMessages = messages.data ?? [] + const oldest = pageMessages.at(0)?.info.id + const newest = pageMessages.at(-1)?.info.id + const revertMessageID = session.data?.revert?.messageID + const mergedMessages = await (async () => { + if (!revertMessageID) return pageMessages + if (pageMessages.some((m) => m.info.id === revertMessageID)) return pageMessages + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (revertResult.data) return [revertResult.data, ...pageMessages] + } catch (e) { + Log.Default.info("Revert marker fetch failed during sync", { + messageID: revertMessageID, + error: e, + }) + } + return pageMessages + })() + const nextOldest = oldest ?? mergedMessages.at(0)?.info.id + const nextNewest = newest ?? mergedMessages.at(-1)?.info.id setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + draft.message[sessionID] = mergedMessages.map((x) => x.info) + for (const message of mergedMessages) { draft.part[message.info.id] = message.parts } draft.session_diff[sessionID] = diff.data ?? [] + draft.message_page[sessionID] = { + hasOlder, + hasNewer: false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } }), ) fullSyncedSessions.add(sessionID) }, + async syncTree(sessionID: string) { + // Walk up to find the root session + let rootID = sessionID + const visited = new Set() + while (true) { + if (visited.has(rootID)) break + visited.add(rootID) + const match = Binary.search(store.session, rootID, (s) => s.id) + if (!match.found) break + const parentID = store.session[match.index].parentID + if (!parentID) break + const parentMatch = Binary.search(store.session, parentID, (s) => s.id) + if (parentMatch.found) { + rootID = parentID + continue + } + // Parent not in store — fetch it + const res = await sdk.client.session.get({ sessionID: parentID }).catch(() => undefined) + if (!res?.data) break + setStore( + produce((draft) => { + const idx = Binary.search(draft.session, res.data!.id, (s) => s.id) + if (!idx.found) draft.session.splice(idx.index, 0, res.data!) + }), + ) + rootID = parentID + } + + if (treeSyncedRoots.has(rootID)) return + treeSyncedRoots.add(rootID) + + // BFS from root — load all descendants level by level + const queue = [rootID] + while (queue.length > 0) { + const level = [...queue] + queue.length = 0 + const results = await Promise.all( + level.map((id) => sdk.client.session.children({ sessionID: id }).catch(() => undefined)), + ) + const all = results.flatMap((r) => r?.data ?? []) + if (all.length === 0) break + setStore( + produce((draft) => { + for (const child of all) { + const idx = Binary.search(draft.session, child.id, (s) => s.id) + if (!idx.found) draft.session.splice(idx.index, 0, child) + } + }), + ) + for (const child of all) { + queue.push(child.id) + } + } + }, + async loadOlder(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + const messages = store.message[sessionID] ?? [] + const cursor = page?.oldest ?? messages.at(0)?.id + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "older", error: undefined }) + + const res = await sdk.client.session.messages( + { sessionID, before: cursor, limit: 100 }, + { throwOnError: true }, + ) + const link = res.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageOldest = res.data?.at(0)?.info.id + for (const msg of res.data ?? []) { + const match = Binary.search(existing, msg.info.id, (m) => m.id) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextOldest = pageOldest ?? draft.message_page[sessionID]?.oldest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromEnd(existing, evictCount, pinned) + for (const msg of evicted) delete draft.part[msg.id] + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder, + hasNewer: true, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } else { + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder, + hasNewer: draft.message_page[sessionID]?.hasNewer ?? false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } + }), + ) + } catch (e) { + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + error: paginationError(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async loadNewer(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasNewer) return + const messages = store.message[sessionID] ?? [] + const cursor = page?.newest ?? messages.at(-1)?.id + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "newer", error: undefined }) + const res = await sdk.client.session.messages( + { sessionID, after: cursor, limit: 100 }, + { throwOnError: true }, + ) + const link = res.response.headers.get("link") ?? "" + const hasNewer = parseLinkHeader(link).next !== undefined + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageNewest = res.data?.at(-1)?.info.id + for (const msg of res.data ?? []) { + const match = Binary.search(existing, msg.info.id, (m) => m.id) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextNewest = pageNewest ?? draft.message_page[sessionID]?.newest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromStart(existing, evictCount, pinned) + for (const msg of evicted) delete draft.part[msg.id] + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: true, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } else { + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: draft.message_page[sessionID]?.hasOlder ?? false, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } + }), + ) + } catch (e) { + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + error: paginationError(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToLatest(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasNewer) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + + try { + // Check for revert state + const session = store.session.find((s) => s.id === sessionID) + const revertMessageID = session?.revert?.messageID + + setStore("message_page", sessionID, { + ...page, + loading: true, + loadingDirection: "newer", + error: undefined, + }) + + // Fetch newest page (no cursor = newest) + const res = await sdk.client.session.messages({ sessionID, limit: 100 }, { throwOnError: true }) + + let messages = res.data ?? [] + const pageOldest = messages.at(0)?.info.id + const pageNewest = messages.at(-1)?.info.id + const link = res.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + + // Revert-aware: If in revert state and marker not in results, fetch it + if (revertMessageID && !messages.some((m) => m.info.id === revertMessageID)) { + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (revertResult.data) { + // Prepend revert message (it's older than newest page) + messages = [revertResult.data, ...messages] + } + } catch (e) { + // Revert message may have been deleted, continue without it + Log.Default.info("Revert marker fetch failed (may be deleted)", { + messageID: revertMessageID, + error: e, + }) + } + } + + const nextOldest = pageOldest ?? messages.at(0)?.info.id + const nextNewest = pageNewest ?? messages.at(-1)?.info.id + + setStore( + produce((draft) => { + // Clean up parts only for messages not in new results + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(messages.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + } + } + + // Store new messages + draft.message[sessionID] = messages.map((m) => m.info) + for (const msg of messages) { + draft.part[msg.info.id] = msg.parts + } + draft.message_page[sessionID] = { + hasOlder, + hasNewer: false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + }), + ) + } catch (e) { + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = paginationError(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToOldest(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + + try { + setStore("message_page", sessionID, { + ...page, + loading: true, + loadingDirection: "older", + error: undefined, + }) + + const res = await sdk.client.session.messages( + { sessionID, oldest: "true", limit: 100 }, + { throwOnError: true }, + ) + + const session = store.session.find((s) => s.id === sessionID) + const revertMessageID = session?.revert?.messageID + + let messages = res.data ?? [] + const pageOldest = messages.at(0)?.info.id + const pageNewest = messages.at(-1)?.info.id + const link = res.response.headers.get("link") ?? "" + const hasNewer = parseLinkHeader(link).next !== undefined + + if (revertMessageID && !messages.some((m) => m.info.id === revertMessageID)) { + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (revertResult.data) { + const index = Binary.search(messages, revertResult.data.info.id, (m) => m.info.id) + if (!index.found) messages.splice(index.index, 0, revertResult.data) + } + } catch (e) { + Log.Default.info("Revert marker fetch failed during jumpToOldest", { + messageID: revertMessageID, + error: e, + }) + } + } + + const nextOldest = pageOldest ?? messages.at(0)?.info.id + const nextNewest = pageNewest ?? messages.at(-1)?.info.id + + setStore( + produce((draft) => { + // Clean up parts only for messages not in new results + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(messages.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + } + } + + // Store new messages + draft.message[sessionID] = messages.map((m) => m.info) + for (const msg of messages) { + draft.part[msg.info.id] = msg.parts + } + draft.message_page[sessionID] = { + hasOlder: false, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + }), + ) + } catch (e) { + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = paginationError(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, }, bootstrap, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx new file mode 100644 index 000000000000..b79d49d56097 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-session-tree.tsx @@ -0,0 +1,185 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo, onMount, type JSX } from "solid-js" +import { Locale } from "@/util/locale" +import { useTheme } from "../../context/theme" +import { useKV } from "../../context/kv" +import type { Session } from "@opencode-ai/sdk/v2" +import "opentui-spinner/solid" + +interface TreeOption { + title: string + value: string + prefix: string + footer: string + gutter: JSX.Element | undefined +} + +/** + * Find the root session by walking up the parentID chain + */ +function findRootSession( + currentSession: Session | undefined, + getSession: (id: string) => Session | undefined, +): Session | undefined { + let current = currentSession + while (current?.parentID) { + current = getSession(current.parentID) + } + return current +} + +/** + * Extract agent name from session title or agent field + * Session titles often contain "@agent-name" pattern + */ +function extractAgentName(session: Session): string { + // Try to extract from title pattern "... (@agent-name ...)" + const match = session.title?.match(/@([^\s)]+)/) + if (match) return match[1] + + // Fallback to first meaningful word of title, or "Session" + const firstWord = session.title?.split(" ")[0] + if (firstWord && firstWord.length > 0 && firstWord.length < 30) { + return firstWord + } + return "Session" +} + +/** + * Build flat array of tree options with visual prefixes using DFS traversal + */ +function buildTreeOptions( + sessions: Session[], + currentSessionId: string, + rootSession: Session | undefined, + sync: ReturnType, + theme: any, + animationsEnabled: boolean, +): TreeOption[] { + if (!rootSession) return [] + + const result: TreeOption[] = [] + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + function getStatusIndicator(session: Session) { + // Current session indicator + if (session.id === currentSessionId) { + return + } + + // Permission awaiting indicator + const permission = sync.data.permission[session.id] + if (permission?.length) { + return + } + + // Busy session indicator (spinner) + const status = sync.data.session_status?.[session.id] + if (status?.type === "busy") { + if (animationsEnabled) { + return + } + return [⋯] + } + + return undefined + } + + function traverse(session: Session, depth: number, prefix: string, isLast: boolean) { + // Determine connector for this node + const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ " + // Determine prefix for children (continuation line or space) + const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "│ ") + + const agentName = extractAgentName(session) + // For root, show full title; for children, show agent + truncated title + const displayTitle = + depth === 0 ? session.title || "Session" : `${agentName} "${session.title || ""}"` + + result.push({ + title: displayTitle, + value: session.id, + prefix: prefix + connector, + footer: Locale.time(session.time.updated), + gutter: getStatusIndicator(session), + }) + + // Get direct children and sort by id for consistent ordering + const children = sessions + .filter((s) => s.parentID === session.id) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + children.forEach((child, i) => { + traverse(child, depth + 1, childPrefix, i === children.length - 1) + }) + } + + traverse(rootSession, 0, "", true) + return result +} + +export function DialogSessionTree() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const { theme } = useTheme() + const kv = useKV() + + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + + const session = createMemo(() => { + const id = currentSessionID() + return id ? sync.session.get(id) : undefined + }) + + const rootSession = createMemo(() => { + return findRootSession(session(), (id) => sync.session.get(id)) + }) + + const animationsEnabled = kv.get("animations_enabled", true) + + const options = createMemo(() => { + const root = rootSession() + const currentId = currentSessionID() + if (!root || !currentId) return [] + + const treeOptions = buildTreeOptions( + sync.data.session, + currentId, + root, + sync, + theme, + animationsEnabled, + ) + + // Convert to DialogSelectOption format with custom rendering + return treeOptions.map((opt) => ({ + title: opt.prefix + opt.title, + value: opt.value, + footer: opt.footer, + gutter: opt.gutter, + })) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + { + route.navigate({ + type: "session", + sessionID: option.value, + }) + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff3725..737ca86e8ff6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -15,7 +15,19 @@ export function Footer() { const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + // Collect permissions from all descendant sessions (full tree) + const rootID = route.data.sessionID + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids.flatMap((id) => sync.data.permission[id] ?? []) }) const directory = useDirectory() const connected = useConnected() 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 0c5ea9a85723..218018e9a912 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,5 +1,5 @@ -import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" -import { useRouteData } from "@tui/context/route" +import { type Accessor, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" @@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" +import { Installation } from "@/installation" import { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -31,6 +32,7 @@ const ContextInfo = (props: { context: Accessor; cost: Acces export function Header() { const route = useRouteData("session") + const { navigate } = useRoute() const sync = useSync() const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) @@ -59,12 +61,113 @@ export function Header() { return result }) + // Build session path from root to current session + const sessionPath = createMemo(() => { + const path: Session[] = [] + let current: Session | undefined = session() + while (current) { + path.unshift(current) + current = current.parentID ? sync.session.get(current.parentID) : undefined + } + return path + }) + + // Current depth (0 = root, 1 = first child, etc.) + const depth = createMemo(() => sessionPath().length - 1) + + // Direct children of current session (for down navigation availability) + const directChildren = createMemo(() => { + const currentID = session()?.id + if (!currentID) return [] + return sync.data.session.filter((x) => x.parentID === currentID) + }) + + // Siblings at current level (for left/right navigation availability) + const siblings = createMemo(() => { + const currentParentID = session()?.parentID + if (!currentParentID) return [] + return sync.data.session.filter((x) => x.parentID === currentParentID) + }) + + // Navigation availability + const canGoUp = createMemo(() => !!session()?.parentID) + const canGoDown = createMemo(() => directChildren().length > 0) + const canCycleSiblings = createMemo(() => siblings().length > 1) + + // Get display name for a session + const getSessionDisplayName = (s: Session, isRoot: boolean) => { + if (isRoot) { + // Root session: show the title + return s.title || s.id.slice(0, 8) + } + // Child session: extract agent name from title like "Description (@agent-name subagent)" + const match = s.title?.match(/\(@([^)]+?)(?:\s+subagent)?\)/) + if (match) { + // Return just the agent name without @ and "subagent" + return match[1] + } + // Fallback to title or shortened ID + return s.title || s.id.slice(0, 8) + } + + // Get UP navigation label based on depth + const upLabel = createMemo(() => { + const d = depth() + if (d <= 0) return "" // Root has no parent + if (d === 1) return "Parent" // Depth 1 → Root + return `Child(L${d - 1})` // Depth N → Child(L{N-1}) + }) + + // Get DOWN navigation label based on depth + const downLabel = createMemo(() => { + const d = depth() + return `Child(L${d + 1})` // Depth N → Child(L{N+1}) + }) + const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() - const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) + const [hover, setHover] = createSignal<"parent" | "root" | "prev" | "next" | "down" | "breadcrumb" | null>(null) + const [hoverBreadcrumbIdx, setHoverBreadcrumbIdx] = createSignal(null) + + // Calculate breadcrumb text for a set of segments + const calcBreadcrumbLength = (segments: Session[], truncated: boolean) => { + let len = 0 + segments.forEach((s, i) => { + len += getSessionDisplayName(s, !s.parentID).length + if (i < segments.length - 1) { + len += truncated && i === 0 ? 9 : 3 // " > ... > " or " > " + } + }) + return len + } + + // Dynamic breadcrumb truncation based on available width + const breadcrumbSegments = createMemo(() => { + const path = sessionPath() + const availableWidth = dimensions().width - 40 // Reserve ~40 chars for right-side stats + + // Try full path first + const fullLength = calcBreadcrumbLength(path, false) + if (fullLength <= availableWidth || path.length <= 2) { + return { truncated: false, segments: path } + } + + // Truncate: show root + ... + last N segments that fit + // Start with root + last segment, add more if space allows + for (let keepLast = path.length - 1; keepLast >= 1; keepLast--) { + const segments = [path[0], ...path.slice(-keepLast)] + const len = calcBreadcrumbLength(segments, true) + if (len <= availableWidth || keepLast === 1) { + return { truncated: true, segments } + } + } + + // Fallback: root + last segment + return { truncated: true, segments: [path[0], path[path.length - 1]] } + }) return ( @@ -81,49 +184,125 @@ export function Header() { > - - - - Subagent session - - + {/* Subagent session: 3-row layout */} + + {/* Row 1: Breadcrumb trail */} + + + {(segment, index) => ( + <> + { + setHover("breadcrumb") + setHoverBreadcrumbIdx(index()) + }} + onMouseOut={() => { + setHover(null) + setHoverBreadcrumbIdx(null) + }} + onMouseUp={() => { + navigate({ type: "session", sessionID: segment.id }) + }} + backgroundColor={ + hover() === "breadcrumb" && hoverBreadcrumbIdx() === index() + ? theme.backgroundElement + : theme.backgroundPanel + } + > + + + {getSessionDisplayName(segment, !segment.parentID)} + + + + + {/* Show "... >" after root when truncated */} + + {index() === 0 && breadcrumbSegments().truncated ? " > ... >" : " > "} + + + + )} + - - setHover("parent")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} - > - - Parent {keybind.print("session_parent")} - - - setHover("prev")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} - backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} - > - - Prev {keybind.print("session_child_cycle_reverse")} - + + {/* Row 2: Divider + stats */} + + + ──────────────────────────────────────── - setHover("next")} - onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} - backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} - > - - Next {keybind.print("session_child_cycle")} - + + + v{Installation.VERSION} + + {/* Row 3: Navigation hints */} + + + setHover("parent")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.parent")} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + > + + {upLabel()} {keybind.print("session_parent")} + + + + = 2}> + setHover("root")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.root")} + backgroundColor={hover() === "root" ? theme.backgroundElement : theme.backgroundPanel} + > + + Root {keybind.print("session_root")} + + + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + > + + Next {keybind.print("session_child_cycle")} + + + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.previous")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + > + + Prev {keybind.print("session_child_cycle_reverse")} + + + + + setHover("down")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("session.child.down")} + backgroundColor={hover() === "down" ? theme.backgroundElement : theme.backgroundPanel} + > + + {downLabel()} {keybind.print("session_child_down")} + + + + - + {/* Root session: responsive layout */} + <ContextInfo context={context} cost={cost} /> </box> 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 314018367667..89dca707aab7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { DialogSessionTree } from "./dialog-session-tree" +import { edgeHints, olderScrollTarget, queueBoundaryLoad } from "@tui/util/pagination" addDefaultParsers(parsers.parsers) @@ -127,16 +129,111 @@ export function Session() { .filter((x) => x.parentID === parentID || x.id === parentID) .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) + // Siblings: sessions with the same direct parent (for left/right cycling) + const siblings = createMemo(() => { + const currentParentID = session()?.parentID + if (!currentParentID) { + // Root session: no siblings to cycle + return [session()!].filter(Boolean) + } + return sync.data.session + .filter((x) => x.parentID === currentParentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + // Direct children: sessions whose parent is this session (for down navigation) + const directChildren = createMemo(() => { + const currentID = session()?.id + if (!currentID) return [] + return sync.data.session + .filter((x) => x.parentID === currentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + // Collect all descendant session IDs (full tree) for permission/question aggregation + const descendants = createMemo(() => { + const rootID = session()?.id + if (!rootID || session()?.parentID) return [] + const ids: string[] = [rootID] + const queue = [rootID] + while (queue.length > 0) { + const parentID = queue.pop()! + for (const s of sync.data.session) { + if (s.parentID !== parentID) continue + ids.push(s.id) + queue.push(s.id) + } + } + return ids + }) + const paging = createMemo(() => sync.data.message_page[route.sessionID]) const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return descendants().flatMap((id) => sync.data.permission[id] ?? []) }) const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return descendants().flatMap((id) => sync.data.question[id] ?? []) }) + const LOAD_MORE_THRESHOLD = 5 + + const loadOlder = () => { + const page = paging() + if (!page?.hasOlder || page.loading || !scroll) return + if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return + + const anchor = (() => { + const scrollTop = scroll.scrollTop + const children = scroll.getChildren() + for (const child of children) { + if (!child.id) continue + if (child.y + child.height > scrollTop) { + return { id: child.id, offset: scrollTop - child.y } + } + } + return undefined + })() + + const height = scroll.scrollHeight + const scrollTop = scroll.scrollTop + sync.session.loadOlder(route.sessionID).then(() => { + queueMicrotask(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + const nextTop = olderScrollTarget(scroll.getChildren(), scroll.scrollHeight, height, scrollTop, anchor) + if (nextTop !== undefined) scroll.scrollTo(nextTop) + refreshEdges() + }) + }) + }) + } + + const loadNewer = () => { + const page = paging() + if (!page?.hasNewer || page.loading || !scroll) return + const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height + if (bottomDistance > LOAD_MORE_THRESHOLD) return + sync.session.loadNewer(route.sessionID).then(() => { + queueMicrotask(() => { + requestAnimationFrame(() => { + refreshEdges() + }) + }) + }) + } + + const refreshEdges = () => { + if (!scroll || scroll.isDestroyed) return + const edges = edgeHints(scroll.scrollTop, scroll.scrollHeight, scroll.viewport.height, HINT_THRESHOLD) + setNearTop(edges.nearTop) + setNearBottom(edges.nearBottom) + } + + const scrollMove = (delta: number) => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollBy(delta) + refreshEdges() + queueBoundaryLoad(delta, loadOlder, loadNewer) + } + const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) @@ -158,6 +255,9 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [nearTop, setNearTop] = createSignal(false) + const [nearBottom, setNearBottom] = createSignal(false) + const HINT_THRESHOLD = 20 const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -185,7 +285,9 @@ export function Session() { await sync.session .sync(route.sessionID) .then(() => { - if (scroll) scroll.scrollBy(100_000) + if (!scroll || scroll.isDestroyed) return + scroll.scrollBy(100_000) + refreshEdges() }) .catch((e) => { console.error(e) @@ -195,6 +297,17 @@ export function Session() { }) return navigate({ type: "home" }) }) + sync.session.syncTree(route.sessionID).catch(() => {}) + }) + + createEffect(() => { + if (!scroll || scroll.isDestroyed) return + messages() + queueMicrotask(() => { + requestAnimationFrame(() => { + refreshEdges() + }) + }) }) const toast = useToast() @@ -264,7 +377,7 @@ export function Session() { const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() const messagesList = messages() - const scrollTop = scroll.y + const scrollTop = scroll.scrollTop // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content const visibleMessages = children @@ -296,13 +409,16 @@ export function Session() { const targetID = findNextVisibleMessage(direction) if (!targetID) { - scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) + scrollMove(direction === "next" ? scroll.height : -scroll.height) dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } dialog.clear() } @@ -310,34 +426,48 @@ export function Session() { setTimeout(() => { if (!scroll || scroll.isDestroyed) return scroll.scrollTo(scroll.scrollHeight) + requestAnimationFrame(() => { + refreshEdges() + }) }, 50) } const local = useLocal() - function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) - if (next) { + function moveChild(direction: number) { + // Use siblings for cycling (sessions with same parentID) + const sibs = siblings() + if (sibs.length <= 1) return + let next = sibs.findIndex((x) => x.id === session()?.id) + direction + if (next >= sibs.length) next = 0 + if (next < 0) next = sibs.length - 1 + if (sibs[next]) { navigate({ type: "session", - sessionID: next.id, + sessionID: sibs[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 + function moveToFirstChild() { + const children = directChildren() + if (children.length === 0) return + navigate({ + type: "session", + sessionID: children[0].id, + }) + } - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) { + function moveToRoot() { + // Traverse up to find root session (no parentID) + let current = session() + while (current?.parentID) { + current = sync.session.get(current.parentID) + } + if (current && current.id !== session()?.id) { navigate({ type: "session", - sessionID: sessions[next].id, + sessionID: current.id, }) } } @@ -408,7 +538,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } }} sessionID={route.sessionID} setPrompt={(promptInfo) => prompt.set(promptInfo)} @@ -431,7 +564,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } }} sessionID={route.sessionID} /> @@ -645,7 +781,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(-scroll.height / 2) + scrollMove(-scroll.height / 2) dialog.clear() }, }, @@ -656,7 +792,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(scroll.height / 2) + scrollMove(scroll.height / 2) dialog.clear() }, }, @@ -667,7 +803,7 @@ export function Session() { category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(-1) + scrollMove(-1) dialog.clear() }, }, @@ -678,7 +814,7 @@ export function Session() { category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(1) + scrollMove(1) dialog.clear() }, }, @@ -689,7 +825,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(-scroll.height / 4) + scrollMove(-scroll.height / 4) dialog.clear() }, }, @@ -700,7 +836,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(scroll.height / 4) + scrollMove(scroll.height / 4) dialog.clear() }, }, @@ -711,7 +847,23 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(0) + const page = paging() + if (page?.hasOlder && !page.loading) { + sync.session.jumpToOldest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(0) + refreshEdges() + }) + }) + } else { + if (!scroll || scroll.isDestroyed) { + dialog.clear() + return + } + scroll.scrollTo(0) + refreshEdges() + } dialog.clear() }, }, @@ -722,7 +874,23 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(scroll.scrollHeight) + const page = paging() + if (page?.hasNewer && !page.loading) { + sync.session.jumpToLatest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(scroll.scrollHeight) + refreshEdges() + }) + }) + } else { + if (!scroll || scroll.isDestroyed) { + dialog.clear() + return + } + scroll.scrollTo(scroll.scrollHeight) + refreshEdges() + } dialog.clear() }, }, @@ -752,7 +920,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === message.id }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } break } } @@ -908,13 +1079,13 @@ export function Session() { }, }, { - title: "Go to child session", - value: "session.child.first", - keybind: "session_child_first", + title: "Go to first child session", + value: "session.child.down", + keybind: "session_child_down", category: "Session", hidden: true, onSelect: (dialog) => { - moveFirstChild() + moveToFirstChild() dialog.clear() }, }, @@ -957,6 +1128,26 @@ export function Session() { dialog.clear() }), }, + { + title: "Go to root session", + value: "session.root", + keybind: "session_root", + category: "Session", + hidden: true, + onSelect: (dialog) => { + moveToRoot() + dialog.clear() + }, + }, + { + title: "Session tree", + value: "session.tree", + keybind: "session_child_list", + category: "Session", + onSelect: (dialog) => { + dialog.replace(() => <DialogSessionTree />) + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -1032,8 +1223,45 @@ export function Session() { <Show when={showHeader() && (!sidebarVisible() || !wide())}> <Header /> </Show> + <Show when={paging()?.loading && paging()?.loadingDirection === "older"}> + <box flexShrink={0} paddingLeft={1}> + <text fg={theme.textMuted}>Loading older messages...</text> + </box> + </Show> + <Show when={!paging()?.loading && paging()?.hasOlder && nearTop()}> + <box flexShrink={0} paddingLeft={1}> + <text fg={theme.textMuted}>(scroll up for more)</text> + </box> + </Show> + <Show when={paging()?.error}> + <box flexShrink={0} paddingLeft={1}> + <text fg={theme.error}>Failed to load: {paging()?.error}</text> + <text fg={theme.textMuted}> (scroll to retry)</text> + </box> + </Show> <scrollbox ref={(r) => (scroll = r)} + onMouseScroll={() => { + refreshEdges() + loadOlder() + loadNewer() + }} + onKeyDown={(e) => { + // Standard scroll triggers incremental load + if (["up", "pageup", "home"].includes(e.name)) { + setTimeout(() => { + refreshEdges() + loadOlder() + }, 0) + } + if (["down", "pagedown", "end"].includes(e.name)) { + setTimeout(() => { + refreshEdges() + loadNewer() + }, 0) + } + }} + viewportCulling={true} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} @@ -1146,6 +1374,16 @@ export function Session() { )} </For> </scrollbox> + <Show when={paging()?.loading && paging()?.loadingDirection === "newer"}> + <box flexShrink={0} paddingLeft={1}> + <text fg={theme.textMuted}>Loading newer messages...</text> + </box> + </Show> + <Show when={!paging()?.loading && paging()?.hasNewer && nearBottom()}> + <box flexShrink={0} paddingLeft={1}> + <text fg={theme.textMuted}>(scroll down for more)</text> + </box> + </Show> <box flexShrink={0}> <Show when={permissions().length > 0}> <PermissionPrompt request={permissions()[0]} /> @@ -1937,7 +2175,7 @@ function Task(props: ToolProps<typeof TaskTool>) { return ( <Switch> - <Match when={props.input.description || props.input.subagent_type}> + <Match when={props.metadata.sessionId}> <BlockTool title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"} onClick={ @@ -1963,12 +2201,10 @@ function Task(props: ToolProps<typeof TaskTool>) { }} </Show> </box> - <Show when={props.metadata.sessionId}> - <text fg={theme.text}> - {keybind.print("session_child_first")} - <span style={{ fg: theme.textMuted }}> view subagents</span> - </text> - </Show> + <text fg={theme.text}> + {keybind.print("session_child_down")} + <span style={{ fg: theme.textMuted }}> view subagents</span> + </text> </BlockTool> </Match> <Match when={true}> diff --git a/packages/opencode/src/cli/cmd/tui/util/pagination.ts b/packages/opencode/src/cli/cmd/tui/util/pagination.ts new file mode 100644 index 000000000000..e1cad6c7e9b0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/pagination.ts @@ -0,0 +1,136 @@ +import type { Message } from "@opencode-ai/sdk/v2" + +const text = (value: unknown) => { + if (typeof value === "string") return value + if (typeof value === "number") return String(value) + if (typeof value === "boolean") return String(value) + return undefined +} + +const message = (value: unknown) => { + if (typeof value !== "object" || value === null) return undefined + return text((value as Record<string, unknown>).message) +} + +export const windowOldest = (messages: Message[], pinned?: string) => { + if (!pinned) return messages.at(0)?.id + for (const msg of messages) { + if (msg.id !== pinned) return msg.id + } + return undefined +} + +export const windowNewest = (messages: Message[], pinned?: string) => { + if (!pinned) return messages.at(-1)?.id + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i] + if (msg && msg.id !== pinned) return msg.id + } + return undefined +} + +export const evictFromStart = (messages: Message[], count: number, pinned?: string) => { + const evicted: Message[] = [] + if (count <= 0) return evicted + let index = 0 + while (index < messages.length && evicted.length < count) { + const msg = messages[index] + if (!msg) break + if (msg.id !== pinned) { + evicted.push(msg) + messages.splice(index, 1) + continue + } + index += 1 + } + return evicted +} + +export const evictFromEnd = (messages: Message[], count: number, pinned?: string) => { + const evicted: Message[] = [] + if (count <= 0) return evicted + let index = messages.length - 1 + while (index >= 0 && evicted.length < count) { + const msg = messages[index] + if (!msg) break + if (msg.id !== pinned) { + evicted.push(msg) + messages.splice(index, 1) + } + index -= 1 + } + return evicted +} + +export const paginationError = (error: unknown) => { + if (error instanceof Error) return error.message + const plain = text(error) + if (plain) return plain + const direct = message(error) + if (direct) return direct + if (typeof error === "object" && error !== null) { + const nested = message((error as Record<string, unknown>).error) + if (nested) return nested + return Bun.inspect(error) + } + return "Unknown error" +} + +export const queueBoundaryLoad = ( + delta: number, + older: () => void, + newer: () => void, + queue: (run: () => void) => void = (run) => { + setTimeout(run, 0) + }, +) => { + if (delta < 0) { + queue(older) + return + } + if (delta > 0) queue(newer) +} + +type Edges = { + nearTop: boolean + nearBottom: boolean +} + +export const edgeHints = ( + scrollTop: number, + scrollHeight: number, + viewportHeight: number, + threshold: number, +): Edges => { + return { + nearTop: scrollTop <= threshold, + nearBottom: scrollHeight - scrollTop - viewportHeight <= threshold, + } +} + +type Anchor = { + id: string + offset: number +} + +type Child = { + id?: string + y: number + height: number +} + +export const olderScrollTarget = ( + children: Child[], + nextHeight: number, + prevHeight: number, + prevTop: number, + anchor?: Anchor, +) => { + if (anchor) { + const child = children.find((item) => item.id === anchor.id) + if (child) return child.y + anchor.offset + } + const delta = nextHeight - prevHeight + if (delta > 0) return prevTop + delta + return undefined +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f6156985f..90e528f12797 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -342,20 +342,19 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse command ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load command", { command: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + const name = (() => { + const patterns = ["/.opencode/command/", "/command/"] + const pattern = patterns.find((p) => item.includes(p)) - const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] - const file = rel(item, patterns) ?? path.basename(item) - const name = trim(file) + if (pattern) { + const index = item.indexOf(pattern) + return item.slice(index + pattern.length, -3) + } + return path.basename(item, ".md") + })() const config = { name, @@ -381,20 +380,23 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse agent ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) - return undefined - }) - if (!md) continue - - const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const file = rel(item, patterns) ?? path.basename(item) - const agentName = trim(file) + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue + + // Extract relative path from agent folder for nested agents + let agentName = path.basename(item, ".md") + const agentFolderPath = item.includes("/.opencode/agent/") + ? item.split("/.opencode/agent/")[1] + : item.includes("/agent/") + ? item.split("/agent/")[1] + : agentName + ".md" + + // If agent is in a subfolder, include folder path in name + if (agentFolderPath.includes("/")) { + const relativePath = agentFolderPath.replace(".md", "") + const pathParts = relativePath.split("/") + agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1] + } const config = { name: agentName, @@ -419,16 +421,8 @@ export namespace Config { dot: true, symlink: true, })) { - const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse mode ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) - return undefined - }) - if (!md) continue + const md = await ConfigMarkdown.parse(item) + if (!md.data) continue const config = { name: path.basename(item, ".md"), @@ -527,7 +521,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -566,7 +562,9 @@ export namespace Config { .int() .positive() .optional() - .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -679,8 +677,16 @@ export namespace Config { hidden: z .boolean() .optional() - .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), - options: z.record(z.string(), z.any()).optional(), + .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), + task_budget: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum task calls this agent can make per session when delegating to other subagents. Set to 0 to explicitly disable, omit to use default (disabled).", + ), + options: z.record(z.string(), z.any()).optional(), color: z .union([ z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"), @@ -709,6 +715,7 @@ export namespace Config { "top_p", "mode", "hidden", + "task_budget", "color", "steps", "maxSteps", @@ -760,29 +767,19 @@ export namespace Config { sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), - status_view: z.string().optional().default("<leader>s").describe("View status"), + status_view: z.string().optional().default("<leader>i").describe("View status"), session_export: z.string().optional().default("<leader>x").describe("Export session to editor"), session_new: z.string().optional().default("<leader>n").describe("Create a new session"), session_list: z.string().optional().default("<leader>l").describe("List all sessions"), session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), - session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), - session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), - stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), - model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), - model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), + session_rename: z.string().optional().default("none").describe("Rename session"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("<leader>c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), - messages_page_down: z - .string() - .optional() - .default("pagedown,ctrl+alt+f") - .describe("Scroll messages down by one page"), - messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), - messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), + messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), + messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() @@ -896,10 +893,12 @@ export namespace Config { .describe("Delete word backward in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), - session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"), - session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), - session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), - session_parent: z.string().optional().default("up").describe("Go to parent session"), + session_child_cycle: z.string().optional().default("<leader>right").describe("Next sibling session"), + session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous sibling session"), + session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), + session_child_down: z.string().optional().default("<leader>down").describe("Go to first child session"), + session_root: z.string().optional().default("<leader>escape").describe("Go to root session"), + session_child_list: z.string().optional().default("<leader>s").describe("Open session tree dialog"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"), @@ -1056,7 +1055,7 @@ export namespace Config { }) .catchall(Agent) .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), + .describe("Agent configuration, see https://opencode.ai/docs/agent"), provider: z .record(z.string(), Provider) .optional() @@ -1165,6 +1164,15 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + level_limit: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum depth for subagent session trees. Prevents infinite delegation loops. " + + "Default: 5. Set to 0 to disable (not recommended)." + ), }) .optional(), }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba04..f3ac00cb237b 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -4,6 +4,7 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" +import { Identifier } from "../../id/id" import { SessionPrompt } from "../../session/prompt" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" @@ -567,16 +568,107 @@ export const SessionRoutes = lazy(() => validator( "query", z.object({ - limit: z.coerce.number().optional(), + limit: z.coerce.number().int().min(0).optional(), + before: Identifier.schema("message").optional(), + after: Identifier.schema("message").optional(), + oldest: z + .string() + .optional() + .transform((value) => { + if (value === undefined) return undefined + if (value === "true") return true + if (value === "false") return false + return value + }) + .pipe(z.boolean().optional()), }), ), async (c) => { const query = c.req.valid("query") - const messages = await Session.messages({ - sessionID: c.req.valid("param").sessionID, - limit: query.limit, + if (query.before && query.after) { + return c.json({ error: "Cannot specify both 'before' and 'after'" }, 400) + } + if (query.oldest && (query.before || query.after)) { + return c.json({ error: "Cannot use 'oldest' with 'before' or 'after'" }, 400) + } + + if (query.limit === 0) return c.json([]) + const rawLimit = query.limit + const sessionID = c.req.valid("param").sessionID + + const usesCursor = !!(query.oldest || query.after || query.before) + + if (!usesCursor && rawLimit === undefined) { + const messages = await Session.messages({ sessionID }) + return c.json(messages) + } + + const pageLimit = usesCursor ? (rawLimit ? Math.min(rawLimit, 100) : 100) : (rawLimit ?? 100) + + if (query.oldest) { + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + oldest: true, + }) + + if (page.length > pageLimit) { + const messages = page.slice(0, -1) + const last = messages.at(-1) + if (last) { + const url = new URL(c.req.url) + url.searchParams.delete("oldest") + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("after", last.info.id) + c.header("Link", `<${url.toString()}>; rel=\"next\"`) + } + return c.json(messages) + } + + return c.json(page) + } + + if (query.after) { + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + after: query.after, + }) + + if (page.length > pageLimit) { + const messages = page.slice(0, -1) + const last = messages.at(-1) + if (last) { + const url = new URL(c.req.url) + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("after", last.info.id) + c.header("Link", `<${url.toString()}>; rel=\"next\"`) + } + return c.json(messages) + } + + return c.json(page) + } + + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + before: query.before, }) - return c.json(messages) + + if (page.length > pageLimit) { + const messages = page.slice(1) + const first = messages.at(0) + if (first) { + const url = new URL(c.req.url) + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("before", first.info.id) + c.header("Link", `<${url.toString()}>; rel=\"prev\"`) + } + return c.json(messages) + } + + return c.json(page) }, ) .get( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 22de477f8d18..758fc6b0bdfa 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -9,8 +9,7 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" - -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt, asc, gt } from "../storage/db" import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" @@ -22,7 +21,6 @@ import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" - import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" import { Global } from "@/global" @@ -513,15 +511,82 @@ export namespace Session { z.object({ sessionID: Identifier.schema("session"), limit: z.number().optional(), + before: Identifier.schema("message").optional(), + after: Identifier.schema("message").optional(), + oldest: z.boolean().optional(), }), async (input) => { - const result = [] as MessageV2.WithParts[] - for await (const msg of MessageV2.stream(input.sessionID)) { - if (input.limit && result.length >= input.limit) break - result.push(msg) + // Mutual exclusion validation (fail-fast before I/O) + if (input.before && input.after) { + throw new Error("Cannot specify both 'before' and 'after' cursors") + } + if (input.oldest && (input.before || input.after)) { + throw new Error("Cannot use 'oldest' with 'before' or 'after' cursors") } - result.reverse() - return result + + if (input.limit === 0) return [] + + const limit = input.limit + const hasCursor = !!(input.oldest || input.after || input.before) + const pageLimit = hasCursor ? (limit ?? 100) : limit + + const orderBy = input.oldest || input.after ? asc(MessageTable.id) : desc(MessageTable.id) + const cursorFilter = (() => { + if (input.after) return gt(MessageTable.id, input.after) + if (input.before) return lt(MessageTable.id, input.before) + return undefined + })() + + const rows = Database.use((db) => { + const where = cursorFilter + ? and(eq(MessageTable.session_id, input.sessionID), cursorFilter) + : eq(MessageTable.session_id, input.sessionID) + + const query = db.select().from(MessageTable).where(where).orderBy(orderBy) + if (pageLimit !== undefined) return query.limit(pageLimit).all() + return query.all() + }) + + if (rows.length === 0) return [] + + const ids = rows.map((row) => row.id) + const partsByMessage = new Map<string, MessageV2.Part[]>() + + const chunkSize = 400 + for (let i = 0; i < ids.length; i += chunkSize) { + const chunk = ids.slice(i, i + chunkSize) + const partRows = Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, chunk)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const list = partsByMessage.get(row.message_id) + if (list) list.push(part) + else partsByMessage.set(row.message_id, [part]) + } + } + + const messages = rows.map( + (row) => + ({ + info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info, + parts: partsByMessage.get(row.id) ?? [], + }) satisfies MessageV2.WithParts, + ) + + if (input.oldest || input.after) return messages + return messages.toReversed() }, ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227a..316f8208789b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -6,7 +6,7 @@ import { Identifier } from "../id/id" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" -import { Database, eq, desc, inArray } from "@/storage/db" +import { Database, eq, desc, inArray, asc, and, gt, lt } from "@/storage/db" import { MessageTable, PartTable } from "./session.sql" import { ProviderTransform } from "@/provider/transform" import { STATUS_CODES } from "http" @@ -713,58 +713,75 @@ export namespace MessageV2 { ) } - export const stream = fn(Identifier.schema("session"), async function* (sessionID) { - const size = 50 - let offset = 0 - while (true) { - const rows = Database.use((db) => - db - .select() - .from(MessageTable) - .where(eq(MessageTable.session_id, sessionID)) - .orderBy(desc(MessageTable.time_created)) - .limit(size) - .offset(offset) - .all(), - ) - if (rows.length === 0) break - - const ids = rows.map((row) => row.id) - const partsByMessage = new Map<string, MessageV2.Part[]>() - if (ids.length > 0) { - const partRows = Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const part = { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - const list = partsByMessage.get(row.message_id) - if (list) list.push(part) - else partsByMessage.set(row.message_id, [part]) + export const stream = fn( + z.union([ + Identifier.schema("session"), + z.object({ + sessionID: Identifier.schema("session"), + ascending: z.boolean().optional(), + }), + ]), + async function* (input) { + const sessionID = typeof input === "string" ? input : input.sessionID + const ascending = typeof input === "object" && input.ascending + + const limit = 50 + const order = ascending ? asc(MessageTable.id) : desc(MessageTable.id) + const descending = !ascending + let cursor: { id: string } | undefined + + while (true) { + const rows = Database.use((db) => { + const range = cursor + ? and( + eq(MessageTable.session_id, sessionID), + descending ? lt(MessageTable.id, cursor.id) : gt(MessageTable.id, cursor.id), + ) + : eq(MessageTable.session_id, sessionID) + + return db.select().from(MessageTable).where(range).orderBy(order).limit(limit).all() + }) + if (rows.length === 0) break + + const ids = rows.map((row) => row.id) + const partsByMessage = new Map<string, MessageV2.Part[]>() + if (ids.length > 0) { + const partRows = Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const list = partsByMessage.get(row.message_id) + if (list) list.push(part) + else partsByMessage.set(row.message_id, [part]) + } } - } - for (const row of rows) { - const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info - yield { - info, - parts: partsByMessage.get(row.id) ?? [], + for (const row of rows) { + const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info + yield { + info, + parts: partsByMessage.get(row.id) ?? [], + } } - } - offset += rows.length - if (rows.length < size) break - } - }) + const last = rows.at(-1) + if (!last) break + cursor = { id: last.id } + if (rows.length < limit) break + } + }, + ) export const parts = fn(Identifier.schema("message"), async (message_id) => { const rows = Database.use((db) => diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8c8cf827abaf..a5adc2e60801 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,41 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +import { Instance } from "../project/instance" + +// Track task calls per session: Map<sessionID, count> +// Budget is per-session (all calls within the delegated work count toward the limit) +// Note: State grows with sessions but entries are small. Future optimization: +// clean up completed sessions via Session lifecycle hooks if memory becomes a concern. +const taskCallState = Instance.state(() => new Map<string, number>()) + +function getCallCount(sessionID: string): number { + return taskCallState().get(sessionID) ?? 0 +} + +function incrementCallCount(sessionID: string): number { + const state = taskCallState() + const newCount = (state.get(sessionID) ?? 0) + 1 + state.set(sessionID, newCount) + return newCount +} + +/** + * Calculate session depth by walking up the parentID chain. + * Root session = depth 0, first child = depth 1, etc. + */ +async function getSessionDepth(sessionID: string): Promise<number> { + let depth = 0 + let currentID: string | undefined = sessionID + while (currentID) { + const session: Awaited<ReturnType<typeof Session.get>> | undefined = + await Session.get(currentID).catch(() => undefined) + if (!session?.parentID) break + currentID = session.parentID + depth++ + } + return depth +} const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -45,8 +80,13 @@ export const TaskTool = Tool.define("task", async (ctx) => { async execute(params: z.infer<typeof parameters>, ctx) { const config = await Config.get() + // Get caller's session to check if this is a subagent calling + const callerSession = await Session.get(ctx.sessionID) + const isSubagent = callerSession.parentID !== undefined + // Skip permission check when user explicitly invoked via @ or command subtask - if (!ctx.extra?.bypassAgentCheck) { + // BUT: always check permissions for subagent-to-subagent delegation + if (!ctx.extra?.bypassAgentCheck || isSubagent) { await ctx.ask({ permission: "task", patterns: [params.subagent_type], @@ -58,40 +98,91 @@ export const TaskTool = Tool.define("task", async (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 targetAgent = await Agent.get(params.subagent_type) + if (!targetAgent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + // Get caller agent info for budget check (ctx.agent is just the name) + const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined + + // Get config values: + // - task_budget on CALLER: how many calls the caller can make per session + const callerTaskBudget = callerAgentInfo?.task_budget ?? 0 + + // Get target's task_budget once (used for session permissions and tool availability) + const targetTaskBudget = targetAgent.task_budget ?? 0 + + // Check session ownership BEFORE incrementing budget (if task_id provided) + // This prevents "wasting" budget on invalid session resume attempts + if (isSubagent && params.task_id) { + const existingSession = await Session.get(params.task_id).catch(() => undefined) + if (existingSession && existingSession.parentID !== ctx.sessionID) { + throw new Error( + `Cannot resume session: not a child of caller session. ` + + `Session "${params.task_id}" is not owned by this caller.`, + ) + } + } + + // Enforce nested delegation controls only for subagent-to-subagent calls + if (isSubagent) { + // Check 1: Caller must have task_budget configured + if (callerTaskBudget <= 0) { + throw new Error( + `Caller has no task budget configured. ` + + `Set task_budget > 0 on the calling agent to enable nested delegation.`, + ) + } + + // Check 2: Budget not exhausted for this session + const currentCount = getCallCount(ctx.sessionID) + if (currentCount >= callerTaskBudget) { + throw new Error( + `Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` + + `Return control to caller to continue.`, + ) + } + + // Check 3: Level limit not exceeded + const levelLimit = config.experimental?.level_limit ?? 5 // Default: 5 + if (levelLimit > 0) { + const currentDepth = await getSessionDepth(ctx.sessionID) + if (currentDepth >= levelLimit) { + throw new Error( + `Level limit reached (depth ${currentDepth}/${levelLimit}). ` + + `Cannot create deeper subagent sessions. Return control to caller.` + ) + } + } - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // Increment count after passing all checks (including ownership above) + incrementCallCount(ctx.sessionID) + } const session = await iife(async () => { if (params.task_id) { const found = await Session.get(params.task_id).catch(() => {}) - if (found) return found + if (found) { + // Ownership already verified above for subagents + return found + } + } + + // Build session permissions + const sessionPermissions: PermissionNext.Rule[] = [ + { permission: "todowrite", pattern: "*", action: "deny" }, + { permission: "todoread", pattern: "*", action: "deny" }, + ] + + // Only deny task if target agent has no task_budget (cannot delegate further) + if (targetTaskBudget <= 0) { + sessionPermissions.push({ permission: "task", pattern: "*", action: "deny" }) } return await Session.create({ parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, + title: params.description + ` (@${targetAgent.name} subagent)`, permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...sessionPermissions, ...(config.experimental?.primary_tools?.map((t) => ({ pattern: "*", action: "allow" as const, @@ -103,7 +194,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const model = agent.model ?? { + const model = targetAgent.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -132,11 +223,12 @@ export const TaskTool = Tool.define("task", async (ctx) => { modelID: model.modelID, providerID: model.providerID, }, - agent: agent.name, + agent: targetAgent.name, tools: { todowrite: false, todoread: false, - ...(hasTaskPermission ? {} : { task: false }), + // Only disable task if target agent has no task_budget (cannot delegate further) + ...(targetTaskBudget <= 0 ? { task: false } : {}), ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, diff --git a/packages/opencode/src/util/link-header.ts b/packages/opencode/src/util/link-header.ts new file mode 100644 index 000000000000..3b2ad109f73e --- /dev/null +++ b/packages/opencode/src/util/link-header.ts @@ -0,0 +1,27 @@ +/** + * Parse RFC 8288 Link header into a map of rel -> URL + * @see https://www.rfc-editor.org/rfc/rfc8288 + */ +export function parseLinkHeader(header: string): Record<string, string> { + if (!header) return {} + try { + const links: Record<string, string> = {} + const parts = header.split(/,(?=\s*<)/) + for (const part of parts) { + const section = part.split(";") + if (section.length < 2) continue + const url = section[0].replace(/<(.*?)>/, "$1").trim() + + for (const attr of section.slice(1)) { + const match = attr.match(/rel=["']?(?<rel>[^"']+)["']?/) + if (match?.groups?.rel) { + links[match.groups.rel.trim()] = url + break + } + } + } + return links + } catch { + return {} + } +} diff --git a/packages/opencode/test/cli/tui/pagination-helpers.test.ts b/packages/opencode/test/cli/tui/pagination-helpers.test.ts new file mode 100644 index 000000000000..512eeca52719 --- /dev/null +++ b/packages/opencode/test/cli/tui/pagination-helpers.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test" +import { + edgeHints, + evictFromEnd, + evictFromStart, + olderScrollTarget, + paginationError, + queueBoundaryLoad, + windowNewest, + windowOldest, +} from "../../../src/cli/cmd/tui/util/pagination" +import type { Message } from "@opencode-ai/sdk/v2" + +const make = (ids: string[]) => + ids.map( + (id) => + ({ + id, + sessionID: "ses_test", + role: "user", + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) as Message, + ) + +describe("tui pagination helpers", () => { + test("window bounds skip pinned message", () => { + const messages = make(["m1", "m2", "m3", "m4"]) + expect(windowOldest(messages, "m1")).toBe("m2") + expect(windowNewest(messages, "m4")).toBe("m3") + }) + + test("evictFromStart skips pinned messages", () => { + const messages = make(["m1", "m2", "m3", "m4", "m5"]) + const evicted = evictFromStart(messages, 2, "m2") + expect(evicted.map((m) => m.id)).toEqual(["m1", "m3"]) + expect(messages.map((m) => m.id)).toEqual(["m2", "m4", "m5"]) + }) + + test("evictFromEnd skips pinned messages", () => { + const messages = make(["m1", "m2", "m3", "m4", "m5"]) + const evicted = evictFromEnd(messages, 2, "m4") + expect(evicted.map((m) => m.id)).toEqual(["m5", "m3"]) + expect(messages.map((m) => m.id)).toEqual(["m1", "m2", "m4"]) + }) + + test("paginationError reads object message fields", () => { + expect(paginationError({ message: "timeout" })).toBe("timeout") + expect(paginationError({ error: { message: "denied" } })).toBe("denied") + }) + + test("paginationError avoids [object Object] fallback", () => { + expect(paginationError({ foo: "bar" })).not.toBe("[object Object]") + }) + + test("queueBoundaryLoad routes direction by delta", () => { + let older = 0 + let newer = 0 + const queue = (run: () => void) => run() + queueBoundaryLoad( + -1, + () => older++, + () => newer++, + queue, + ) + queueBoundaryLoad( + 1, + () => older++, + () => newer++, + queue, + ) + queueBoundaryLoad( + 0, + () => older++, + () => newer++, + queue, + ) + expect(older).toBe(1) + expect(newer).toBe(1) + }) + + test("edgeHints computes nearTop and nearBottom", () => { + expect(edgeHints(0, 300, 100, 20)).toEqual({ nearTop: true, nearBottom: false }) + expect(edgeHints(200, 300, 100, 20)).toEqual({ nearTop: false, nearBottom: true }) + }) + + test("olderScrollTarget prefers anchor child", () => { + const top = olderScrollTarget( + [ + { id: "m1", y: 80, height: 20 }, + { id: "m2", y: 120, height: 20 }, + ], + 500, + 400, + 0, + { id: "m1", offset: 3 }, + ) + expect(top).toBe(83) + }) + + test("olderScrollTarget falls back to delta", () => { + const top = olderScrollTarget([{ id: "m1", y: 80, height: 20 }], 500, 400, 25, { + id: "missing", + offset: 0, + }) + expect(top).toBe(125) + }) + + test("olderScrollTarget can keep viewport unchanged", () => { + const top = olderScrollTarget([], 400, 400, 10) + expect(top).toBeUndefined() + }) + + test("anchor restore moves edge state away from top", () => { + const top = olderScrollTarget([{ id: "m1", y: 60, height: 20 }], 500, 400, 0, { + id: "m1", + offset: 0, + }) + expect(top).toBe(60) + expect(edgeHints(top!, 500, 100, 20).nearTop).toBe(false) + }) + + test("command scroll flow updates top hint after older load restore", () => { + let older = 0 + let newer = 0 + const queue = (run: () => void) => run() + + expect(edgeHints(0, 320, 100, 20)).toEqual({ nearTop: true, nearBottom: false }) + + queueBoundaryLoad( + -40, + () => older++, + () => newer++, + queue, + ) + + const top = olderScrollTarget([{ id: "a", y: 55, height: 10 }], 460, 320, 0, { + id: "a", + offset: 0, + }) + + expect(older).toBe(1) + expect(newer).toBe(0) + expect(top).toBe(55) + expect(edgeHints(top!, 460, 100, 20)).toEqual({ nearTop: false, nearBottom: false }) + }) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts new file mode 100644 index 000000000000..bbd65ae9fd3b --- /dev/null +++ b/packages/opencode/test/server/session-messages.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { Identifier } from "../../src/id/id" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +const password = process.env.OPENCODE_SERVER_PASSWORD +const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" +const auth = password ? "Basic " + Buffer.from(`${username}:${password}`).toString("base64") : undefined + +const request = (app: ReturnType<typeof Server.App>, url: string) => { + if (!auth) return app.request(url) + return app.request(url, { headers: { Authorization: auth } }) +} + +const TEST_TIMEOUT_MS = 30_000 + +describe("session.messages API", () => { + test( + "returns 400 when both before and after specified", + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + const response = await request(app, `/session/${session.id}/message?before=msg_01ABC&after=msg_01XYZ`) + + expect(response.status).toBe(400) + const body = (await response.json()) as { error: string } + expect(body.error).toContain("Cannot specify both") + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test("includes Link header with rel=prev when more pages exist (before cursor)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages + for (let i = 0; i < 5; i++) { + await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + } + + // Request with limit=2 (should have more) + const response = await request(app, `/session/${session.id}/message?limit=2`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toContain('rel="prev"') + expect(link).toContain("before=") + }, + }) + }) + + test("includes Link header with rel=next when using after cursor with more pages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + ids.push(msg.id) + } + + // Request after first message with limit=2 + const response = await request(app, `/session/${session.id}/message?after=${ids[0]}&limit=2`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toContain('rel="next"') + expect(link).toContain("after=") + }, + }) + }) + + test("omits Link header when no more pages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 2 messages + for (let i = 0; i < 2; i++) { + await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + } + + // Request with limit=10 (more than available) + const response = await request(app, `/session/${session.id}/message?limit=10`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toBeNull() + }, + }) + }) + + test("returns 400 when oldest used with before or after", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + const response1 = await request(app, `/session/${session.id}/message?oldest=true&before=msg_01ABC`) + expect(response1.status).toBe(400) + const body1 = (await response1.json()) as { error: string } + expect(body1.error).toContain("Cannot use 'oldest' with") + + const response2 = await request(app, `/session/${session.id}/message?oldest=true&after=msg_01XYZ`) + expect(response2.status).toBe(400) + const body2 = (await response2.json()) as { error: string } + expect(body2.error).toContain("Cannot use 'oldest' with") + }, + }) + }) + + test("oldest=true returns messages in ascending order with rel=next Link", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + ids.push(msg.id) + } + + // Request oldest with limit=2 (should have more pages) + const response = await request(app, `/session/${session.id}/message?oldest=true&limit=2`) + + expect(response.status).toBe(200) + const messages = (await response.json()) as Array<{ info: { id: string } }> + expect(messages.length).toBe(2) + // Oldest messages should be first (ascending order) + expect(messages[0].info.id).toBe(ids[0]) + expect(messages[1].info.id).toBe(ids[1]) + + const link = response.headers.get("Link") + expect(link).toContain('rel="next"') + expect(link).toContain("after=") + expect(link).not.toContain("oldest=") // oldest param stripped on subsequent pages + }, + }) + }) + + test("limit=0 returns empty results", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + for (let i = 0; i < 3; i++) { + await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + } + + const response = await request(app, `/session/${session.id}/message?limit=0`) + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual([]) + expect(response.headers.get("Link")).toBeNull() + }, + }) + }) +}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 479be4a17f8c..7c998969af89 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -8,6 +8,20 @@ import { Server } from "../../src/server/server" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +const password = process.env.OPENCODE_SERVER_PASSWORD +const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" +const auth = password ? "Basic " + Buffer.from(`${username}:${password}`).toString("base64") : undefined + +const request = (app: ReturnType<typeof Server.App>, url: string, body: Record<string, unknown>) => { + const headers: Record<string, string> = { "Content-Type": "application/json" } + if (auth) headers.Authorization = auth + return app.request(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }) +} + describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await Instance.provide({ @@ -18,11 +32,7 @@ describe("tui.selectSession endpoint", () => { // #when const app = Server.App() - const response = await app.request("/tui/select-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) + const response = await request(app, "/tui/select-session", { sessionID: session.id }) // #then expect(response.status).toBe(200) @@ -43,11 +53,7 @@ describe("tui.selectSession endpoint", () => { // #when const app = Server.App() - const response = await app.request("/tui/select-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionID: nonExistentSessionID }), - }) + const response = await request(app, "/tui/select-session", { sessionID: nonExistentSessionID }) // #then expect(response.status).toBe(404) @@ -64,11 +70,7 @@ describe("tui.selectSession endpoint", () => { // #when const app = Server.App() - const response = await app.request("/tui/select-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionID: invalidSessionID }), - }) + const response = await request(app, "/tui/select-session", { sessionID: invalidSessionID }) // #then expect(response.status).toBe(400) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts new file mode 100644 index 000000000000..368d22409cf0 --- /dev/null +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Identifier } from "../../src/id/id" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const TEST_TIMEOUT_MS = 30_000 + +describe("session messages pagination", () => { + test( + "should paginate messages correctly", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageCount = 10 + const messageIds: string[] = [] + + // Create 10 messages + for (let i = 0; i < messageCount; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + // time is optional/handled by default, ULID handles ordering + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID, + type: "text", + text: `Message ${i}`, + }) + } + + // 1. Initial load (limit 3) -> should get last 3 (7, 8, 9) + const page1 = await Session.messages({ + sessionID, + limit: 3, + }) + expect(page1.length).toBe(3) + expect(page1[0].info.id).toBe(messageIds[7]) + expect(page1[2].info.id).toBe(messageIds[9]) + + // 2. Load before page1[0] (limit 3) -> should get 4, 5, 6 + const page2 = await Session.messages({ + sessionID, + limit: 3, + before: page1[0].info.id, + }) + expect(page2.length).toBe(3) + expect(page2[0].info.id).toBe(messageIds[4]) + expect(page2[2].info.id).toBe(messageIds[6]) + + // 3. Load before page2[0] (limit 3) -> should get 1, 2, 3 + const page3 = await Session.messages({ + sessionID, + limit: 3, + before: page2[0].info.id, + }) + expect(page3.length).toBe(3) + expect(page3[0].info.id).toBe(messageIds[1]) + expect(page3[2].info.id).toBe(messageIds[3]) + + // 4. Load before page3[0] (limit 3) -> should get 0 (and only 1 message) + const page4 = await Session.messages({ + sessionID, + limit: 3, + before: page3[0].info.id, + }) + expect(page4.length).toBe(1) + expect(page4[0].info.id).toBe(messageIds[0]) + + // 5. Load before page4[0] -> should be empty + const page5 = await Session.messages({ + sessionID, + limit: 3, + before: page4[0].info.id, + }) + expect(page5.length).toBe(0) + + // 6. Test boundary: exact match (before message 9, should get 0..8) + // Wait, 'before' filters out the cursor itself. + // If IDs are [0..9]. before=ids[9]. + // Should get ids[0..8]. Length 9. + const exact = await Session.messages({ + sessionID, + limit: 10, + before: messageIds[9], + }) + expect(exact.length).toBe(9) + expect(exact[8].info.id).toBe(messageIds[8]) + + // 7. Test boundary: unknown cursor (lexicographically larger) + const unknownFuture = "msg" + "z".repeat(26) + const pageFuture = await Session.messages({ + sessionID, + limit: 3, + before: unknownFuture, + }) + expect(pageFuture.length).toBe(3) + expect(pageFuture[2].info.id).toBe(messageIds[9]) + + // 8. Test boundary: unknown cursor (lexicographically smaller) + const unknownPast = "msg" + "0".repeat(26) + const pagePast = await Session.messages({ + sessionID, + limit: 3, + before: unknownPast, + }) + expect(pagePast.length).toBe(0) + + // 9. Test concurrent load + const [res1, res2] = await Promise.all([ + Session.messages({ sessionID, limit: 3, before: page1[0].info.id }), + Session.messages({ sessionID, limit: 3, before: page1[0].info.id }), + ]) + + expect(res1[0].info.id).toBe(res2[0].info.id) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "handles deleted message during pagination", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageIds: string[] = [] + + // Create 10 messages + for (let i = 0; i < 10; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // Get first page (messages 6-10) + const page1 = await Session.messages({ sessionID, limit: 5 }) + expect(page1.length).toBe(5) + expect(page1[4].info.id).toBe(messageIds[9]) // Last message is most recent + + // Delete message 3 (which would be in the next page, index 2) + await Session.removeMessage({ sessionID, messageID: messageIds[2] }) + + // Request next page with cursor from page1 + const page2 = await Session.messages({ sessionID, limit: 5, before: page1[0].info.id }) + + // Verify remaining messages are returned without error + // Should get 0, 1, 3, 4 (since 2 was deleted) = 4 messages + // OR 0, 1, 3, 4 + one more if available? No, limit applies to ID list which is stale? + // Storage.list is re-run, so index 2 is gone. + // IDs: [0, 1, 3, 4, 5, 6, 7, 8, 9] + // Cursor: before 5 (index 4 in new list) + // binaryLowerBound(5) -> index 4 + // start = 3 + // loop: 3, 2, 1, 0 -> IDs[3]=4, IDs[2]=3, IDs[1]=1, IDs[0]=0 + expect(page2.length).toBe(4) + expect(page2.map((m) => m.info.id)).toEqual([messageIds[0], messageIds[1], messageIds[3], messageIds[4]]) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "message IDs are lexicographically sorted (ULID invariant)", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const messageIds: string[] = [] + + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // Verify IDs are lexicographically sorted (ULID invariant for binary search) + for (let i = 1; i < messageIds.length; i++) { + expect(messageIds[i] > messageIds[i - 1]).toBe(true) + } + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "after cursor returns messages after cursor (ascending)", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageIds: string[] = [] + + for (let i = 0; i < 10; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // after=msg[2] should return msg[3], msg[4], msg[5] (limit 3) + const page1 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[2], + }) + expect(page1.length).toBe(3) + expect(page1[0].info.id).toBe(messageIds[3]) + expect(page1[1].info.id).toBe(messageIds[4]) + expect(page1[2].info.id).toBe(messageIds[5]) + + // after=msg[8] should return msg[9] only + const page2 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[8], + }) + expect(page2.length).toBe(1) + expect(page2[0].info.id).toBe(messageIds[9]) + + // after=msg[9] (last) should return empty + const page3 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[9], + }) + expect(page3.length).toBe(0) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "cannot specify both before and after cursors", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + + await expect( + Session.messages({ + sessionID, + limit: 3, + before: msg.id, + after: msg.id, + }), + ).rejects.toThrow("Cannot specify both") + }, + }) + }, + TEST_TIMEOUT_MS, + ) +}) diff --git a/packages/opencode/test/task-delegation.test.ts b/packages/opencode/test/task-delegation.test.ts new file mode 100644 index 000000000000..35084a9810fc --- /dev/null +++ b/packages/opencode/test/task-delegation.test.ts @@ -0,0 +1,244 @@ +import { describe, test, expect } from "bun:test" +import { Config } from "../src/config/config" +import { Instance } from "../src/project/instance" +import { Agent } from "../src/agent/agent" +import { PermissionNext } from "../src/permission/next" +import { tmpdir } from "./fixture/fixture" + +describe("task_budget configuration (caller)", () => { + test("task_budget is preserved from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + orchestrator: { + description: "Agent with high task budget", + mode: "subagent", + task_budget: 20, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["orchestrator"] + expect(agentConfig?.task_budget).toBe(20) + }, + }) + }) + + test("task_budget of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "disabled-agent": { + description: "Agent with explicitly disabled budget", + mode: "subagent", + task_budget: 0, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["disabled-agent"] + expect(agentConfig?.task_budget).toBe(0) + }, + }) + }) + + test("missing task_budget defaults to undefined (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "default-agent": { + description: "Agent without task_budget", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["default-agent"] + expect(agentConfig?.task_budget).toBeUndefined() + }, + }) + }) +}) + +describe("task_budget with permissions config", () => { + test("task_budget with permission rules for selective delegation", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + orchestrator: { + description: "Coordinates other subagents", + mode: "subagent", + task_budget: 20, + permission: { + task: { + "*": "deny", + "worker-a": "allow", + "worker-b": "allow", + }, + }, + }, + "worker-a": { + description: "Worker with medium budget", + mode: "subagent", + task_budget: 3, + permission: { + task: { + "*": "deny", + "worker-b": "allow", + }, + }, + }, + "worker-b": { + description: "Worker with minimal budget", + mode: "subagent", + task_budget: 1, + permission: { + task: { + "*": "deny", + "worker-a": "allow", + }, + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + + // Orchestrator: high budget + const orchestratorConfig = config.agent?.["orchestrator"] + expect(orchestratorConfig?.task_budget).toBe(20) + + // Verify permission rules + const orchestratorRuleset = PermissionNext.fromConfig(orchestratorConfig?.permission ?? {}) + expect(PermissionNext.evaluate("task", "worker-a", orchestratorRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "worker-b", orchestratorRuleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator", orchestratorRuleset).action).toBe("deny") + + // Worker-A: medium budget + const workerAConfig = config.agent?.["worker-a"] + expect(workerAConfig?.task_budget).toBe(3) + + // Worker-B: minimal budget + const workerBConfig = config.agent?.["worker-b"] + expect(workerBConfig?.task_budget).toBe(1) + }, + }) + }) +}) + +describe("backwards compatibility", () => { + test("agent without delegation config has defaults (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + "legacy-agent": { + description: "Agent without delegation config", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agentConfig = config.agent?.["legacy-agent"] + + // Should be undefined/falsy = delegation disabled + const taskBudget = (agentConfig?.task_budget as number) ?? 0 + + expect(taskBudget).toBe(0) + }, + }) + }) + + test("built-in agents should not have delegation config by default", async () => { + await using tmp = await tmpdir({ + git: true, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Get the built-in general agent + const generalAgent = await Agent.get("general") + + // Built-in agents should not have delegation configured + const taskBudget = generalAgent?.task_budget ?? 0 + + expect(taskBudget).toBe(0) + }, + }) + }) +}) + +describe("level_limit configuration", () => { + test("level_limit is preserved from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + level_limit: 8, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBe(8) + }, + }) + }) + + test("level_limit defaults to undefined when not set (implementation defaults to 5)", async () => { + await using tmp = await tmpdir({ + git: true, + config: {}, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBeUndefined() + }, + }) + }) + + test("level_limit of 0 is preserved (disabled)", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + experimental: { + level_limit: 0, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.experimental?.level_limit).toBe(0) + }, + }) + }) +}) diff --git a/packages/opencode/test/util/parse-link-header.test.ts b/packages/opencode/test/util/parse-link-header.test.ts new file mode 100644 index 000000000000..b1e316f21199 --- /dev/null +++ b/packages/opencode/test/util/parse-link-header.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { parseLinkHeader } from "../../src/util/link-header" + +describe("util.parseLinkHeader", () => { + test("returns empty object for empty string", () => { + expect(parseLinkHeader("")).toEqual({}) + }) + + test("returns empty object for undefined-ish input", () => { + expect(parseLinkHeader(undefined as unknown as string)).toEqual({}) + }) + + test("parses single link with rel", () => { + const header = '<https://api.example.com/items?page=2>; rel="next"' + expect(parseLinkHeader(header)).toEqual({ + next: "https://api.example.com/items?page=2", + }) + }) + + test("parses multiple links", () => { + const header = + '<https://api.example.com/items?page=1>; rel="prev", <https://api.example.com/items?page=3>; rel="next"' + expect(parseLinkHeader(header)).toEqual({ + prev: "https://api.example.com/items?page=1", + next: "https://api.example.com/items?page=3", + }) + }) + + test("handles unquoted rel values", () => { + const header = "<https://example.com>; rel=next" + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) + + test("handles single-quoted rel values", () => { + const header = "<https://example.com>; rel='next'" + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) + + test("ignores links without rel attribute", () => { + const header = "<https://example.com>; type=text/html" + expect(parseLinkHeader(header)).toEqual({}) + }) + + test("handles malformed input gracefully", () => { + expect(parseLinkHeader("not a valid header")).toEqual({}) + expect(parseLinkHeader("<<>>;;")).toEqual({}) + }) + + test("handles extra whitespace", () => { + const header = ' <https://example.com> ; rel="next" ' + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6165c0f7b096..a7d780969f8c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1478,11 +1478,14 @@ export class Session2 extends HeyApiClient { * * Retrieve all messages in a session, including user prompts and AI responses. */ - public messages<ThrowOnError extends boolean = false>( + public messages<ThrowOnError extends boolean = false>( parameters: { sessionID: string directory?: string limit?: number + before?: string + after?: string + oldest?: string }, options?: Options<never, ThrowOnError>, ) { @@ -1494,6 +1497,9 @@ export class Session2 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "limit" }, + { in: "query", key: "before" }, + { in: "query", key: "after" }, + { in: "query", key: "oldest" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index be6c00cf4457..12d61efba7df 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3069,6 +3069,9 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + before?: string + after?: string + oldest?: string } url: "/session/{sessionID}/message" } diff --git a/packages/util/src/binary.ts b/packages/util/src/binary.ts index 3d8f61851aef..048b6bc371b5 100644 --- a/packages/util/src/binary.ts +++ b/packages/util/src/binary.ts @@ -38,4 +38,19 @@ export namespace Binary { array.splice(left, 0, item) return array } + + /** + * Find the first index where array[index] >= target (lower bound). + * For string arrays ordered lexicographically (e.g., ULIDs). + */ + export function lowerBound(array: string[], target: string): number { + let left = 0 + let right = array.length + while (left < right) { + const mid = (left + right) >>> 1 + if (array[mid] < target) left = mid + 1 + else right = mid + } + return left + } }